Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
107 changes: 94 additions & 13 deletions src/config.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -73,6 +73,18 @@ async function emptyCwd(): Promise<string> {
return mkdtemp(join(tmpdir(), "ic-config-"));
}

async function expectCliHelp(argv: readonly string[]): Promise<void> {
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();
Expand Down Expand Up @@ -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/,
Expand Down
21 changes: 15 additions & 6 deletions src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand All @@ -567,6 +571,12 @@ export async function loadConfig(
argv: readonly string[],
options: LoadConfigOptions = {},
): Promise<Config | UnconfiguredConfig> {
// 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.
Expand All @@ -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.`,
Expand All @@ -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;
Expand All @@ -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;
Expand Down
Loading