From b3b9966f24cd9b85bfbb3c6af0b17dbe97fb5c3f Mon Sep 17 00:00:00 2001 From: li-xiu-qi Date: Thu, 22 Oct 2026 00:28:39 +0800 Subject: [PATCH 1/2] feat(cli): add -C/--cwd flag to set the working directory explicitly 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 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. --- apps/cli/src/main.ts | 1 + packages/coding-agent/src/cli/args.ts | 9 +++++++++ packages/coding-agent/src/main.ts | 7 ++++++- packages/coding-agent/test/args.test.ts | 8 ++++++++ 4 files changed, 24 insertions(+), 1 deletion(-) diff --git a/apps/cli/src/main.ts b/apps/cli/src/main.ts index d3dc737b..57d7f356 100644 --- a/apps/cli/src/main.ts +++ b/apps/cli/src/main.ts @@ -198,6 +198,7 @@ const stepMainOptions: MainOptions = { agentDir: resolveStepAgentDir(), modelsPath: join(resolveStepConfigRoot(), "models.json"), configDirName: resolveStepConfigDir(), + cwd: stepPermissionArgs.cwd, sessionManagerFactory: createStepSessionManagerFactory(resolveStepAgentDir()), settingsManagerFactory: (cwd, agentDir, options) => { const stepManager = createStepSettingsManager(cwd, agentDir, options); diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index 86324354..d9d7431d 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -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[]; @@ -391,6 +393,12 @@ export function parseArgs(args: string[]): Args { result.sessionDir = taken.value; i = taken.nextIndex; } + } else if (arg === "--cwd" || arg === "-C") { + 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) { @@ -673,6 +681,7 @@ ${stepPermissionOptionsText} --session-id Use exact project session ID, creating it if missing --fork Fork specific session file or partial UUID into a new session --session-dir Directory for session storage and lookup + --cwd, -C Working directory (defaults to the process cwd) --no-session Don't save session (ephemeral) --name, -n Set session display name --models Comma-separated model patterns for Ctrl+P cycling diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index cdcb8319..5c12e46f 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -721,6 +721,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. */ @@ -863,7 +868,7 @@ export type MainPreparation = export async function prepareMain(args: string[], options?: MainOptions): Promise { const extensionFactories = [...builtInExtensions, ...(options?.extensionFactories ?? [])]; const configDirName = options?.configDirName?.trim() || CONFIG_DIR_NAME; - const cwd = process.cwd(); + const cwd = resolvePath(options?.cwd ?? process.cwd()); 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 diff --git a/packages/coding-agent/test/args.test.ts b/packages/coding-agent/test/args.test.ts index 2a267af1..0b322a11 100644 --- a/packages/coding-agent/test/args.test.ts +++ b/packages/coding-agent/test/args.test.ts @@ -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"], @@ -698,6 +700,12 @@ describe("parseArgs", () => { expect(result.diagnostics).toEqual([]); }); + test("--cwd and -C both capture the working directory", () => { + 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(); From 432ca167efd610a94aafd6b8c4b1de1a89348cd7 Mon Sep 17 00:00:00 2001 From: li-xiu-qi Date: Thu, 22 Oct 2026 21:29:03 +0800 Subject: [PATCH 2/2] fix(cli): honour -C/--cwd at every entrypoint and validate the path early 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. --- apps/cli/src/main.ts | 5 +- apps/cli/src/ui/startup-ui.ts | 9 +- apps/cli/test/startup-themes-cwd.test.ts | 45 +++++ packages/coding-agent/docs/usage.md | 32 ++++ .../coding-agent/src/cli/file-processor.ts | 9 +- .../coding-agent/src/core/session-export.ts | 5 +- packages/coding-agent/src/main.ts | 44 ++++- .../src/modes/interactive-contract.ts | 2 + .../test/file-processor-base-dir.test.ts | 58 +++++++ .../test/prepare-main-cwd.test.ts | 157 ++++++++++++++++++ .../test/project-trust-startup-paths.test.ts | 59 +++++++ .../test/session-export-cwd.test.ts | 46 +++++ 12 files changed, 460 insertions(+), 11 deletions(-) create mode 100644 apps/cli/test/startup-themes-cwd.test.ts create mode 100644 packages/coding-agent/test/file-processor-base-dir.test.ts create mode 100644 packages/coding-agent/test/prepare-main-cwd.test.ts create mode 100644 packages/coding-agent/test/project-trust-startup-paths.test.ts create mode 100644 packages/coding-agent/test/session-export-cwd.test.ts diff --git a/apps/cli/src/main.ts b/apps/cli/src/main.ts index 57d7f356..96d867f9 100644 --- a/apps/cli/src/main.ts +++ b/apps/cli/src/main.ts @@ -198,7 +198,10 @@ const stepMainOptions: MainOptions = { agentDir: resolveStepAgentDir(), modelsPath: join(resolveStepConfigRoot(), "models.json"), configDirName: resolveStepConfigDir(), - cwd: stepPermissionArgs.cwd, + // 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); diff --git a/apps/cli/src/ui/startup-ui.ts b/apps/cli/src/ui/startup-ui.ts index 65dc4ec4..cfad2509 100644 --- a/apps/cli/src/ui/startup-ui.ts +++ b/apps/cli/src/ui/startup-ui.ts @@ -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[] { @@ -53,7 +55,7 @@ function loadThemes(resources: ResolvedResource[]): Theme[] { return themes; } -async function loadStartupThemes( +export async function loadStartupThemes( settingsManager: SettingsManager, paths: Required, ): Promise { @@ -61,7 +63,9 @@ async function loadStartupThemes( 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 ` pickers list 's themes. + cwd: paths.cwd, agentDir: paths.agentDir, configDirName: paths.configDirName, settingsManager: globalSettingsManager, @@ -77,6 +81,7 @@ export async function createStartupTui( const resolvedPaths: Required = { 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 diff --git a/apps/cli/test/startup-themes-cwd.test.ts b/apps/cli/test/startup-themes-cwd.test.ts new file mode 100644 index 00000000..6135c1cb --- /dev/null +++ b/apps/cli/test/startup-themes-cwd.test.ts @@ -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) => 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; + 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" }), + ); + }); +}); diff --git a/packages/coding-agent/docs/usage.md b/packages/coding-agent/docs/usage.md index b423bc8e..b68fb84a 100644 --- a/packages/coding-agent/docs/usage.md +++ b/packages/coding-agent/docs/usage.md @@ -200,9 +200,41 @@ cat README.md | step -p "Summarize this text" | `--session ` | Use a specific session file or partial UUID | | `--fork ` | Fork a session file or partial UUID into a new session | | `--session-dir ` | Custom session storage directory | +| `-C`, `--cwd ` | Working directory for this run (defaults to the process cwd) | | `--no-session` | Ephemeral mode; do not save | | `--name `, `-n ` | Set session display name at startup | +#### Working directory (`-C` / `--cwd`) + +`-C ` 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 ``, and continue looks for + the most recent session of ``. +- `--fork ` forks into ``. +- `--session ` / `--session ` 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 ``. +- `--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 ``, matching `git -C` expectations. The `--flag=value` form is +reserved for extension flags; startup flags take a space-separated value +(`--cwd `). + +`-C` is a startup flag: top-level subcommands (`mcp`, `login`, `feedback`, +`session-index`, ...) are detected from the first argument, so `step -C +` runs the subcommand, not a prompt, and the subcommand does not +honour `-C`. + ### Tool Options | Option | Description | diff --git a/packages/coding-agent/src/cli/file-processor.ts b/packages/coding-agent/src/cli/file-processor.ts index 5cb681ad..159b075c 100644 --- a/packages/coding-agent/src/cli/file-processor.ts +++ b/packages/coding-agent/src/cli/file-processor.ts @@ -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 */ @@ -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 ` users expect; baseDir defaults to the launcher cwd. + const absolutePath = resolve(resolveReadPath(fileArg, options?.baseDir ?? process.cwd())); // Check if file exists try { diff --git a/packages/coding-agent/src/core/session-export.ts b/packages/coding-agent/src/core/session-export.ts index c5229e0b..f8de907a 100644 --- a/packages/coding-agent/src/core/session-export.ts +++ b/packages/coding-agent/src/core/session-export.ts @@ -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 --export out.jsonl` + // lands in 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)) { diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 5c12e46f..b18c056c 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -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"; @@ -306,6 +307,7 @@ function assertAllowedAuthProvider( async function prepareInitialMessage( parsed: Args, autoResizeImages: boolean, + baseDir: string, stdinContent?: string, ): Promise<{ initialMessage?: string; @@ -317,6 +319,7 @@ async function prepareInitialMessage( const { text, images } = await processFileArguments(parsed.fileArgs, { autoResizeImages, + baseDir, }); return buildInitialMessage({ parsed, @@ -868,13 +871,39 @@ export type MainPreparation = export async function prepareMain(args: string[], options?: MainOptions): Promise { const extensionFactories = [...builtInExtensions, ...(options?.extensionFactories ?? [])]; const configDirName = options?.configDirName?.trim() || CONFIG_DIR_NAME; - const cwd = resolvePath(options?.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 @@ -940,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; @@ -1049,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, ); @@ -1159,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, @@ -1352,6 +1385,7 @@ export async function prepareMain(args: string[], options?: MainOptions): Promis const { initialMessage, initialImages } = await prepareInitialMessage( parsed, settingsManager.getImageAutoResize(), + cwd, stdinContent, ); setThemeStorageDir(agentDir); diff --git a/packages/coding-agent/src/modes/interactive-contract.ts b/packages/coding-agent/src/modes/interactive-contract.ts index a106e450..887a2b4b 100644 --- a/packages/coding-agent/src/modes/interactive-contract.ts +++ b/packages/coding-agent/src/modes/interactive-contract.ts @@ -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. */ diff --git a/packages/coding-agent/test/file-processor-base-dir.test.ts b/packages/coding-agent/test/file-processor-base-dir.test.ts new file mode 100644 index 00000000..d721f6e1 --- /dev/null +++ b/packages/coding-agent/test/file-processor-base-dir.test.ts @@ -0,0 +1,58 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { processFileArguments } from "../src/cli/file-processor.ts"; + +const tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } + vi.restoreAllMocks(); +}); + +function makeTempDir(): string { + const dir = mkdtempSync(join(tmpdir(), "step-atfile-")); + tempDirs.push(dir); + return dir; +} + +describe("processFileArguments baseDir", () => { + test("relative @file paths resolve against baseDir, not the launcher cwd", async () => { + const target = makeTempDir(); + writeFileSync(join(target, "note.md"), "hello from target", "utf-8"); + + const result = await processFileArguments(["note.md"], { baseDir: target, autoResizeImages: false }); + + expect(result.text).toContain("hello from target"); + }); + + test("absolute @file paths ignore baseDir", async () => { + const target = makeTempDir(); + const absolute = join(target, "abs.md"); + writeFileSync(absolute, "absolute content", "utf-8"); + + const result = await processFileArguments([absolute], { baseDir: makeTempDir(), autoResizeImages: false }); + + expect(result.text).toContain("absolute content"); + }); + + test("a missing relative file reports the baseDir-resolved path and exits", async () => { + const base = makeTempDir(); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + // Throwing mirrors the real control flow: the mocked exit never returns. + const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => { + throw new Error("process.exit"); + }) as never); + + await expect(processFileArguments(["missing.md"], { baseDir: base, autoResizeImages: false })).rejects.toThrow( + "process.exit", + ); + + expect(exitSpy).toHaveBeenCalledWith(1); + const stderr = errorSpy.mock.calls.map(([message]) => String(message)).join("\n"); + expect(stderr).toContain(resolve(base, "missing.md")); + }); +}); diff --git a/packages/coding-agent/test/prepare-main-cwd.test.ts b/packages/coding-agent/test/prepare-main-cwd.test.ts new file mode 100644 index 00000000..1960915c --- /dev/null +++ b/packages/coding-agent/test/prepare-main-cwd.test.ts @@ -0,0 +1,157 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { SettingsManager } from "../src/core/settings-manager.ts"; +import { prepareMain } from "../src/main.ts"; + +const tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } + vi.restoreAllMocks(); +}); + +function makeTempDir(): string { + const dir = mkdtempSync(join(tmpdir(), "step-cwd-")); + tempDirs.push(dir); + return dir; +} + +/** A --cwd path that does not exist. The temp prefix keeps it unique per run. */ +function missingCwdPath(): string { + return join(makeTempDir(), "missing-target"); +} + +describe("prepareMain --cwd wiring", () => { + test("settings manager is built against the --cwd directory at every entrypoint", async () => { + const dir = makeTempDir(); + const seenCwds: string[] = []; + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + // --version is handled after the settings manager is built, so the factory + // observes the cwd the run actually resolved. + const result = await prepareMain(["--cwd", dir, "--version"], { + settingsManagerFactory: (cwd) => { + seenCwds.push(cwd); + return SettingsManager.inMemory(); + }, + disableBackgroundServices: true, + }); + + expect(result).toEqual({ kind: "completed", exitCode: 0 }); + expect(seenCwds).toEqual([resolve(dir)]); + expect(logSpy).toHaveBeenCalled(); + }); + + test("an options.cwd passed by a product shell still wins over the flag", async () => { + const flagDir = makeTempDir(); + const optionDir = makeTempDir(); + const seenCwds: string[] = []; + vi.spyOn(console, "log").mockImplementation(() => {}); + + await prepareMain(["--cwd", flagDir, "--version"], { + cwd: optionDir, + settingsManagerFactory: (cwd) => { + seenCwds.push(cwd); + return SettingsManager.inMemory(); + }, + disableBackgroundServices: true, + }); + + expect(seenCwds).toEqual([resolve(optionDir)]); + }); + + test("omitting the flag keeps the process cwd", async () => { + const seenCwds: string[] = []; + vi.spyOn(console, "log").mockImplementation(() => {}); + + await prepareMain(["--version"], { + settingsManagerFactory: (cwd) => { + seenCwds.push(cwd); + return SettingsManager.inMemory(); + }, + disableBackgroundServices: true, + }); + + expect(seenCwds).toEqual([resolve(process.cwd())]); + }); + + test("a nonexistent --cwd fails fast instead of reaching the session layer", async () => { + const missing = missingCwdPath(); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const factory = vi.fn(() => SettingsManager.inMemory()); + + const result = await prepareMain(["--cwd", missing], { + settingsManagerFactory: factory, + disableBackgroundServices: true, + }); + + expect(result).toEqual({ kind: "completed", exitCode: 1 }); + const stderr = errorSpy.mock.calls.map(([message]) => String(message)).join("\n"); + expect(stderr).toContain("--cwd path does not exist"); + expect(stderr).toContain(resolve(missing)); + // The failure happens before any settings or session work. + expect(factory).not.toHaveBeenCalled(); + }); + + test("a --cwd pointing at a file fails fast", async () => { + const dir = makeTempDir(); + const filePath = join(dir, "not-a-directory.txt"); + writeFileSync(filePath, "x", "utf-8"); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + const result = await prepareMain(["--cwd", filePath], { + settingsManagerFactory: () => SettingsManager.inMemory(), + disableBackgroundServices: true, + }); + + expect(result).toEqual({ kind: "completed", exitCode: 1 }); + const stderr = errorSpy.mock.calls.map(([message]) => String(message)).join("\n"); + expect(stderr).toContain("--cwd path is not a directory"); + }); + + test("--no-session does not skip the existence check", async () => { + const missing = missingCwdPath(); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + const result = await prepareMain(["--cwd", missing, "--no-session"], { + settingsManagerFactory: () => SettingsManager.inMemory(), + disableBackgroundServices: true, + }); + + expect(result).toEqual({ kind: "completed", exitCode: 1 }); + const stderr = errorSpy.mock.calls.map(([message]) => String(message)).join("\n"); + expect(stderr).toContain("--cwd path does not exist"); + }); + + test("an empty --cwd is rejected instead of falling back to the process cwd", async () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const factory = vi.fn(() => SettingsManager.inMemory()); + + const result = await prepareMain(["--cwd", ""], { + settingsManagerFactory: factory, + disableBackgroundServices: true, + }); + + expect(result).toEqual({ kind: "completed", exitCode: 1 }); + const stderr = errorSpy.mock.calls.map(([message]) => String(message)).join("\n"); + expect(stderr).toContain("--cwd requires a non-empty path"); + expect(factory).not.toHaveBeenCalled(); + }); + + test("a whitespace-only --cwd is rejected too", async () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + const result = await prepareMain(["--cwd", " "], { + settingsManagerFactory: () => SettingsManager.inMemory(), + disableBackgroundServices: true, + }); + + expect(result).toEqual({ kind: "completed", exitCode: 1 }); + const stderr = errorSpy.mock.calls.map(([message]) => String(message)).join("\n"); + expect(stderr).toContain("--cwd requires a non-empty path"); + }); +}); diff --git a/packages/coding-agent/test/project-trust-startup-paths.test.ts b/packages/coding-agent/test/project-trust-startup-paths.test.ts new file mode 100644 index 00000000..b8c154fe --- /dev/null +++ b/packages/coding-agent/test/project-trust-startup-paths.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, test } from "vitest"; +import { createProjectTrustContext } from "../src/cli/project-trust.ts"; +import { SettingsManager } from "../src/core/settings-manager.ts"; + +describe("createProjectTrustContext paths", () => { + test("forwards the startup paths (including cwd) to the selector primitives", async () => { + const seen: unknown[] = []; + const context = createProjectTrustContext({ + cwd: "/work/dir", + mode: "interactive", + settingsManager: SettingsManager.inMemory(), + hasUI: true, + // Same shape main.ts builds for the trust prompt: the runtime cwd rides + // along so the startup selectors discover project-level themes in the + // -C target directory, not the launcher directory. + paths: { agentDir: "/agent", configDirName: ".stepcode", cwd: "/work/dir" }, + ui: { + showStartupSelector: async ( + _settingsManager: SettingsManager, + _title: string, + _options: Array<{ label: string; value: T }>, + paths?: unknown, + ) => { + seen.push(paths); + return "trust" as T; + }, + }, + }); + + await context.ui.select("Trust this directory?", ["trust", "decline"]); + + expect(seen).toEqual([{ agentDir: "/agent", configDirName: ".stepcode", cwd: "/work/dir" }]); + }); + + test("keeps paths undefined when the caller supplies none", async () => { + const seen: unknown[] = []; + const context = createProjectTrustContext({ + cwd: "/work/dir", + mode: "interactive", + settingsManager: SettingsManager.inMemory(), + hasUI: true, + ui: { + showStartupSelector: async ( + _settingsManager: SettingsManager, + _title: string, + _options: Array<{ label: string; value: T }>, + paths?: unknown, + ) => { + seen.push(paths); + return "trust" as T; + }, + }, + }); + + await context.ui.select("Trust this directory?", ["trust"]); + + expect(seen).toEqual([undefined]); + }); +}); diff --git a/packages/coding-agent/test/session-export-cwd.test.ts b/packages/coding-agent/test/session-export-cwd.test.ts new file mode 100644 index 00000000..98e86c50 --- /dev/null +++ b/packages/coding-agent/test/session-export-cwd.test.ts @@ -0,0 +1,46 @@ +import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { afterEach, describe, expect, test } from "vitest"; +import { exportSessionToJsonl } from "../src/core/session-export.ts"; +import { SessionManager } from "../src/core/session-manager.ts"; + +const tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +function makeTempDir(): string { + const dir = mkdtempSync(join(tmpdir(), "step-export-")); + tempDirs.push(dir); + return dir; +} + +describe("exportSessionToJsonl output base", () => { + test("a relative output path lands in the session working directory", () => { + const sessionCwd = makeTempDir(); + const sessionManager = SessionManager.create(sessionCwd); + + const written = exportSessionToJsonl(sessionManager, "out.jsonl"); + + expect(written).toBe(resolve(sessionCwd, "out.jsonl")); + expect(existsSync(written)).toBe(true); + const header = JSON.parse(readFileSync(written, "utf-8").split("\n")[0]); + expect(header.cwd).toBe(resolve(sessionCwd)); + }); + + test("an absolute output path is honoured as-is", () => { + const sessionCwd = makeTempDir(); + const elsewhere = makeTempDir(); + const target = join(elsewhere, "custom.jsonl"); + const sessionManager = SessionManager.create(sessionCwd); + + const written = exportSessionToJsonl(sessionManager, target); + + expect(written).toBe(resolve(target)); + expect(existsSync(target)).toBe(true); + }); +});