Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/cli/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,10 @@ const stepMainOptions: MainOptions = {
agentDir: resolveStepAgentDir(),
modelsPath: join(resolveStepConfigRoot(), "models.json"),
configDirName: resolveStepConfigDir(),
// No cwd here: prepareMain resolves options.cwd ?? parseArgs(args).cwd ?? process.cwd()
// from the final argv it actually executes. Passing a value parsed from the early
// process.argv snapshot would create a second source of truth that can diverge
// from the rewritten argv (config defaults, step command translation, defaults).
sessionManagerFactory: createStepSessionManagerFactory(resolveStepAgentDir()),
settingsManagerFactory: (cwd, agentDir, options) => {
const stepManager = createStepSettingsManager(cwd, agentDir, options);
Expand Down
9 changes: 7 additions & 2 deletions apps/cli/src/ui/startup-ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ const STARTUP_PRESENTATION: "native" | "step" = IS_STEP_ENTRYPOINT ? "step" : "n
export interface StartupTuiPathOptions {
agentDir?: string;
configDirName?: string;
/** Working directory the startup UI discovers project-level resources against. Default: process.cwd() */
cwd?: string;
}

function loadThemes(resources: ResolvedResource[]): Theme[] {
Expand All @@ -53,15 +55,17 @@ function loadThemes(resources: ResolvedResource[]): Theme[] {
return themes;
}

async function loadStartupThemes(
export async function loadStartupThemes(
settingsManager: SettingsManager,
paths: Required<StartupTuiPathOptions>,
): Promise<Theme[]> {
const globalSettingsManager = SettingsManager.inMemory(settingsManager.getGlobalSettings(), {
projectTrusted: false,
});
const packageManager = new DefaultPackageManager({
cwd: process.cwd(),
// Discover project-level themes against the resolved working directory, not the
// launcher's process.cwd(), so `step -C <dir>` pickers list <dir>'s themes.
cwd: paths.cwd,
agentDir: paths.agentDir,
configDirName: paths.configDirName,
settingsManager: globalSettingsManager,
Expand All @@ -77,6 +81,7 @@ export async function createStartupTui(
const resolvedPaths: Required<StartupTuiPathOptions> = {
agentDir: paths.agentDir ?? (IS_STEP_ENTRYPOINT ? resolveStepAgentDir() : getAgentDir()),
configDirName: paths.configDirName?.trim() || CONFIG_DIR_NAME,
cwd: paths.cwd ?? process.cwd(),
};
// Theme helpers have a product-level custom-theme directory context in
// addition to the resource loader's explicit paths. Bind it before any
Expand Down
45 changes: 45 additions & 0 deletions apps/cli/test/startup-themes-cwd.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { describe, expect, test, vi } from "vitest";
import type { SettingsManager } from "../../../packages/coding-agent/src/core/settings-manager.ts";

const packageManagerMocks = vi.hoisted(() => {
const instance = { resolve: vi.fn<(fn: () => Promise<string>) => Promise<{ themes: never[] }>>() };
return { instance, ctor: vi.fn<(options: { cwd: string }) => typeof instance>() };
});

// Capture the cwd the startup theme discovery builds its package manager with.
vi.mock("@step-harness/coding-agent", async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>;
return {
...actual,
DefaultPackageManager: class {
constructor(options: { cwd: string }) {
packageManagerMocks.ctor(options);
return packageManagerMocks.instance as unknown as this;
}
},
};
});

const { loadStartupThemes } = await import("../src/ui/startup-ui.ts");

function fakeSettingsManager(): SettingsManager {
return {
getGlobalSettings: () => ({}),
} as unknown as SettingsManager;
}

describe("loadStartupThemes discovery base", () => {
test("discovers project-level themes against the resolved cwd, not the launcher", async () => {
packageManagerMocks.instance.resolve.mockResolvedValue({ themes: [] });

await loadStartupThemes(fakeSettingsManager(), {
agentDir: "/agent",
configDirName: ".stepcode",
cwd: "/target/project",
});

expect(packageManagerMocks.ctor).toHaveBeenCalledWith(
expect.objectContaining({ cwd: "/target/project" }),
);
});
});
32 changes: 32 additions & 0 deletions packages/coding-agent/docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,9 +200,41 @@ cat README.md | step -p "Summarize this text"
| `--session <path\|id>` | Use a specific session file or partial UUID |
| `--fork <path\|id>` | Fork a session file or partial UUID into a new session |
| `--session-dir <dir>` | Custom session storage directory |
| `-C`, `--cwd <dir>` | Working directory for this run (defaults to the process cwd) |
| `--no-session` | Ephemeral mode; do not save |
| `--name <name>`, `-n <name>` | Set session display name at startup |

#### Working directory (`-C` / `--cwd`)

`-C <dir>` sets the working directory for the whole run: settings, session storage
and lookup, resource loading (extensions, skills, prompt templates, themes) and
tools all resolve against it. A nonexistent path, or a path that is not a
directory, fails fast with exit code 1.

Interaction with the session flags:

- New sessions and `-c` / `--continue` start in `<dir>`, and continue looks for
the most recent session of `<dir>`.
- `--fork <path|id>` forks into `<dir>`.
- `--session <path>` / `--session <id>` opens the session as-is: the cwd stored
in the session header wins, and `-C` only applies if that directory no longer
exists (you are then prompted to pick one). A session id found under a
different project is offered for forking into `<dir>`.
- `--session-dir` is orthogonal: it relocates storage, not work. A relative
`--session-dir` resolves against the launcher's directory, like any other
storage path.

CLI-side relative paths (`@file` arguments, `-e` / `--skill` /
`--prompt-template` / `--theme`, and a relative `--export` output) resolve
against `<dir>`, matching `git -C` expectations. The `--flag=value` form is
reserved for extension flags; startup flags take a space-separated value
(`--cwd <dir>`).

`-C` is a startup flag: top-level subcommands (`mcp`, `login`, `feedback`,
`session-index`, ...) are detected from the first argument, so `step -C <dir>
<subcommand>` runs the subcommand, not a prompt, and the subcommand does not
honour `-C`.

### Tool Options

| Option | Description |
Expand Down
9 changes: 9 additions & 0 deletions packages/coding-agent/src/cli/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ export interface Args {
sessionId?: string;
fork?: string;
sessionDir?: string;
/** Working directory override. Defaults to process.cwd() when unset. */
cwd?: string;
models?: string[];
tools?: string[];
excludeTools?: string[];
Expand Down Expand Up @@ -391,6 +393,12 @@ export function parseArgs(args: string[]): Args {
result.sessionDir = taken.value;
i = taken.nextIndex;
}
} else if (arg === "--cwd" || arg === "-C") {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two small parser questions:

  • --cwd=/abs/path is not handled, so it falls through to the arg.startsWith("--") branch and is recorded as unknownFlags.set("cwd", "/abs/path") — i.e. treated as an extension flag. Either support the = form like --completion-check= does, or state explicitly that = is reserved for extension flags.
  • An empty value (--cwd "") is accepted and later becomes the process cwd through resolvePath, which is an easy typo to miss in a launcher script. Worth rejecting alongside the existence check.

const taken = takeOptionValue(args, i, "--cwd", result);
if (taken) {
result.cwd = taken.value;
i = taken.nextIndex;
}
} else if (arg === "--models") {
const taken = takeOptionValue(args, i, "--models", result);
if (taken) {
Expand Down Expand Up @@ -673,6 +681,7 @@ ${stepPermissionOptionsText}
--session-id <id> Use exact project session ID, creating it if missing
--fork <path|id> Fork specific session file or partial UUID into a new session
--session-dir <dir> Directory for session storage and lookup
--cwd, -C <dir> Working directory (defaults to the process cwd)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ordering nit: neighbours here and in docs/usage.md are short-alias-first (--name, -n <name>, -c, --continue). -C, --cwd <dir> would match. Also packages/coding-agent/docs/usage.md:194 (Session Options) still has no row for this flag — the CLI surface changed, so the docs table needs the entry, plus the --session/--resume and --session-dir precedence notes.

--no-session Don't save session (ephemeral)
--name, -n <name> Set session display name
--models <patterns> Comma-separated model patterns for Ctrl+P cycling
Expand Down
9 changes: 7 additions & 2 deletions packages/coding-agent/src/cli/file-processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ export interface ProcessedFiles {
export interface ProcessFileOptions {
/** Whether to auto-resize images to 2000x2000 max. Default: true */
autoResizeImages?: boolean;
/** Base directory for relative @file paths. Default: process.cwd() */
baseDir?: string;
}

/** Process @file arguments into text content and image attachments */
Expand All @@ -28,8 +30,11 @@ export async function processFileArguments(fileArgs: string[], options?: Process
const images: ImageContent[] = [];

for (const fileArg of fileArgs) {
// Expand and resolve path (handles ~ expansion and macOS screenshot Unicode spaces)
const absolutePath = resolve(resolveReadPath(fileArg, process.cwd()));
// Expand and resolve path (handles ~ expansion and macOS screenshot Unicode spaces).
// Relative paths resolve against baseDir so they share one base with the other
// CLI-side paths (-e/--skill/--prompt-template/--theme), which is what
// `step -C <dir>` users expect; baseDir defaults to the launcher cwd.
const absolutePath = resolve(resolveReadPath(fileArg, options?.baseDir ?? process.cwd()));

// Check if file exists
try {
Expand Down
5 changes: 4 additions & 1 deletion packages/coding-agent/src/core/session-export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,12 @@ export function exportSessionToJsonl(
outputPath?: string,
createTrailingEntries?: (parentId: string | null, timestamp: string) => readonly object[],
): string {
// Resolve a relative --export path against the session's working directory, the same
// base the other CLI-side paths (@file, -e/--skill/...) use, so `step -C <dir> --export out.jsonl`
// lands in <dir> rather than the launcher directory.
const filePath = resolvePath(
outputPath ?? `session-${new Date().toISOString().replace(/[:.]/g, "-")}.jsonl`,
process.cwd(),
sessionManager.getCwd(),
);
const dir = dirname(filePath);
if (!existsSync(dir)) {
Expand Down
49 changes: 44 additions & 5 deletions packages/coding-agent/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
* createAgentSession() options. The SDK does the heavy lifting.
*/

import { existsSync, statSync } from "node:fs";
import { join } from "node:path";
import { createInterface } from "node:readline";
import { setCapabilityOverrides } from "@step-harness/pi-tui";
Expand Down Expand Up @@ -306,6 +307,7 @@ function assertAllowedAuthProvider(
async function prepareInitialMessage(
parsed: Args,
autoResizeImages: boolean,
baseDir: string,
stdinContent?: string,
): Promise<{
initialMessage?: string;
Expand All @@ -317,6 +319,7 @@ async function prepareInitialMessage(

const { text, images } = await processFileArguments(parsed.fileArgs, {
autoResizeImages,
baseDir,
});
return buildInitialMessage({
parsed,
Expand Down Expand Up @@ -721,6 +724,11 @@ async function promptForMissingSessionCwd(

export interface MainOptions {
extensionFactories?: InlineExtension[];
/**
* Working directory override. When set, the session, settings discovery,
* and resource loading all key off this path instead of process.cwd().
*/
cwd?: string;
/** Global agent directory for this product instance. */
agentDir?: string;
/** Project resource directory name for this product instance. */
Expand Down Expand Up @@ -863,13 +871,39 @@ export type MainPreparation =
export async function prepareMain(args: string[], options?: MainOptions): Promise<MainPreparation> {
const extensionFactories = [...builtInExtensions, ...(options?.extensionFactories ?? [])];
const configDirName = options?.configDirName?.trim() || CONFIG_DIR_NAME;
const cwd = process.cwd();
// Parse argv up front so an explicit --cwd participates in resolution at every
// entrypoint: coding-agent's own main() and SDK callers pass no options.cwd, so
// without this the flag would parse, print in --help, and then be ignored.
const parsed = parseArgs(args);
const requestedCwd = options?.cwd ?? parsed.cwd;
// An empty value is an easy typo to miss in a launcher script: resolvePath("") would
// silently normalize it to the process cwd and every check below would pass.
if (requestedCwd !== undefined && requestedCwd.trim() === "") {
console.error(chalk.red("Error: --cwd requires a non-empty path"));
return { kind: "completed", exitCode: 1 };
}
const cwd = resolvePath(requestedCwd ?? process.cwd());
if (requestedCwd !== undefined) {
// Fail fast like `git -C`: a missing or non-directory --cwd must not reach the
// session layer, where getMissingSessionCwdIssue would report the just-supplied
// path as a stale session-file cwd (and --no-session would skip the check entirely).
if (!existsSync(cwd)) {
console.error(chalk.red(`Error: --cwd path does not exist: ${cwd}`));
return { kind: "completed", exitCode: 1 };
}
if (!statSync(cwd).isDirectory()) {
console.error(chalk.red(`Error: --cwd path is not a directory: ${cwd}`));
return { kind: "completed", exitCode: 1 };
}
}
const agentDir = options?.agentDir ? resolvePath(options.agentDir) : getAgentDir();
// A computed default agent directory is also used by ordinary Pi. Only an
// explicitly supplied product path opts into the Step storage facade; this
// keeps Pi's no-argument SessionManager.listAll/continue semantics intact.
const productStoragePaths: StartupTuiPathOptions | undefined =
options?.agentDir !== undefined || options?.configDirName !== undefined ? { agentDir, configDirName } : undefined;
options?.agentDir !== undefined || options?.configDirName !== undefined
? { agentDir, configDirName, cwd }
: undefined;
const authPath = options?.authPath
? resolvePath(options.authPath, cwd)
: options?.agentDir
Expand Down Expand Up @@ -935,7 +969,6 @@ export async function prepareMain(args: string[], options?: MainOptions): Promis
return { kind: "completed" };
}

const parsed = parseArgs(args);
if (parsed.diagnostics.length > 0) {
for (const d of parsed.diagnostics) {
const color = d.type === "error" ? chalk.red : chalk.yellow;
Expand Down Expand Up @@ -1044,7 +1077,9 @@ export async function prepareMain(args: string[], options?: MainOptions): Promis
cwd,
sessionDir,
startupSettingsManager,
productStoragePaths,
// Always hand the resolved cwd to the startup selectors so project-level
// discovery (themes, prompts) follows -C even when no storage facade paths apply.
productStoragePaths ?? { cwd },
sessionRoot,
options?.uiHooks?.selectSession,
);
Expand Down Expand Up @@ -1154,7 +1189,10 @@ export async function prepareMain(args: string[], options?: MainOptions): Promis
mode: isInitialRuntime ? trustPromptMode : appMode,
settingsManager: startupSettingsManager,
hasUI: isInitialRuntime && trustPromptMode === "interactive",
paths: { agentDir, configDirName },
// cwd here is the runtime's working directory: the trust prompt and the
// first-time setup wizard discover project-level themes against it, so a
// -C target shows its own themes instead of the launcher's.
paths: { agentDir, configDirName, cwd },
ui: options?.uiHooks
? {
showStartupSelector: options.uiHooks.showStartupSelector,
Expand Down Expand Up @@ -1347,6 +1385,7 @@ export async function prepareMain(args: string[], options?: MainOptions): Promis
const { initialMessage, initialImages } = await prepareInitialMessage(
parsed,
settingsManager.getImageAutoResize(),
cwd,
stdinContent,
);
setThemeStorageDir(agentDir);
Expand Down
2 changes: 2 additions & 0 deletions packages/coding-agent/src/modes/interactive-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,8 @@ export interface InteractiveStartupContext {
export interface StartupTuiPathOptions {
agentDir?: string;
configDirName?: string;
/** Working directory the startup UI discovers project-level resources against. Default: process.cwd() */
cwd?: string;
}

/** Loader used by the session selector to fetch a list of sessions. */
Expand Down
8 changes: 8 additions & 0 deletions packages/coding-agent/test/args.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -649,6 +649,8 @@ describe("parseArgs", () => {
["--session-id", "--session-id"],
["--fork", "--fork"],
["--session-dir", "--session-dir"],
["--cwd", "--cwd"],
["-C", "--cwd"],
["--models", "--models"],
["--tools", "--tools"],
["-t", "--tools"],
Expand Down Expand Up @@ -698,6 +700,12 @@ describe("parseArgs", () => {
expect(result.diagnostics).toEqual([]);
});

test("--cwd and -C both capture the working directory", () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These cases only prove the parser stores the token; nothing here covers the actual behaviour the PR claims (settings discovery, session storage and resource loading keying off the path). A cheap end-to-end assertion is possible because --version is handled after bootstrapSettingsManager is created:

const seen: string[] = [];
await prepareMain(["--cwd", dir, "--version"], {
  settingsManagerFactory: (cwd) => { seen.push(cwd); return SettingsManager.inMemory(); },
});
expect(seen[0]).toBe(dir);

That would also have caught the dead-Args.cwd issue (the flag must work when passed through argv, not only via MainOptions). A directory-does-not-exist case would cover the second blocking item.

expect(parseArgs(["--cwd", "/tmp/project"]).cwd).toBe("/tmp/project");
expect(parseArgs(["-C", "/tmp/project"]).cwd).toBe("/tmp/project");
expect(parseArgs(["-C", "/tmp/project", "fix the bug"]).messages).toEqual(["fix the bug"]);
});

test("an invalid --thinking value still warns rather than erroring", () => {
const result = parseArgs(["--thinking", "bogus"]);
expect(result.thinking).toBeUndefined();
Expand Down
Loading
Loading