Skip to content

Commit 4c882a0

Browse files
committed
fix(config): recognize subcommands after leading global flags
Launchers that prepend global flags (`corbits --cwd "$dir" "$@"`) turned `resume` into the first message of a fresh session. The verb is now the first token that is neither a flag nor a flag's value, and `--` ends flag parsing so a prompt that is literally a verb stays expressible.
1 parent 9305d2e commit 4c882a0

3 files changed

Lines changed: 345 additions & 48 deletions

File tree

‎docs/IMPLEMENTATION.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -445,10 +445,11 @@ Printed by `corbits --help` / `-h` from `CLI_HELP_TEXT` in `src/config/index.ts`
445445
| `--auto` | true (default) | Force auto mode on (workspace writes + unconstrained shell without prompts) |
446446
| `--no-auto` | false | Start with auto mode off (ask on every consequential action); no in-session key toggles it |
447447
| `--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) |
448+
| `--` | — | End of flags: every later token is prompt text, even one spelled like a verb or flag (`corbits -- resume`, `corbits exec -- run`) |
448449
| `--help`, `-h` | — | Show help (exit 0 via `CliHelpError`) |
449450
| `--version`, `-V` | — | Print `corbits v` plus the running `package.json` version (exit 0 via `CliVersionError`) |
450451

451-
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.
452+
Global flags may precede the verb (`corbits --cwd <dir> 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.
452453

453454
### Agent Source
454455

‎src/config.test.ts‎

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,23 @@ async function loadFor(
161161
return config;
162162
}
163163

164+
// loadFor with `--cwd` leading instead of trailing, the shape a launcher
165+
// script produces (`corbits --cwd "$dir" "$@"`). Trailing `--cwd` would land
166+
// after a `--` end-of-flags marker and become prompt text.
167+
async function loadWithLeadingCwd(
168+
cwd: string,
169+
argv: readonly string[],
170+
extras?: Pick<LoadConfigOptions, "home">,
171+
): Promise<Config> {
172+
const globalPath = await writeGlobalSettings(cwd);
173+
const config = await loadConfig(["--cwd", cwd, ...argv], {
174+
globalSettingsPath: globalPath,
175+
...extras,
176+
});
177+
assertConfigured(config);
178+
return config;
179+
}
180+
164181
// A cwd with no per-repo settings file, so local resolution is inert.
165182
async function emptyCwd(): Promise<string> {
166183
const dir = await mkdtemp(join(tmpdir(), "ic-config-"));
@@ -851,6 +868,222 @@ describe("loadConfig", () => {
851868
).rejects.toThrow(/not a session id/);
852869
});
853870

