diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index 202a1bdf1..8aed8e349 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -445,10 +445,11 @@ Printed by `corbits --help` / `-h` from `CLI_HELP_TEXT` in `src/config/index.ts` | `--auto` | true (default) | Force auto mode on (workspace writes + unconstrained shell without prompts) | | `--no-auto` | false | Start with auto mode off (ask on every consequential action); no in-session key toggles it | | `--no-workflow` | — | Deprecated no-op: accepted with a stderr warning and ignored (kept for one release so existing wrappers keep starting; will become `unrecognized flag` after removal) | +| `--` | — | End of flags: every later token is prompt text, even one spelled like a verb or flag (`corbits -- resume`, `corbits exec -- run`) | | `--help`, `-h` | — | Show help (exit 0 via `CliHelpError`) | | `--version`, `-V` | — | Print `corbits v` plus the running `package.json` version (exit 0 via `CliVersionError`) | -Positional arguments after flags are joined into the optional initial task delivered when the TUI mounts. With no positional task, the operator starts from an empty prompt. +Global flags may precede the verb (`corbits --cwd resume`): the verb is the first token that is neither a flag nor a flag's value, and only that token is checked, so `corbits fix the resume bug` stays a prompt. Positional arguments after flags are joined into the optional initial task delivered when the TUI mounts. With no positional task, the operator starts from an empty prompt. ### Agent Source diff --git a/src/config.test.ts b/src/config.test.ts index 4b86d86f3..b3642e289 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -161,6 +161,23 @@ async function loadFor( return config; } +// loadFor with `--cwd` leading instead of trailing, the shape a launcher +// script produces (`corbits --cwd "$dir" "$@"`). Trailing `--cwd` would land +// after a `--` end-of-flags marker and become prompt text. +async function loadWithLeadingCwd( + cwd: string, + argv: readonly string[], + extras?: Pick, +): Promise { + const globalPath = await writeGlobalSettings(cwd); + const config = await loadConfig(["--cwd", cwd, ...argv], { + globalSettingsPath: globalPath, + ...extras, + }); + assertConfigured(config); + return config; +} + // A cwd with no per-repo settings file, so local resolution is inert. async function emptyCwd(): Promise { const dir = await mkdtemp(join(tmpdir(), "ic-config-")); @@ -851,6 +868,222 @@ describe("loadConfig", () => { ).rejects.toThrow(/not a session id/); }); + describe("subcommand after leading flags", () => { + const model = "accounts/fireworks/routers/kimi-k2p6-turbo"; + + test.each([ + { argv: ["resume"] }, + { argv: ["continue"] }, + { argv: ["--auto", "resume", "--list"] }, + { argv: ["--model", model, "continue"] }, + { argv: ["--provider", "fireworks", "--no-auto", "resume"] }, + ] as const)("--cwd $argv opens the picker", async ({ argv }) => { + const cwd = await emptyCwd(); + const config = await loadWithLeadingCwd(cwd, argv, { + home: await tempHome(), + }); + expect(config.command).toBe("tui"); + expect(config.resumeMode).toBe("pick"); + expect(config.resumePicker).toBe(true); + expect(config.skipInitialTask).toBe(true); + expect(config.task).toBe(""); + }); + + test.each([["resume"], ["continue"]] as const)( + "--cwd --model %s reopens that session", + async (verb) => { + const cwd = await emptyCwd(); + const home = await tempHome(); + const sessionId = generateSessionId(); + await initSessionDir(cwd, sessionId, home); + await saveState( + cwd, + sessionId, + { + status: "done", + turnsUsed: 1, + task: "earlier work", + startedAt: Date.now() - 1_000, + finishedAt: Date.now(), + }, + home, + ); + const config = await loadWithLeadingCwd( + cwd, + ["--model", model, verb, sessionId], + { home }, + ); + expect(config.command).toBe("tui"); + expect(config.resumeMode).toBe("id"); + expect(config.sessionId).toBe(sessionId); + expect(config.skipInitialTask).toBe(true); + expect(config.task).toBe("earlier work"); + }, + ); + + test.each([ + { argv: ["exec", "ship it"] }, + { argv: ["run", "ship", "it"] }, + { argv: ["--provider", "fireworks", "exec", "ship it"] }, + { argv: ["--auto", "--model", model, "run", "ship it"] }, + ] as const)("--cwd $argv runs exec", async ({ argv }) => { + const cwd = await emptyCwd(); + const config = await loadWithLeadingCwd(cwd, argv); + expect(config.command).toBe("exec"); + expect(config.task).toBe("ship it"); + }); + + test("--director before exec sees exec mode", async () => { + const cwd = await emptyCwd(); + const config = await loadWithLeadingCwd(cwd, [ + "--director", + "dispatch", + "exec", + "ship it", + ]); + expect(config.command).toBe("exec"); + expect(config.director).toBe("dispatch"); + expect(config.task).toBe("ship it"); + }); + + // A value flag's argument is never the subcommand, even when it spells one. + test.each([["resume"], ["exec"]] as const)( + "--provider %s keeps the next token as the subcommand or prompt", + async (name) => { + const cwd = await emptyCwd(); + const globalPath = await writeGlobalSettings(cwd, { + defaultProvider: "fireworks", + providers: { + fireworks: { + baseURL: "https://api.fireworks.ai/inference", + apiKey: "test-key", + models: [model], + }, + [name]: { + baseURL: "https://example.test/v1", + apiKey: "other-key", + models: ["other-model"], + }, + }, + }); + const asPrompt = await loadConfig( + ["--cwd", cwd, "--provider", name, "hello"], + { globalSettingsPath: globalPath }, + ); + assertConfigured(asPrompt); + expect(asPrompt.command).toBe("tui"); + expect(asPrompt.providerName).toBe(name); + expect(asPrompt.resumeMode).toBeUndefined(); + expect(asPrompt.task).toBe("hello"); + + const asExec = await loadConfig( + ["--cwd", cwd, "--provider", name, "exec", "hello"], + { globalSettingsPath: globalPath }, + ); + assertConfigured(asExec); + expect(asExec.command).toBe("exec"); + expect(asExec.providerName).toBe(name); + expect(asExec.task).toBe("hello"); + }, + ); + + test("--resume consumes its argument before subcommand detection", async () => { + const cwd = await emptyCwd(); + const globalPath = await writeGlobalSettings(cwd); + await expect( + loadConfig(["--cwd", cwd, "--resume", "exec", "hello"], { + globalSettingsPath: globalPath, + }), + ).rejects.toThrow("'exec' is not a session id"); + }); + + test.each([ + { argv: ["fix", "the", "resume", "bug"], task: "fix the resume bug" }, + { + argv: ["--auto", "explain", "exec", "mode"], + task: "explain exec mode", + }, + ] as const)( + "--cwd $argv stays a TUI prompt", + async ({ argv, task }) => { + const cwd = await emptyCwd(); + const config = await loadWithLeadingCwd(cwd, argv); + expect(config.command).toBe("tui"); + expect(config.resumeMode).toBeUndefined(); + expect(config.task).toBe(task); + }, + ); + + test("--cwd resume still rejects the token", async () => { + const cwd = await emptyCwd(); + const globalPath = await writeGlobalSettings(cwd); + await expect( + loadConfig(["--cwd", cwd, "resume", "not-a-uuid"], { + globalSettingsPath: globalPath, + }), + ).rejects.toThrow(/not a session id/); + }); + + test.each([ + { argv: ["resume", "-p", "hello"] }, + { argv: ["-p", "continue"] }, + ] as const)( + "--cwd $argv rejects resume with -p", + async ({ argv }) => { + const cwd = await emptyCwd(); + const globalPath = await writeGlobalSettings(cwd); + await expect( + loadConfig(["--cwd", cwd, ...argv], { + globalSettingsPath: globalPath, + }), + ).rejects.toThrow("cannot combine resume with -p"); + }, + ); + + test.each([ + [["--cwd", ".", "resume", "--help"]], + [["--cwd", ".", "exec", "-h"]], + [["--cwd", ".", "--", "--help"]], + ])("%j still shows help", async (argv) => { + await expectCliHelp(argv); + }); + + test.each([ + [["--cwd", ".", "resume", "--version"]], + [["--cwd", ".", "--", "-V"]], + ])("%j still shows the version", async (argv) => { + await expectCliVersion(argv); + }); + }); + + describe("-- ends flag parsing", () => { + test.each([ + { argv: ["--", "resume"], command: "tui", task: "resume" }, + { + argv: ["--", "continue", "please"], + command: "tui", + task: "continue please", + }, + { argv: ["--auto", "--", "exec"], command: "tui", task: "exec" }, + { argv: ["exec", "--", "resume"], command: "exec", task: "resume" }, + { argv: ["-p", "--", "run"], command: "exec", task: "run" }, + { argv: ["--", "--model", "-p"], command: "tui", task: "--model -p" }, + ] as const)( + "--cwd $argv sends the rest as the prompt", + async ({ argv, command, task }) => { + const cwd = await emptyCwd(); + const config = await loadWithLeadingCwd(cwd, argv); + expect(config.command).toBe(command); + expect(config.resumeMode).toBeUndefined(); + expect(config.task).toBe(task); + }, + ); + + test("CLI_HELP_TEXT documents the -- prompt escape", () => { + expect(CLI_HELP_TEXT).toMatch(/^ {2}--\s+\S/m); + }); + }); + test("plain corbits always creates fresh state even when a previous session exists", async () => { const cwd = await emptyCwd(); const home = await tempHome(); diff --git a/src/config/index.ts b/src/config/index.ts index 17cbbc77c..f85eff33c 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -720,8 +720,9 @@ export const CLI_HELP_TEXT = `corbits — coding agent CLI Usage: corbits [flags] [task...] - corbits exec|run|-p [flags] - corbits resume|continue [session-id] [flags] + corbits [flags] exec|run|-p [flags] + corbits [flags] resume|continue [session-id] [flags] + corbits [flags] [exec|run|-p] -- Continue verbs (project-keyed to this checkout's git toplevel): resume / continue interactive session picker @@ -751,6 +752,8 @@ Flags: --config selects another source --auto / --no-auto auto mode on/off --no-workflow deprecated and ignored + -- end of flags: the rest is prompt text, even a + verb or flag (corbits -- resume) --help, -h show this help --version, -V show version `; @@ -817,6 +820,55 @@ function isFlagToken(arg: string): boolean { return arg.startsWith("--") || arg === "-h" || arg === "-p" || arg === "-V"; } +const END_OF_FLAGS = "--"; + +// Single source for which flags bind the next token: the main parse loop +// dispatches on it, and subcommand detection skips the bound value with it. +const VALUE_FLAGS = [ + "--cwd", + "--config", + "--permissions", + "--provider", + "--model", + "--profile", + "--director", +] as const; +type ValueFlag = (typeof VALUE_FLAGS)[number]; + +function isValueFlag(arg: string): arg is ValueFlag { + return (VALUE_FLAGS as readonly string[]).includes(arg); +} + +const SUBCOMMANDS = ["exec", "run", "resume", "continue"] as const; +type Subcommand = (typeof SUBCOMMANDS)[number]; + +function isSubcommand(arg: string): arg is Subcommand { + return (SUBCOMMANDS as readonly string[]).includes(arg); +} + +// The subcommand is the first token that is neither a flag nor a flag's +// value, so launchers can prepend global flags (`corbits --cwd X resume`). +// Only that first token is checked: `corbits fix the resume bug` stays a +// prompt, and `corbits -- resume` sends "resume" as the prompt. +function findSubcommand( + args: readonly string[], +): { name: Subcommand; index: number } | undefined { + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === undefined || arg === END_OF_FLAGS) return undefined; + // `--resume` binds an optional session id the same way the parse loop + // does; a non-id there is rejected by the loop, never read as a verb. + if (isValueFlag(arg) || arg === "--resume") { + const next = args[i + 1]; + if (next !== undefined && !isFlagToken(next)) i++; + continue; + } + if (isFlagToken(arg)) continue; + return isSubcommand(arg) ? { name: arg, index: i } : undefined; + } + return undefined; +} + export async function loadConfig( argv: readonly string[], options?: LoadConfigOptions & { allowUnconfigured?: false }, @@ -840,22 +892,25 @@ export async function loadConfig( const args = [...argv]; - // Leading subcommand: `corbits exec "prompt"` (alias: `run`). Default is TUI. + // Subcommand: `corbits exec "prompt"` (alias: `run`). Default is TUI. // `corbits resume` / `continue` reopen a prior session for this project key // (this checkout's git toplevel — see docs/IMPLEMENTATION.md). let command: "tui" | "exec" = "tui"; let resumeMode: "id" | "pick" | undefined; let resumeSessionId: string | undefined; - const leading = args[0]; - if (leading === "exec" || leading === "run") { + const subcommand = findSubcommand(args); + const isResume = + subcommand?.name === "resume" || subcommand?.name === "continue"; + if (subcommand !== undefined) { + args.splice(subcommand.index, 1); + } + if (subcommand?.name === "exec" || subcommand?.name === "run") { command = "exec"; - args.shift(); - } else if (leading === "resume" || leading === "continue") { - args.shift(); + } else if (subcommand !== undefined && isResume) { // Bare resume opens the list; a session id is the only direct-resume path. // Invalid non-flag positionals error instead of falling through (a // free-form token would otherwise become task while skipInitialTask is set). - const next = args.slice()[0]; + const next = args[subcommand.index]; if (next !== undefined && !isFlagToken(next)) { if (!isSessionId(next)) { throw new Error( @@ -864,17 +919,19 @@ export async function loadConfig( } resumeMode = "id"; resumeSessionId = next; - args.shift(); + args.splice(subcommand.index, 1); } else { resumeMode = "pick"; } } - // `-p` is the exec one-shot path in any position (`-p --provider …`, + // `-p` is the exec one-shot path in any flag position (`-p --provider …`, // `--model … -p …`). Detect it before value flags so exec-only options - // that appear before `-p` still see exec mode. - if (args.includes("-p")) { - if (leading === "resume" || leading === "continue") { + // that appear before `-p` still see exec mode. After `--` it is prompt text. + const endOfFlags = args.indexOf(END_OF_FLAGS); + const flagArgs = endOfFlags === -1 ? args : args.slice(0, endOfFlags); + if (flagArgs.includes("-p")) { + if (isResume) { throw new Error( `cannot combine resume with -p; use \`${COMMAND_NAME} -p --resume \` to continue a session headlessly`, ); @@ -911,41 +968,47 @@ export async function loadConfig( for (let i = 0; i < args.length; i++) { const arg = args[i]; if (arg === undefined) continue; - if (arg === "--cwd") { - cwd = resolve(requireValue("--cwd", args[++i])); - continue; - } - if (arg === "--config") { - configPath = resolve(requireValue("--config", args[++i])); - continue; + if (arg === END_OF_FLAGS) { + positional.push(...args.slice(i + 1)); + break; } - if (arg === "--permissions") { - permissionsPath = resolve(requireValue("--permissions", args[++i])); - continue; - } - if (arg === "--provider") { - provider = requireValue("--provider", args[++i]); - continue; - } - if (arg === "--model") { - model = requireValue("--model", args[++i]); - continue; - } - if (arg === "--profile") { - profileFlag = requireValue("--profile", args[++i]); - continue; - } - if (arg === "--director") { - const value = requireValue("--director", args[++i]); - if (command !== "exec") { - throw new Error("--director is only available in exec mode"); - } - if (!isDirectorId(value)) { - throw new Error( - `Unknown director "${value}". Use one of: ${DIRECTOR_IDS.join(", ")}.`, - ); + if (isValueFlag(arg)) { + const value = requireValue(arg, args[++i]); + switch (arg) { + case "--cwd": + cwd = resolve(value); + break; + case "--config": + configPath = resolve(value); + break; + case "--permissions": + permissionsPath = resolve(value); + break; + case "--provider": + provider = value; + break; + case "--model": + model = value; + break; + case "--profile": + profileFlag = value; + break; + case "--director": + if (command !== "exec") { + throw new Error("--director is only available in exec mode"); + } + if (!isDirectorId(value)) { + throw new Error( + `Unknown director "${value}". Use one of: ${DIRECTOR_IDS.join(", ")}.`, + ); + } + director = value; + break; + default: { + const unhandled: never = arg; + throw new Error(`unhandled value flag: ${String(unhandled)}`); + } } - director = value; continue; }