diff --git a/apps/cli/src/main.ts b/apps/cli/src/main.ts index d3dc737b..96d867f9 100644 --- a/apps/cli/src/main.ts +++ b/apps/cli/src/main.ts @@ -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); 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/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/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 cdcb8319..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, @@ -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. */ @@ -863,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 = 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 @@ -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; @@ -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, ); @@ -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, @@ -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); 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/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(); 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); + }); +});