871+
describe("subcommand after leading flags", () => {
872+
const model = "accounts/fireworks/routers/kimi-k2p6-turbo";
873+
874+
test.each([
875+
{ argv: ["resume"] },
876+
{ argv: ["continue"] },
877+
{ argv: ["--auto", "resume", "--list"] },
878+
{ argv: ["--model", model, "continue"] },
879+
{ argv: ["--provider", "fireworks", "--no-auto", "resume"] },
880+
] as const)("--cwd <dir> $argv opens the picker", async ({ argv }) => {
881+
const cwd = await emptyCwd();
882+
const config = await loadWithLeadingCwd(cwd, argv, {
883+
home: await tempHome(),
884+
});
885+
expect(config.command).toBe("tui");
886+
expect(config.resumeMode).toBe("pick");
887+
expect(config.resumePicker).toBe(true);
888+
expect(config.skipInitialTask).toBe(true);
889+
expect(config.task).toBe("");
890+
});
891+
892+
test.each([["resume"], ["continue"]] as const)(
893+
"--cwd <dir> --model <id> %s <session-id> reopens that session",
894+
async (verb) => {
895+
const cwd = await emptyCwd();
896+
const home = await tempHome();
897+
const sessionId = generateSessionId();
898+
await initSessionDir(cwd, sessionId, home);
899+
await saveState(
900+
cwd,
901+
sessionId,
902+
{
903+
status: "done",
904+
turnsUsed: 1,
905+
task: "earlier work",
906+
startedAt: Date.now() - 1_000,
907+
finishedAt: Date.now(),
908+
},
909+
home,
910+
);
911+
const config = await loadWithLeadingCwd(
912+
cwd,
913+
["--model", model, verb, sessionId],
914+
{ home },
915+
);
916+
expect(config.command).toBe("tui");
917+
expect(config.resumeMode).toBe("id");
918+
expect(config.sessionId).toBe(sessionId);
919+
expect(config.skipInitialTask).toBe(true);
920+
expect(config.task).toBe("earlier work");
921+
},
922+
);
923+
924+
test.each([
925+
{ argv: ["exec", "ship it"] },
926+
{ argv: ["run", "ship", "it"] },
927+
{ argv: ["--provider", "fireworks", "exec", "ship it"] },
928+
{ argv: ["--auto", "--model", model, "run", "ship it"] },
929+
] as const)("--cwd <dir> $argv runs exec", async ({ argv }) => {
930+
const cwd = await emptyCwd();
931+
const config = await loadWithLeadingCwd(cwd, argv);
932+
expect(config.command).toBe("exec");
933+
expect(config.task).toBe("ship it");
934+
});
935+
936+
test("--director before exec sees exec mode", async () => {
937+
const cwd = await emptyCwd();
938+
const config = await loadWithLeadingCwd(cwd, [
939+
"--director",
940+
"dispatch",
941+
"exec",
942+
"ship it",
943+
]);
944+
expect(config.command).toBe("exec");
945+
expect(config.director).toBe("dispatch");
946+
expect(config.task).toBe("ship it");
947+
});
948+
949+
// A value flag's argument is never the subcommand, even when it spells one.
950+
test.each([["resume"], ["exec"]] as const)(
951+
"--provider %s keeps the next token as the subcommand or prompt",
952+
async (name) => {
953+
const cwd = await emptyCwd();
954+
const globalPath = await writeGlobalSettings(cwd, {
955+
defaultProvider: "fireworks",
956+
providers: {
957+
fireworks: {
958+
baseURL: "https://api.fireworks.ai/inference",
959+
apiKey: "test-key",
960+
models: [model],
961+
},
962+
[name]: {
963+
baseURL: "https://example.test/v1",
964+
apiKey: "other-key",
965+
models: ["other-model"],
966+
},
967+
},
968+
});
969+
const asPrompt = await loadConfig(
970+
["--cwd", cwd, "--provider", name, "hello"],
971+
{ globalSettingsPath: globalPath },
972+
);
973+
assertConfigured(asPrompt);
974+
expect(asPrompt.command).toBe("tui");
975+
expect(asPrompt.providerName).toBe(name);
976+
expect(asPrompt.resumeMode).toBeUndefined();
977+
expect(asPrompt.task).toBe("hello");
978+
979+
const asExec = await loadConfig(
980+
["--cwd", cwd, "--provider", name, "exec", "hello"],
981+
{ globalSettingsPath: globalPath },
982+
);
983+
assertConfigured(asExec);
984+
expect(asExec.command).toBe("exec");
985+
expect(asExec.providerName).toBe(name);
986+
expect(asExec.task).toBe("hello");
987+
},
988+
);
989+
990+
test("--resume consumes its argument before subcommand detection", async () => {
991+
const cwd = await emptyCwd();
992+
const globalPath = await writeGlobalSettings(cwd);
993+
await expect(
994+
loadConfig(["--cwd", cwd, "--resume", "exec", "hello"], {
995+
globalSettingsPath: globalPath,
996+
}),
997+
).rejects.toThrow("'exec' is not a session id");
998+
});
999+
1000+
test.each([
1001+
{ argv: ["fix", "the", "resume", "bug"], task: "fix the resume bug" },
1002+
{
1003+
argv: ["--auto", "explain", "exec", "mode"],
1004+
task: "explain exec mode",
1005+
},
1006+
] as const)(
1007+
"--cwd <dir> $argv stays a TUI prompt",
1008+
async ({ argv, task }) => {
1009+
const cwd = await emptyCwd();
1010+
const config = await loadWithLeadingCwd(cwd, argv);
1011+
expect(config.command).toBe("tui");
1012+
expect(config.resumeMode).toBeUndefined();
1013+
expect(config.task).toBe(task);
1014+
},
1015+
);
1016+
1017+
test("--cwd <dir> resume <non-uuid> still rejects the token", async () => {
1018+
const cwd = await emptyCwd();
1019+
const globalPath = await writeGlobalSettings(cwd);
1020+
await expect(
1021+
loadConfig(["--cwd", cwd, "resume", "not-a-uuid"], {
1022+
globalSettingsPath: globalPath,
1023+
}),
1024+
).rejects.toThrow(/not a session id/);
1025+
});
1026+
1027+
test.each([
1028+
{ argv: ["resume", "-p", "hello"] },
1029+
{ argv: ["-p", "continue"] },
1030+
] as const)(
1031+
"--cwd <dir> $argv rejects resume with -p",
1032+
async ({ argv }) => {
1033+
const cwd = await emptyCwd();
1034+
const globalPath = await writeGlobalSettings(cwd);
1035+
await expect(
1036+
loadConfig(["--cwd", cwd, ...argv], {
1037+
globalSettingsPath: globalPath,
1038+
}),
1039+
).rejects.toThrow("cannot combine resume with -p");
1040+
},
1041+
);
1042+
1043+
test.each([
1044+
[["--cwd", ".", "resume", "--help"]],
1045+
[["--cwd", ".", "exec", "-h"]],
1046+
[["--cwd", ".", "--", "--help"]],
1047+
])("%j still shows help", async (argv) => {
1048+
await expectCliHelp(argv);
1049+
});
1050+
1051+
test.each([
1052+
[["--cwd", ".", "resume", "--version"]],
1053+
[["--cwd", ".", "--", "-V"]],
1054+
])("%j still shows the version", async (argv) => {
1055+
await expectCliVersion(argv);
1056+
});
1057+
});
1058+
1059+
describe("-- ends flag parsing", () => {
1060+
test.each([
1061+
{ argv: ["--", "resume"], command: "tui", task: "resume" },
1062+
{
1063+
argv: ["--", "continue", "please"],
1064+
command: "tui",
1065+
task: "continue please",
1066+
},
1067+
{ argv: ["--auto", "--", "exec"], command: "tui", task: "exec" },
1068+
{ argv: ["exec", "--", "resume"], command: "exec", task: "resume" },
1069+
{ argv: ["-p", "--", "run"], command: "exec", task: "run" },
1070+
{ argv: ["--", "--model", "-p"], command: "tui", task: "--model -p" },
1071+
] as const)(
1072+
"--cwd <dir> $argv sends the rest as the prompt",
1073+
async ({ argv, command, task }) => {
1074+
const cwd = await emptyCwd();
1075+
const config = await loadWithLeadingCwd(cwd, argv);
1076+
expect(config.command).toBe(command);
1077+
expect(config.resumeMode).toBeUndefined();
1078+
expect(config.task).toBe(task);
1079+
},
1080+
);
1081+
1082+
test("CLI_HELP_TEXT documents the -- prompt escape", () => {
1083+
expect(CLI_HELP_TEXT).toMatch(/^ {2}--\s+\S/m);
1084+
});
1085+
});
1086+
8541087
test("plain corbits always creates fresh state even when a previous session exists", async () => {
8551088
const cwd = await emptyCwd();
8561089
const home = await tempHome();

0 commit comments

Comments
 (0)