Skip to content

feat(cli): add -C/--cwd flag to set the working directory explicitly - #289

Merged
liuedcson merged 2 commits into
stepfun-ai:mainfrom
li-xiu-qi:fix/cwd-flag
Oct 23, 2026
Merged

liuedcson merged 2 commits into
stepfun-ai:mainfrom
li-xiu-qi:fix/cwd-flag

Conversation

@li-xiu-qi

@li-xiu-qi li-xiu-qi commented Oct 21, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #288

Problem

The working directory is always taken from process.cwd() at startup. Every
other entrypoint into step code takes an explicit cwd: createAgentSession
accepts options.cwd, the server harness requires a cwd field, subagent
tasks carry a per-task cwd, and the ACP-style protocol channels pass it in
the session request. The CLI entrypoint is the only one without it, which
leaves the cwd uncontrollable exactly where it matters:

  • Launchers that cd into the install directory before exec: an outer cd
    cannot help, the launcher's cd wins.
  • Scripts and IDE integrations that spawn step from a fixed location with
    no way to point the session at another project.
  • External agents driving step code as a separate process (Claude Code over
    ACP, or step code itself spawning subagent processes): protocol-driven
    invocation passes the path as a parameter, but the process-level CLI has
    no equivalent, so callers must fall back to cd and hope the launcher
    does not override it.

cd <dir> && step covers the friendly interactive case, but it makes the
working directory an implicit shell-state fact (cf. git -C, which exists
for exactly this reason) and fails outright when the launcher itself
changes directories.

Changes

  • Parse -C / --cwd <dir> in packages/coding-agent/src/cli/args.ts and
    expose it on Args.
  • Accept a cwd override in MainOptions and resolve
    options?.cwd ?? parsed.cwd ?? process.cwd() in prepareMain, so session
    storage, settings discovery, resource loading, and tool file access all
    key off the resolved path. argv is parsed up front, so the flag takes
    effect at every entrypoint: coding-agent's own main() and SDK callers
    pass no options.cwd, and without this the flag would parse, print in
    --help, and then be silently ignored.
  • apps/cli/src/main.ts no longer forwards a cwd parsed from the early argv
    snapshot into stepPermissionArgs. That value came from
    process.argv.slice(2) before normalizeStepSessionSelectorArgs, a second
    source of truth alongside the finalArgs that prepareMain actually
    executes, so any future rewrite that changed or dropped --cwd in one
    place would leave the other diverged. There is now a single source:
    prepareMain parses the argv it really runs.
  • Right after resolution the path is checked for existence and
    directory-ness, and a missing or non-directory --cwd fails fast with
    exit code 1 and a clear message on stderr. resolvePath only normalizes,
    so a bad path previously reached the session layer, where
    getMissingSessionCwdIssue reported the just-supplied path as a stale
    session-file cwd, and --no-session skipped even that check.
  • CLI-side relative paths now share one base with --cwd: @file
    arguments, -e / --skill / --prompt-template / --theme, and a
    relative --export output all resolve against <dir>, matching git -C
    expectations. --session-dir stays launcher-relative; it relocates
    storage, not work.
  • The startup selectors (the trust prompt and the first-time setup wizard's
    theme discovery) use the resolved cwd, so step -C <dir> lists <dir>'s
    themes on first launch instead of the launcher's.
  • packages/coding-agent/docs/usage.md gains the -C row plus a section
    spelling out precedence with the session flags and what stays orthogonal.

Omitting --cwd keeps today's behavior exactly; the value composes with
session flags (step -C <dir> -c continues the most recent session in
<dir>, since continue already resolves sessions by cwd).

