From 57fc6e0528515a2144899ff4cee09443d59183fc Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 31 Aug 2026 13:01:48 -0700 Subject: [PATCH 1/3] Recognize CLI help regardless of argument position Value flags were binding the next token even when it was --help or another flag. Scan argv for help first, and reject flag-shaped tokens as required option values. --- CHANGELOG.md | 2 ++ src/config.test.ts | 61 +++++++++++++++++++++++++++++++++++++++++++++ src/config/index.ts | 15 +++++++---- 3 files changed, 73 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b3d14b5..6d33be65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,8 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename live-identity rule. Context usage and `/cost` still work; `/cost` reports Codex cost as covered by ChatGPT subscription. Metered OpenAI API endpoints keep dollar estimates. +- CLI `--help` / `-h` is recognized in any argument position. Value flags no + longer swallow help or other flag-shaped tokens as their option values. ## [0.3.11] - 2026-08-31 diff --git a/src/config.test.ts b/src/config.test.ts index 633911bb..a6c59a2a 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -741,6 +741,67 @@ describe("loadConfig", () => { } }); + test("--help after flags throws CliHelpError", async () => { + await expect( + loadConfig(["--force", "--help"], { globalSettingsPath: NO_SETTINGS }), + ).rejects.toBeInstanceOf(CliHelpError); + await expect( + loadConfig(["--force", "-h"], { globalSettingsPath: NO_SETTINGS }), + ).rejects.toBeInstanceOf(CliHelpError); + }); + + test("--help after a positional throws CliHelpError", async () => { + await expect( + loadConfig(["ship it", "--help"], { globalSettingsPath: NO_SETTINGS }), + ).rejects.toBeInstanceOf(CliHelpError); + }); + + test("resume -h / --help throws CliHelpError instead of treating it as a session id", async () => { + await expect( + loadConfig(["resume", "-h"], { globalSettingsPath: NO_SETTINGS }), + ).rejects.toBeInstanceOf(CliHelpError); + await expect( + loadConfig(["resume", "--help"], { globalSettingsPath: NO_SETTINGS }), + ).rejects.toBeInstanceOf(CliHelpError); + await expect( + loadConfig(["continue", "-h"], { globalSettingsPath: NO_SETTINGS }), + ).rejects.toBeInstanceOf(CliHelpError); + }); + + test("value flags do not swallow --help / -h as their value", async () => { + for (const flag of ["--provider", "--model", "--cwd", "--config", "--profile"] as const) { + await expect( + loadConfig([flag, "--help"], { globalSettingsPath: NO_SETTINGS }), + ).rejects.toBeInstanceOf(CliHelpError); + await expect( + loadConfig([flag, "-h"], { globalSettingsPath: NO_SETTINGS }), + ).rejects.toBeInstanceOf(CliHelpError); + } + }); + + test("value flags reject other flag-shaped tokens as values", async () => { + await expect( + loadConfig(["--provider", "--force"], { globalSettingsPath: NO_SETTINGS }), + ).rejects.toThrow("--provider requires a value"); + await expect( + loadConfig(["--model", "--cwd"], { globalSettingsPath: NO_SETTINGS }), + ).rejects.toThrow("--model requires a value"); + await expect( + loadConfig(["exec", "--director", "--force", "ship it"], { + globalSettingsPath: NO_SETTINGS, + }), + ).rejects.toThrow("--director requires a value"); + }); + + test("value flags still error clearly when the value is omitted", async () => { + await expect(loadConfig(["--provider"], { globalSettingsPath: NO_SETTINGS })).rejects.toThrow( + "--provider requires a value", + ); + await expect(loadConfig(["--model"], { globalSettingsPath: NO_SETTINGS })).rejects.toThrow( + "--model requires a value", + ); + }); + test("rejects unknown flags", async () => { await expect(loadConfig(["--unknown"], { globalSettingsPath: NO_SETTINGS })).rejects.toThrow( /unrecognized flag/, diff --git a/src/config/index.ts b/src/config/index.ts index cff1ebd7..f786c625 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -567,6 +567,12 @@ export async function loadConfig( argv: readonly string[], options: LoadConfigOptions = {}, ): Promise { + // Help wins in any position, including after subcommands and immediately + // after a value flag that would otherwise swallow the token as its value. + if (argv.some((arg) => arg === "--help" || arg === "-h")) { + throw new CliHelpError(); + } + const args = [...argv]; // Leading subcommand: `corbits exec "prompt"` (alias: `run`). Default is TUI. @@ -603,10 +609,6 @@ export async function loadConfig( } } - if (args[0] === "--help" || args[0] === "-h") { - throw new CliHelpError(); - } - let cwd = process.cwd(); let force = false; let dangerouslySkipPermissions = false; @@ -626,7 +628,10 @@ export async function loadConfig( const positional: string[] = []; const requireValue = (flag: string, value: string | undefined): string => { - if (value === undefined) { + // Flag-shaped tokens are never option values. `--provider --force` and a + // trailing `--provider` both surface as a missing value rather than binding + // the next flag (or accepting `--help`, which is already handled above). + if (value === undefined || value.startsWith("-")) { throw new Error(`${flag} requires a value`); } return value; From 2b2c6b4d3a10dcfe7a5a018b57cd9f481316f70d Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 31 Aug 2026 14:16:43 -0700 Subject: [PATCH 2/3] Reject only real flags as option values not dash-prefixed paths Value flags now reject --* and -h via isFlagToken instead of every dash-prefixed token, so POSIX paths like --cwd -my-dir still bind. --- CHANGELOG.md | 2 +- src/config.test.ts | 93 +++++++++++++++++++++++++++------------------ src/config/index.ts | 8 +++- 3 files changed, 63 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d33be65..5e0c40b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,7 +22,7 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename reports Codex cost as covered by ChatGPT subscription. Metered OpenAI API endpoints keep dollar estimates. - CLI `--help` / `-h` is recognized in any argument position. Value flags no - longer swallow help or other flag-shaped tokens as their option values. + longer swallow `--*` or `-h` as their option values. ## [0.3.11] - 2026-08-31 diff --git a/src/config.test.ts b/src/config.test.ts index a6c59a2a..f12e8667 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -73,6 +73,18 @@ async function emptyCwd(): Promise { return mkdtemp(join(tmpdir(), "ic-config-")); } +async function expectCliHelp(argv: readonly string[]): Promise { + try { + await loadConfig([...argv], { globalSettingsPath: NO_SETTINGS }); + expect.unreachable("expected CliHelpError"); + } catch (err) { + expect(err).toBeInstanceOf(CliHelpError); + const help = err as CliHelpError; + expect(help.exitCode).toBe(0); + expect(help.message).toBe(CLI_HELP_TEXT); + } +} + describe("loadConfig", () => { test("resolves provider from the global settings file", async () => { const cwd = await emptyCwd(); @@ -726,56 +738,44 @@ describe("loadConfig", () => { }); test("--help throws CliHelpError with exitCode 0 and full help text", async () => { - await expect( - loadConfig(["--help"], { globalSettingsPath: NO_SETTINGS }), - ).rejects.toBeInstanceOf(CliHelpError); - try { - await loadConfig(["-h"], { globalSettingsPath: NO_SETTINGS }); - expect.unreachable("expected CliHelpError"); - } catch (err) { - expect(err).toBeInstanceOf(CliHelpError); - const help = err as CliHelpError; - expect(help.exitCode).toBe(0); - expect(help.message).toBe(CLI_HELP_TEXT); - expect(help.message).toContain("resume"); - } + await expectCliHelp(["--help"]); + await expectCliHelp(["-h"]); }); test("--help after flags throws CliHelpError", async () => { - await expect( - loadConfig(["--force", "--help"], { globalSettingsPath: NO_SETTINGS }), - ).rejects.toBeInstanceOf(CliHelpError); - await expect( - loadConfig(["--force", "-h"], { globalSettingsPath: NO_SETTINGS }), - ).rejects.toBeInstanceOf(CliHelpError); + await expectCliHelp(["--force", "--help"]); + await expectCliHelp(["--force", "-h"]); }); test("--help after a positional throws CliHelpError", async () => { - await expect( - loadConfig(["ship it", "--help"], { globalSettingsPath: NO_SETTINGS }), - ).rejects.toBeInstanceOf(CliHelpError); + await expectCliHelp(["ship it", "--help"]); + await expectCliHelp(["ship", "it", "--help"]); + }); + + test("--help after a bound flag value throws CliHelpError", async () => { + await expectCliHelp(["--cwd", ".", "--help"]); + await expectCliHelp(["--provider", "fireworks", "--help"]); + }); + + test("exec --help throws CliHelpError", async () => { + await expectCliHelp(["exec", "--help"]); + await expectCliHelp(["exec", "--director", "--help"]); + }); + + test("resume --pick --help throws CliHelpError", async () => { + await expectCliHelp(["resume", "--pick", "--help"]); }); test("resume -h / --help throws CliHelpError instead of treating it as a session id", async () => { - await expect( - loadConfig(["resume", "-h"], { globalSettingsPath: NO_SETTINGS }), - ).rejects.toBeInstanceOf(CliHelpError); - await expect( - loadConfig(["resume", "--help"], { globalSettingsPath: NO_SETTINGS }), - ).rejects.toBeInstanceOf(CliHelpError); - await expect( - loadConfig(["continue", "-h"], { globalSettingsPath: NO_SETTINGS }), - ).rejects.toBeInstanceOf(CliHelpError); + await expectCliHelp(["resume", "-h"]); + await expectCliHelp(["resume", "--help"]); + await expectCliHelp(["continue", "-h"]); }); test("value flags do not swallow --help / -h as their value", async () => { for (const flag of ["--provider", "--model", "--cwd", "--config", "--profile"] as const) { - await expect( - loadConfig([flag, "--help"], { globalSettingsPath: NO_SETTINGS }), - ).rejects.toBeInstanceOf(CliHelpError); - await expect( - loadConfig([flag, "-h"], { globalSettingsPath: NO_SETTINGS }), - ).rejects.toBeInstanceOf(CliHelpError); + await expectCliHelp([flag, "--help"]); + await expectCliHelp([flag, "-h"]); } }); @@ -786,6 +786,9 @@ describe("loadConfig", () => { await expect( loadConfig(["--model", "--cwd"], { globalSettingsPath: NO_SETTINGS }), ).rejects.toThrow("--model requires a value"); + await expect( + loadConfig(["--cwd", "--tmp"], { globalSettingsPath: NO_SETTINGS }), + ).rejects.toThrow("--cwd requires a value"); await expect( loadConfig(["exec", "--director", "--force", "ship it"], { globalSettingsPath: NO_SETTINGS, @@ -802,6 +805,22 @@ describe("loadConfig", () => { ); }); + test("value flags accept a POSIX path that starts with a single dash", async () => { + const parent = await emptyCwd(); + const dashedCwd = join(parent, "-my-dir"); + await mkdir(dashedCwd); + try { + const globalPath = await writeGlobalSettings(dashedCwd); + const config = await loadConfig(["--cwd", dashedCwd, "do something"], { + globalSettingsPath: globalPath, + }); + assertConfigured(config); + expect(config.cwd).toBe(dashedCwd); + } finally { + await rm(parent, { recursive: true, force: true }); + } + }); + test("rejects unknown flags", async () => { await expect(loadConfig(["--unknown"], { globalSettingsPath: NO_SETTINGS })).rejects.toThrow( /unrecognized flag/, diff --git a/src/config/index.ts b/src/config/index.ts index f786c625..3bda5d1f 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -555,6 +555,10 @@ export interface LoadConfigOptions { pricing?: PricingFetcherOptions; } +function isFlagToken(arg: string): boolean { + return arg.startsWith("--") || arg === "-h"; +} + export async function loadConfig( argv: readonly string[], options?: LoadConfigOptions & { allowUnconfigured?: false }, @@ -595,7 +599,7 @@ export async function loadConfig( if (next === "--pick" || next === "--list") { resumeMode = "pick"; args.shift(); - } else if (next !== undefined && !next.startsWith("--")) { + } else if (next !== undefined && !isFlagToken(next)) { if (!isSessionId(next)) { throw new Error( `'${next}' is not a session id. Use a UUID session id or \`corbits resume\` to choose.`, @@ -631,7 +635,7 @@ export async function loadConfig( // Flag-shaped tokens are never option values. `--provider --force` and a // trailing `--provider` both surface as a missing value rather than binding // the next flag (or accepting `--help`, which is already handled above). - if (value === undefined || value.startsWith("-")) { + if (value === undefined || isFlagToken(value)) { throw new Error(`${flag} requires a value`); } return value; From 7b55aaaf7f03a71928be7d76c760d13a9961c7b3 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 31 Aug 2026 19:12:23 -0700 Subject: [PATCH 3/3] Pin dash-prefixed cwd tokens in CLI flag tests The previous case passed an absolute path whose last segment was -my-dir, so reverting isFlagToken to startsWith("-") would still pass CI while breaking corbits --cwd -my-dir. Bind the argv token itself and assert cwd is resolve("-my-dir"). Also cover omitted --cwd/--config/--profile. --- src/config.test.ts | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/src/config.test.ts b/src/config.test.ts index f12e8667..12f37bfb 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -1,7 +1,7 @@ import { describe, test, expect } from "bun:test"; import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { join, resolve } from "node:path"; import { buildBifrostSource, @@ -803,22 +803,23 @@ describe("loadConfig", () => { await expect(loadConfig(["--model"], { globalSettingsPath: NO_SETTINGS })).rejects.toThrow( "--model requires a value", ); + await expect(loadConfig(["--cwd"], { globalSettingsPath: NO_SETTINGS })).rejects.toThrow( + "--cwd requires a value", + ); + await expect(loadConfig(["--config"], { globalSettingsPath: NO_SETTINGS })).rejects.toThrow( + "--config requires a value", + ); + await expect(loadConfig(["--profile"], { globalSettingsPath: NO_SETTINGS })).rejects.toThrow( + "--profile requires a value", + ); }); test("value flags accept a POSIX path that starts with a single dash", async () => { - const parent = await emptyCwd(); - const dashedCwd = join(parent, "-my-dir"); - await mkdir(dashedCwd); - try { - const globalPath = await writeGlobalSettings(dashedCwd); - const config = await loadConfig(["--cwd", dashedCwd, "do something"], { - globalSettingsPath: globalPath, - }); - assertConfigured(config); - expect(config.cwd).toBe(dashedCwd); - } finally { - await rm(parent, { recursive: true, force: true }); - } + const config = await loadConfig(["--cwd", "-my-dir", "do something"], { + allowUnconfigured: true, + globalSettingsPath: NO_SETTINGS, + }); + expect(config.cwd).toBe(resolve("-my-dir")); }); test("rejects unknown flags", async () => {