diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b3d14b5..5e0c40b4 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 `--*` 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 633911bb..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, @@ -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,21 +738,90 @@ 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 expectCliHelp(["--force", "--help"]); + await expectCliHelp(["--force", "-h"]); + }); + + test("--help after a positional throws CliHelpError", async () => { + 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 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 expectCliHelp([flag, "--help"]); + await expectCliHelp([flag, "-h"]); } }); + 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(["--cwd", "--tmp"], { globalSettingsPath: NO_SETTINGS }), + ).rejects.toThrow("--cwd 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", + ); + 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 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 () => { 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..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 }, @@ -567,6 +571,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. @@ -589,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.`, @@ -603,10 +613,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 +632,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 || isFlagToken(value)) { throw new Error(`${flag} requires a value`); } return value;