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;
}