diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index a978f4a99..62a1bd75b 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -424,6 +424,7 @@ 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 | | `--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. diff --git a/src/auth/store.test.ts b/src/auth/store.test.ts index a29f23cf6..ae0d7a9ed 100644 --- a/src/auth/store.test.ts +++ b/src/auth/store.test.ts @@ -11,7 +11,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { type } from "arktype"; -import { withMockedModule } from "../../tests/helpers/mock-module.js"; +import { withMockedModule } from "../../testkit/mock-module.js"; import type { BaseTokens } from "./store.js"; let failingUnlinkPath: string | undefined; diff --git a/src/config.test.ts b/src/config.test.ts index f9eee84a6..8e7d2e673 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -20,7 +20,9 @@ import { catalogEntryAsProviderSettings, CliHelpError, CliUserError, + CliVersionError, CLI_HELP_TEXT, + CLI_VERSION_TEXT, KEYLESS_API_KEY, loadConfig, providerCatalogToSettings, @@ -30,6 +32,7 @@ import { SOURCE_MAX_TOKENS, } from "./config/index.js"; import { DIRECTOR_IDS } from "./agent/directors/types.js"; +import pkg from "../package.json" with { type: "json" }; import { clearSourceCredentials, peekSourceCredentialSecret, @@ -200,6 +203,20 @@ async function expectCliHelp(argv: readonly string[]): Promise { } } +async function expectCliVersion(argv: readonly string[]): Promise { + try { + await loadConfig([...argv], { globalSettingsPath: NO_SETTINGS }); + expect.unreachable("expected CliVersionError"); + } catch (err) { + expect(err).toBeInstanceOf(CliVersionError); + const version = err as CliVersionError; + expect(version.exitCode).toBe(0); + expect(version.message).toBe(CLI_VERSION_TEXT); + expect(version.message).toContain(pkg.version); + expect(version.message).toBe(`corbits v${pkg.version}`); + } +} + describe("loadConfig", () => { test("resolves provider from the global settings file", async () => { const cwd = await emptyCwd(); @@ -951,6 +968,54 @@ describe("loadConfig", () => { }, ); + test("CLI_HELP_TEXT documents --version and -V", () => { + expect(CLI_HELP_TEXT).toContain("--version, -V"); + }); + + test.each([ + [["--version"]], + [["-V"]], + [["--auto", "--version"]], + [["--auto", "-V"]], + [["ship it", "--version"]], + [["ship", "it", "--version"]], + [["--cwd", ".", "--version"]], + [["--provider", "fireworks", "--version"]], + [["exec", "--version"]], + [["exec", "--director", "--version"]], + [["resume", "--pick", "--version"]], + [["resume", "-V"]], + [["resume", "--version"]], + [["continue", "-V"]], + // Value flags must not swallow --version / -V as their value. + [["--provider", "--version"]], + [["--provider", "-V"]], + [["--model", "--version"]], + [["--model", "-V"]], + [["--cwd", "--version"]], + [["--cwd", "-V"]], + [["--config", "--version"]], + [["--config", "-V"]], + [["--profile", "--version"]], + [["--profile", "-V"]], + ])( + "%j throws CliVersionError with exitCode 0 and package version", + async (argv) => { + await expectCliVersion(argv); + }, + ); + + test.each([ + [["--version", "--help"]], + [["-V", "-h"]], + [["--help", "--version"]], + ])( + "%j prefers CliHelpError when help and version are both present", + async (argv) => { + await expectCliHelp(argv); + }, + ); + test.each([ // Other flag-shaped tokens are not accepted as values. [["--provider", "--auto"], "--provider requires a value"], diff --git a/src/config/index.ts b/src/config/index.ts index a108ecf29..cf6cdeb59 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -9,6 +9,7 @@ import { } from "../session/index.js"; import { loadState } from "../session/state.js"; import { COMMAND_NAME } from "../branding.js"; +import pkg from "../../package.json" with { type: "json" }; import { isDirectorId } from "../agent/directors/registry.js"; import { DIRECTOR_IDS, type DirectorId } from "../agent/directors/types.js"; @@ -736,6 +737,7 @@ Flags: --config selects another source --auto / --no-auto auto mode on/off --help, -h show this help + --version, -V show version `; /** @@ -751,6 +753,24 @@ export class CliHelpError extends Error { } } +/** Printed for `corbits --version` / `-V`. Matches `package.json` version. */ +export const CLI_VERSION_TEXT = `${COMMAND_NAME} v${ + typeof pkg.version === "string" ? pkg.version : "0.0.0" +}`; + +/** + * Thrown when the operator asked for the CLI version. Entry points must print + * `message` to stdout and exit 0 — not treat this as a crash. + */ +export class CliVersionError extends Error { + readonly exitCode = 0 as const; + + constructor(text: string = CLI_VERSION_TEXT) { + super(text); + this.name = "CliVersionError"; + } +} + /** * Thrown for a recoverable operator mistake. Entry points must print * `message` to stderr and exit 1 — not dump a stack. @@ -781,7 +801,7 @@ export interface LoadConfigOptions { } function isFlagToken(arg: string): boolean { - return arg.startsWith("--") || arg === "-h" || arg === "-p"; + return arg.startsWith("--") || arg === "-h" || arg === "-p" || arg === "-V"; } export async function loadConfig( @@ -801,6 +821,9 @@ export async function loadConfig( if (argv.some((arg) => arg === "--help" || arg === "-h")) { throw new CliHelpError(); } + if (argv.some((arg) => arg === "--version" || arg === "-V")) { + throw new CliVersionError(); + } const args = [...argv]; diff --git a/src/index.test.ts b/src/index.test.ts index a66393660..8e1daacbf 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -3,7 +3,7 @@ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { Config } from "./config/index.js"; -import { CliHelpError, CliUserError } from "./config/index.js"; +import { CliHelpError, CliUserError, CliVersionError } from "./config/index.js"; import { resetPricingMetadataRefreshForTests, schedulePricingMetadataRefresh, @@ -171,6 +171,14 @@ test("CliHelpError prints help to stdout and exits 0", () => { expect(exit.text).toBe("usage: corbits\n"); }); +test("CliVersionError prints version to stdout and exits 0", () => { + const err = new CliVersionError("1.2.3"); + const exit = cliCaughtExit(err); + expect(exit.stream).toBe("stdout"); + expect(exit.code).toBe(0); + expect(exit.text).toBe("1.2.3\n"); +}); + test("generic Error still dumps a stack to stderr", () => { const err = new Error("No session abc for this project"); const exit = cliCaughtExit(err); diff --git a/src/index.ts b/src/index.ts index 1e0b8230e..142a75b00 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,12 @@ import { getLogger } from "@intx/log"; import { LOG_NAMESPACE_ROOT } from "./branding.js"; import { primeCrashReporting } from "./crash/report.js"; -import { loadConfig, CliHelpError, CliUserError } from "./config/index.js"; +import { + loadConfig, + CliHelpError, + CliUserError, + CliVersionError, +} from "./config/index.js"; import { ensureTelemetrySettings, globalSettingsPath, @@ -139,7 +144,7 @@ export function cliCaughtExit(err: unknown): { text: string; code: number; } { - if (err instanceof CliHelpError) { + if (err instanceof CliHelpError || err instanceof CliVersionError) { return { stream: "stdout", text: `${err.message}\n`, code: err.exitCode }; } if (err instanceof CliUserError) {