Behavior and compatibility

  • No public CLI surface is removed or renamed; --cwd is additive.
  • Default behavior is unchanged when the flag is absent
    (resolvePath(process.cwd()) is equivalent to today's process.cwd()).
  • Provider responses, persisted sessions, and config keys are untouched; the
    flag only selects which directory the session binds to at startup.
  • --cwd and --session-dir remain orthogonal: one picks the project, the
    other the session storage location.
  • StartupTuiPathOptions gains an optional cwd field and
    ProcessFileOptions gains an optional baseDir field. Both are optional
    and their defaults preserve the previous behavior.
  • One SDK-visible behavior change is worth calling out: a relative
    --export output in exportSessionToJsonl now resolves against the
    session's cwd instead of process.cwd(), matching the base used by the
    other CLI-side paths. SDK callers that relied on the old base will see
    the output move after this merges.

Validation

  • New packages/coding-agent/test/prepare-main-cwd.test.ts (6 cases): the
    cwd the factory receives, exitCode, stderr content, and that the factory
    is not called at all on the fail-fast paths.
  • New packages/coding-agent/test/session-export-cwd.test.ts: a relative
    export lands in the session cwd and absolute paths are unaffected.
  • New packages/coding-agent/test/file-processor-base-dir.test.ts: @file
    relative paths resolve against the supplied baseDir.
  • New
    packages/coding-agent/test/project-trust-startup-paths.test.ts:
    createProjectTrustContext forwards the startup paths, cwd included, to
    the selector primitives.
  • New apps/cli/test/startup-themes-cwd.test.ts: startup theme discovery
    uses the passed cwd rather than process.cwd().
  • Every new test was checked by fault injection: reverting each fix to its
    defect form (not reading parsed.cwd, disabling fail fast, baseDir
    back to process.cwd(), the export base back to process.cwd(),
    paths.cwd back to process.cwd()) turns the matching test red, so the
    assertions carry real weight.
  • Full suites for both packages, each compared against the same run on an
    unmodified tree: apps/cli reports 738 passed and 8 failed, and the 5
    failing files are exactly the baseline's 5 (clipboard, suspend, and
    path-formatting cases that are environment-dependent on Windows);
    packages/coding-agent reports 4654 passed and 53 failed against the
    baseline's 4641 passed and 53 failed, so the failing-assertion count is
    unchanged and the failing file set matches, with two
    Windows-environment-dependent files trading places between runs (a
    node.exe spawn timeout in step-config-isolation and
    workflow-extension, both of which also fail on the baseline).
  • tsgo --noEmit is clean in packages/coding-agent and apps/cli.
  • biome check --error-on-warnings is clean on all changed files.
  • Repo guards check:layer-direction, check:ui-layer,
    check:ts-imports, check:public-boundary, check:no-secret-leak, and
    check:pinned-deps pass.
  • The repository-wide CI job (build-check-test) passes on
    ubuntu-latest, covering pnpm run build, pnpm run check (tsgo,
    biome with --error-on-warnings, and the guard scripts), and
    pnpm test across the workspace. The commit attribution check passes
    as well.
  • Verified on a real run: step-dev --help lists the new flag;
    step-dev --cwd <dir> starts with session, settings, and resource loading
    all keyed off <dir>; a nonexistent --cwd and a --cwd pointing at a
    file both fail fast with a clear error; a relative --cwd ./sub resolves
    against the launcher directory.

Limitations

The change is confined to cwd resolution and path bases. The flag is
additive, the default path is unchanged when it is absent, and no behavioral
limitation is known.

The startup cwd was hardwired to process.cwd(), leaving launchers that
cd elsewhere, script/IDE integrations, and agent-to-agent invocation
with no way to pin a specific project directory. Parse -C/--cwd <dir>
and resolve it in prepareMain so session storage, settings discovery,
resource loading, and tool file access all key off the given path.
Omitting the flag keeps the current behavior; the flag composes with
session flags since continue already resolves sessions by cwd.

@liuedcson liuedcson left a comment

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.

Thanks — the plumbing choice is right: threading one cwd through prepareMain covers settings, session storage, resource loading and tools, and -C <dir> -c does resolve the recent session in <dir> as claimed.

I reviewed the branch statically. Two items look blocking, the rest are follow-ups.

Blocking

1. The flag is only honoured by apps/cli; coding-agent's own main() and any SDK caller silently drop it.
Args.cwd is a dead field today (prepareMain reads only options?.cwd, and nothing anywhere reads parsed.cwd). Since coding-agent's main() calls prepareMain(args) with no options, main(["--cwd", dir]) parses the flag, prints it in --help, and then ignores it. Suggest letting parsed.cwd take part in the resolution so the flag is truthful at every entrypoint.

2. A nonexistent --cwd is reported as a stale session working directory.
resolvePath only normalises; nothing checks existence. getMissingSessionCwdIssue then treats the just-supplied path as a broken session header cwd: interactive resume shows cwd from session file does not exist <the path you just passed> with fallbackCwd equal to the same path, and print/JSON/RPC exits 1 with the same misleading message. With --no-session the check is skipped entirely and the session starts against a directory that does not exist, failing later inside bash/read. git -C fails fast here; so should this.

Follow-ups

3. Relative paths now split across two bases. --extension/-e, --skill, --prompt-template, --theme resolve against the --cwd value (resolveCliPaths), while @file arguments (cli/file-processor.ts:32) and the --export output (core/session-export.ts:15) still resolve against process.cwd(), and a relative --session-dir stays launcher-relative. step -C proj @src/a.ts -e ./ext.ts therefore puts two ./ in different directories, which was impossible before this PR. Whichever semantic you pick, it should be uniform (git -C users will expect CLI paths relative to --cwd) and documented.

