Repository navigation
feat(cli): add -C/--cwd flag to set the working directory explicitly - #289
Conversation
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
left a comment
There was a problem hiding this comment.
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 withusage.md's-c, --continue: prefer-C, --cwd <dir>. --cwd=/abs/pathis not supported and instead lands in the extension-flag map asunknownFlags.set("cwd", ...); worth pinning down explicitly (support it, or note that the=form is reserved for extension flags).apps/cli/src/main.ts:347detects top-level commands fromprocess.argv[2], sostep -C dir session-index ...becomes a prompt rather than the subcommand. That limitation predates this PR, but since-Cis 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.
| agentDir: resolveStepAgentDir(), | ||
| modelsPath: join(resolveStepConfigRoot(), "models.json"), | ||
| configDirName: resolveStepConfigDir(), | ||
| cwd: stepPermissionArgs.cwd, |
There was a problem hiding this comment.
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:
coding-agent's ownmain()(packages/coding-agent/src/main.ts:1468) callsprepareMain(args)without options, sostep --cwd dirfrom any embedding/SDK path parses and advertises the flag but silently ignores it.Args.cwdis currently read by nobody.- The value is captured from a different argv than the one actually executed, so any future argument rewrite that changes or drops
--cwdin 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.
| const extensionFactories = [...builtInExtensions, ...(options?.extensionFactories ?? [])]; | ||
| const configDirName = options?.configDirName?.trim() || CONFIG_DIR_NAME; | ||
| const cwd = process.cwd(); | ||
| const cwd = resolvePath(options?.cwd ?? process.cwd()); |
There was a problem hiding this comment.
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 showscwd from session file does not exist <the path the user just passed>, withfallbackCwdequal to that same path (it is thecwdargument above), so choosing "Continue" loops; - print/JSON/RPC exits 1 with
Stored session working directory does not exist: <same path>; --no-sessionskips 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") { |
There was a problem hiding this comment.
Two small parser questions:
--cwd=/abs/pathis not handled, so it falls through to thearg.startsWith("--")branch and is recorded asunknownFlags.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 throughresolvePath, 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) |
There was a problem hiding this comment.
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", () => { |
There was a problem hiding this comment.
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.
cfb801c to
432ca16
Compare
|
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. 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 args.ts ( args.ts (ordering) and usage.md The help text reads 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 |
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.
Fixes #288
Problem
The working directory is always taken from
process.cwd()at startup. Everyother entrypoint into step code takes an explicit cwd:
createAgentSessionaccepts
options.cwd, the server harness requires acwdfield, subagenttasks carry a per-task
cwd, and the ACP-style protocol channels pass it inthe session request. The CLI entrypoint is the only one without it, which
leaves the cwd uncontrollable exactly where it matters:
cdinto the install directory before exec: an outercdcannot help, the launcher's
cdwins.stepfrom a fixed location withno way to point the session at another project.
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
cdand hope the launcherdoes not override it.
cd <dir> && stepcovers the friendly interactive case, but it makes theworking directory an implicit shell-state fact (cf.
git -C, which existsfor exactly this reason) and fails outright when the launcher itself
changes directories.
Changes
-C/--cwd <dir>inpackages/coding-agent/src/cli/args.tsandexpose it on
Args.cwdoverride inMainOptionsand resolveoptions?.cwd ?? parsed.cwd ?? process.cwd()inprepareMain, so sessionstorage, 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 callerspass no
options.cwd, and without this the flag would parse, print in--help, and then be silently ignored.apps/cli/src/main.tsno longer forwards a cwd parsed from the early argvsnapshot into
stepPermissionArgs. That value came fromprocess.argv.slice(2)beforenormalizeStepSessionSelectorArgs, a secondsource of truth alongside the
finalArgsthatprepareMainactuallyexecutes, so any future rewrite that changed or dropped
--cwdin oneplace would leave the other diverged. There is now a single source:
prepareMainparses the argv it really runs.directory-ness, and a missing or non-directory
--cwdfails fast withexit code 1 and a clear message on stderr.
resolvePathonly normalizes,so a bad path previously reached the session layer, where
getMissingSessionCwdIssuereported the just-supplied path as a stalesession-file cwd, and
--no-sessionskipped even that check.--cwd:@filearguments,
-e/--skill/--prompt-template/--theme, and arelative
--exportoutput all resolve against<dir>, matchinggit -Cexpectations.
--session-dirstays launcher-relative; it relocatesstorage, not work.
theme discovery) use the resolved cwd, so
step -C <dir>lists<dir>'sthemes on first launch instead of the launcher's.
packages/coding-agent/docs/usage.mdgains the-Crow plus a sectionspelling out precedence with the session flags and what stays orthogonal.
Omitting
--cwdkeeps today's behavior exactly; the value composes withsession flags (
step -C <dir> -ccontinues the most recent session in<dir>, sincecontinuealready resolves sessions by cwd).Behavior and compatibility
--cwdis additive.(
resolvePath(process.cwd())is equivalent to today'sprocess.cwd()).flag only selects which directory the session binds to at startup.
--cwdand--session-dirremain orthogonal: one picks the project, theother the session storage location.
StartupTuiPathOptionsgains an optionalcwdfield andProcessFileOptionsgains an optionalbaseDirfield. Both are optionaland their defaults preserve the previous behavior.
--exportoutput inexportSessionToJsonlnow resolves against thesession's cwd instead of
process.cwd(), matching the base used by theother CLI-side paths. SDK callers that relied on the old base will see
the output move after this merges.
Validation
packages/coding-agent/test/prepare-main-cwd.test.ts(6 cases): thecwd the factory receives, exitCode, stderr content, and that the factory
is not called at all on the fail-fast paths.
packages/coding-agent/test/session-export-cwd.test.ts: a relativeexport lands in the session cwd and absolute paths are unaffected.
packages/coding-agent/test/file-processor-base-dir.test.ts:@filerelative paths resolve against the supplied baseDir.
packages/coding-agent/test/project-trust-startup-paths.test.ts:createProjectTrustContextforwards the startup paths, cwd included, tothe selector primitives.
apps/cli/test/startup-themes-cwd.test.ts: startup theme discoveryuses the passed cwd rather than
process.cwd().defect form (not reading
parsed.cwd, disabling fail fast,baseDirback to
process.cwd(), the export base back toprocess.cwd(),paths.cwdback toprocess.cwd()) turns the matching test red, so theassertions carry real weight.
unmodified tree:
apps/clireports 738 passed and 8 failed, and the 5failing files are exactly the baseline's 5 (clipboard, suspend, and
path-formatting cases that are environment-dependent on Windows);
packages/coding-agentreports 4654 passed and 53 failed against thebaseline'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.exespawn timeout instep-config-isolationandworkflow-extension, both of which also fail on the baseline).tsgo --noEmitis clean inpackages/coding-agentandapps/cli.biome check --error-on-warningsis clean on all changed files.check:layer-direction,check:ui-layer,check:ts-imports,check:public-boundary,check:no-secret-leak, andcheck:pinned-depspass.build-check-test) passes onubuntu-latest, coveringpnpm run build,pnpm run check(tsgo,biome with
--error-on-warnings, and the guard scripts), andpnpm testacross the workspace. The commit attribution check passesas well.
step-dev --helplists the new flag;step-dev --cwd <dir>starts with session, settings, and resource loadingall keyed off
<dir>; a nonexistent--cwdand a--cwdpointing at afile both fail fast with a clear error; a relative
--cwd ./subresolvesagainst 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.