4. The startup selector still discovers themes from process.cwd(). apps/cli/src/ui/startup-ui.ts:64 builds DefaultPackageManager({ cwd: process.cwd() }), so with -C the startup picker lists the launcher project's project-level themes rather than the target project's.

5. Docs are missing. packages/coding-agent/docs/usage.md:194 (Session Options) has no -C, --cwd <dir> row. It would also be worth stating (a) precedence when --session/--resume points at a session from another project — the header cwd wins and --cwd only sets the starting cwd for new/continued sessions — and (b) that --cwd and --session-dir are orthogonal.

6. Tests stop at argument parsing. There is no coverage of the actual wiring; a cheap regression is available because --version is handled after the settings manager is built, so prepareMain(["--cwd", dir, "--version"], { settingsManagerFactory: (cwd) => ... }) can assert the cwd the factory receives.

Minor

  • Help ordering is inconsistent with the neighbours (--name, -n), and with usage.md's -c, --continue: prefer -C, --cwd <dir>.
  • --cwd=/abs/path is not supported and instead lands in the extension-flag map as unknownFlags.set("cwd", ...); worth pinning down explicitly (support it, or note that the = form is reserved for extension flags).
  • apps/cli/src/main.ts:347 detects top-level commands from process.argv[2], so step -C dir session-index ... becomes a prompt rather than the subcommand. That limitation predates this PR, but since -C is documented as a global startup flag it should at least be called out in the help/docs.

I did not run the suites against this branch (the local worktree had unrelated changes), so the validation here is static review only.

Comment thread apps/cli/src/main.ts Outdated
agentDir: resolveStepAgentDir(),
modelsPath: join(resolveStepConfigRoot(), "models.json"),
configDirName: resolveStepConfigDir(),
cwd: stepPermissionArgs.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.

This is the only place the parsed flag is consumed, and it comes from the early parseArgs(normalizeStepSessionSelectorArgs(process.argv.slice(2))) at the top of the module, while prepareMain receives finalArgs (after applyStepCodeConfigDefaults, translateStepCommandArgs, withStepDefaults). Two consequences:

  1. coding-agent's own main() (packages/coding-agent/src/main.ts:1468) calls prepareMain(args) without options, so step --cwd dir from any embedding/SDK path parses and advertises the flag but silently ignores it. Args.cwd is currently read by nobody.
  2. The value is captured from a different argv than the one actually executed, so any future argument rewrite that changes or drops --cwd in one pass and not the other will diverge.

Suggestion: resolve options?.cwd ?? parseArgs(args).cwd ?? process.cwd() inside prepareMain (parse happens right after the bootstrap settings manager today; it can move above it since parseArgs is pure), and then this line can go away.

Comment thread packages/coding-agent/src/main.ts Outdated
const extensionFactories = [...builtInExtensions, ...(options?.extensionFactories ?? [])];
const configDirName = options?.configDirName?.trim() || CONFIG_DIR_NAME;
const cwd = process.cwd();
const cwd = resolvePath(options?.cwd ?? 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.

resolvePath normalises only — it does not check that the path exists or is a directory. That makes a bad --cwd indistinguishable from a session whose recorded cwd disappeared:

  • interactive resume hits getMissingSessionCwdIssue (core/session-cwd.ts:14) and shows cwd from session file does not exist <the path the user just passed>, with fallbackCwd equal to that same path (it is the cwd argument above), so choosing "Continue" loops;
  • print/JSON/RPC exits 1 with Stored session working directory does not exist: <same path>;
  • --no-session skips the check entirely (getSessionFile() is undefined) and the session starts against a nonexistent directory, failing later inside bash/read.

git -C fails immediately, which is the expectation the PR description borrows. Suggest validating before any settings/session work, e.g. statSync(resolved, { throwIfNoEntry: false })?.isDirectory() test with --cwd: not a directory: <path> and exitCode 1, and rejecting an empty string (currently it silently behaves like the process cwd). That also keeps the missing-session-cwd recovery path serving only real session headers.

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.

--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.

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.

…arly

The flag only took effect through the apps/cli passthrough, so
coding-agent's own main() and SDK callers parsed it, printed it in
--help, and then silently ignored it. Parse argv up front in
prepareMain and resolve options.cwd ?? parsed.cwd ?? process.cwd(), and
drop the passthrough so a single source of truth remains instead of two
that diverge whenever the argv is rewritten.

resolvePath only normalizes, so a bad value used to reach the session
layer and be reported as a stale session-file cwd, with --no-session
skipping even that check. Check the resolved path right after
resolution and fail fast on stderr with exit code 1:

- a path that does not exist, or is not a directory, is rejected;
- an empty value is rejected, and it has to be checked before resolvePath
  runs because resolvePath("") normalizes to the process cwd and would
  pass every later check. A whitespace-only value is rejected too.

That keeps the missing-session-cwd recovery path serving only real
session headers.

Also unify the CLI-side relative path base (@file, -e/--skill,
--prompt-template/--theme and a relative --export output) on the
resolved cwd, discover startup themes against it, and document the flag
together with its precedence over the session flags.

Tests cover the cwd the settings factory receives, the fail-fast exit
code and stderr, that the factory is not called on those paths, the
empty and whitespace-only values, the export output base, the @file
base, the trust path passthrough, and startup theme discovery. Each was
checked by reverting its fix to the defect form and confirming the test
goes red.
@li-xiu-qi

Copy link
Copy Markdown
Collaborator Author

Thanks for the review. All six points are addressed in a follow-up commit.

apps/cli/src/main.ts (double source of truth) Taken as suggested. parseArgs now runs at the top of prepareMain and the flag resolves as options?.cwd ?? parsed.cwd ?? process.cwd(), so coding-agent's own main() and SDK callers behave the same as the CLI entrypoint. The cwd: stepPermissionArgs.cwd line is gone, as you predicted; prepareMain parses the argv it actually executes.

packages/coding-agent/src/main.ts (bad path reaches the session layer) Existence and directory-ness are checked immediately after resolution, before any settings or session work, and both fail with exit code 1 on stderr. An empty value needs an earlier check of its own, because resolvePath("") normalizes it to the process cwd and every later check would then pass; a whitespace-only value is rejected by the same check. That keeps the missing-session-cwd recovery path serving only real session headers, and --no-session no longer skips it.

args.ts (--cwd= form) Not supported, and now stated explicitly rather than left implicit: packages/coding-agent/docs/usage.md records that the --flag=value form is reserved for extension flags and startup flags take a space-separated value. An extension flag keeps its = form.

args.ts (ordering) and usage.md The help text reads --cwd, -C <dir>, matching its neighbours (--continue, -c, --resume, -r). The docs table uses -C, --cwd <dir>, matching its neighbours (-c, --continue). The Session Options table gained the row, and a new section spells out precedence with --session / --resume / -c / --continue and what stays orthogonal in --session-dir.

args.test.ts (only the parser was covered) Five new test files cover the behaviour the flag claims: the cwd the settings factory receives at every entrypoint, the fail-fast exit code and stderr, that the factory is not called on those paths, the empty and whitespace-only values, the --export output base, the @file base, the trust path passthrough, and startup theme discovery. Each was checked by reverting its fix to the defect form and confirming the test goes red.

@liuedcson
liuedcson merged commit b0a1297 into stepfun-ai:main Oct 23, 2026
2 of 3 checks passed
liuedcson added a commit that referenced this pull request Oct 23, 2026
Brings the seven commits main gained since dff413f:

- feat(coding-agent): make a feedback report investigable (#297)
- fix(coding-agent): settle extension command turns before returning (#298)
- fix(coding-agent): keep the tool block stable across plan mode (#296)
- fix(cli): save model selection as default on Enter (#285)
- feat(cli): add -C/--cwd flag (#289)
- fix(coding-agent): preserve task-state prompt cache prefixes (#293)

Two conflicts, both in the plan extension, where this branch had added
behaviour on top of files main rewrote:

- `plan-mode-tools.ts`: main's comment for `exitPlanMode` ("restore any tools
  plan mode added") with this branch's optional `submittedPlanHash` parameter,
  which records the proposal handed to an external reviewer.
- `step-plan.ts`: main's `version: 2` entry and `carriesLegacyTools` migration
  path, plus this branch's `submittedPlanHash` / `planHashAtEnter` on the same
  entry, and both sides of the restore step (legacy tool baseline and the
  persisted hashes). Main's `resolveToCwd` guard and `planModeAddedTools`
  bookkeeping are kept as-is: plan mode now appends tools instead of replacing
  the list, which is what keeps the request's tool block stable.

The host plan-mode test asserted the old refusal wording; the guard now says
"Only the plan file may be written", so it matches on that.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature Request] Add -C/--cwd flag to set the working directory explicitly

2 participants