cli · diff
git:20260928.21b7769 to git:20260928.8b55f03
146 added, 62 removed. Audit A to A.
---
name: cli
- description: Type-safe CLI argument parsing with citty, the standard parser for this repo's Bun scripts. Use when writing or editing any script that takes arguments (flags, positional parameters, subcommands, nested subcommands, --help) instead of reading existing scripts for the pattern.
+ description: Type-safe CLI argument parsing with commander and @commander-js/extra-typings, the standard parser for this repo's Bun scripts. Use when writing or editing any script that takes arguments (flags, positional parameters, subcommands, nested subcommands, --help) instead of reading existing scripts for the pattern.
---
# CLI
- New scripts parse arguments with [citty](https://github.com/unjs/citty). Older scripts still use cleye.
+ New scripts parse arguments with [commander](https://github.com/tj/commander.js) through [`@commander-js/extra-typings`](https://github.com/commander-js/extra-typings), which infers each option's and argument's type from its flag string. Scripts still on cleye migrate to commander over time.
+ Import `Command`, `Option`, and the error classes from `@commander-js/extra-typings`. Importing them from `commander` drops the inference and types every option as `any`.
+
## Basic Usage
```ts
#!/usr/bin/env bun
- import { defineCommand, runMain } from "citty";
+ import { Command, Option } from "@commander-js/extra-typings";
- export const main = defineCommand({
- meta: { name: "scan", description: "Scan repository prose for AI writing tropes." },
- args: {
- path: { type: "positional", required: true, description: "Directory to scan" },
- output: { type: "string", alias: "o", description: "Output path" },
- format: { type: "enum", options: ["text", "json"], default: "text", description: "Output format" },
- verbose: { type: "boolean", description: "Print every match" },
- },
- run({ args }) {
- console.log(args.path); // string
- console.log(args.output); // string | undefined
- console.log(args.format); // "text" | "json"
- console.log(args.verbose); // boolean | undefined
- },
- });
+ export const program = new Command("scan")
+ .description("Scan repository prose for AI writing tropes.")
+ .argument("<path>", "directory to scan")
+ .option("-o, --output <file>", "output path")
+ .addOption(new Option("--format <format>", "output format").choices(["text", "json"]).default("text"))
+ .option("--dry-run", "report without writing")
+ .action((path, options) => {
+ console.log(path); // string
+ console.log(options.output); // string | undefined
+ console.log(options.format); // "text" | "json"
+ console.log(options.dryRun); // true | undefined
+ });
if (import.meta.main) {
- await runMain(main);
+ await program.parseAsync();
}
```
- `runMain` handles `--help`/`-h`, and `--version` when `meta.version` is set. A missing required argument, an invalid enum value, or an unknown subcommand prints usage and exits 1.
+ The action receives each declared argument in order, then the options object, then the command. Reading an undeclared key such as `options.outptu` is a type error.
- ## Args
+ `parseAsync` handles `--help` and `-h`, and awaits an async action. A missing argument, an unknown option, an excess positional, or a value outside `choices` prints an error and exits 1.
- Each key in `args` takes a `type`:
+ ## Options
- - `string`: `--name value` or `--name=value`
- - `boolean`: `--flag`
- - `enum`: `--mode a`, validated against `options`, typed as their union
- - `positional`: matched in declaration order
+ | Declaration | Type |
+ | --- | --- |
+ | `--name <value>` | `string \| undefined` |
+ | `--flag` | `true \| undefined` |
+ | `--no-color` | `boolean`, default `true` |
+ | `--since [date]` | `string \| true \| undefined` |
+ | `.requiredOption("--repo <r>")` | `string` |
+ | `.option("--limit <n>", "…", int, 20)` | `number` |
+ | `.argument("<path>")` | `string` |
+ | `.argument("[files...]")` | `string[]` |
- `required: true` or a `default` narrows the type to exclude `undefined`. A camelCase key (`dryRun`) accepts the kebab-case flag (`--dry-run`).
+ A default or `requiredOption` drops `undefined` from the type. A kebab-case flag (`--dry-run`) arrives as a camelCase key (`dryRun`). An enum needs `addOption` with `new Option(...).choices([...])`, which types the value as the union.
- Typing gaps to work around:
+ ### Numbers and Repeated Flags
- - No number type. Declare `string` and convert with `Number()`, rejecting `NaN`.
- - No repeated flag. `--tag a --tag b` keeps the last value. Take a comma-separated string and split it.
- - No variadic positional. `args._` holds every positional value plus everything after `--`.
- - Parsed args carry an index signature, so a misspelled key type-checks as `string | number | boolean | string[]`. Read only the keys declared in `args`.
+ Commander has no number type and keeps only the last value of a repeated flag. Pass a parser function as the third argument for both:
+ ```ts
+ #!/usr/bin/env bun
+
+ import { Command, InvalidArgumentError } from "@commander-js/extra-typings";
+
+ function int(value: string): number {
+ const n = Number(value);
+ if (!Number.isInteger(n)) throw new InvalidArgumentError("Not an integer.");
+ return n;
+ }
+
+ function collect(value: string, previous: string[] = []): string[] {
+ return [...previous, value];
+ }
+
+ export const program = new Command("search")
+ .option("-n, --limit <n>", "maximum results", int, 20)
+ .option("--tag <name>", "tag to match, repeatable", collect, [])
+ .requiredOption("--label <name>", "label to apply, repeatable", collect)
+ .action((options) => {
+ console.log(options.limit); // number
+ console.log(options.tag); // string[]
+ console.log(options.label); // string[]
+ });
+
+ if (import.meta.main) {
+ await program.parseAsync();
+ }
+ ```
+
+ - Copy `int` and `collect` into each script that uses them. A distributed plugin cannot import them from another plugin or a workspace package, and they are too small to publish.
+ - A required repeated flag takes `collect` with no default. `requiredOption` counts a `[]` default as present and never reports the flag missing.
+ - An `InvalidArgumentError` thrown from a parser prints `error: option '...' argument '...' is invalid. <message>` and exits 1.
+ - Use `collect` for repeated flags instead of a variadic option (`--tag <names...>`), which also absorbs every positional that follows it.
+
## Subcommands
- Pass commands to `subCommands`. A group is a command whose `subCommands` holds its actions, so `tool trial start` nests with no extra wiring:
+ Build each command with `new Command(name)` and attach it with `addCommand`. A group is a command whose children are its actions, so `watch-session trial start` nests with no extra wiring:
```ts
- import { defineCommand, runMain } from "citty";
+ #!/usr/bin/env bun
- const start = defineCommand({
- meta: { name: "start", description: "Start a trial." },
- args: {
- name: { type: "string", default: "trial", description: "Trial name" },
- },
- run({ args, rawArgs }) {
- const forwarded = rawArgs.includes("--") ? rawArgs.slice(rawArgs.indexOf("--") + 1) : [];
- console.log(args.name, forwarded);
- },
- });
+ import { Command } from "@commander-js/extra-typings";
- const end = defineCommand({
- meta: { name: "end", description: "End a trial." },
- run() {},
- });
+ export const start = new Command("start")
+ .description("Start a trial.")
+ .option("--name <name>", "trial name", "trial")
+ .argument("<command...>", "command to run, with its arguments")
+ .passThroughOptions()
+ .action((forward, options) => {
+ console.log(options.name, forward);
+ });
- const trial = defineCommand({
- meta: { name: "trial", description: "Start or end a trial." },
- subCommands: { start, end },
+ const end = new Command("end").description("End a trial.").action(() => {
+ console.log("ended");
});
- export const main = defineCommand({
- meta: { name: "watch-session" },
- subCommands: { trial },
- });
+ const trial = new Command("trial")
+ .description("Start or end a trial.")
+ .enablePositionalOptions()
+ .addCommand(start)
+ .addCommand(end);
+
+ export const program = new Command("watch-session").enablePositionalOptions().addCommand(trial);
+
+ if (import.meta.main) {
+ await program.parseAsync();
+ }
```
- - A group without `run` prints its usage when invoked without an action.
- - `rawArgs` is relative to the command that runs, so a leaf that forwards arguments to another program slices it after `--`.
- - `setup` and `cleanup` hooks run around `run` on every command in the path.
+ - A group without an action prints its usage and exits 1 when invoked without a subcommand.
+ - Options declared on a group belong to the group, and must precede the subcommand under `enablePositionalOptions()`. A leaf reads them with `command.optsWithGlobals()`, which types them only when the group's `.command(name)` created the leaf. A leaf attached with `addCommand` gets them at runtime without the types.
+ - `--` does not reach the action as its own value. `[forward...]` holds the operands before `--` and after it with no boundary between them, so a stray positional before `--` gets forwarded.
+ - To forward arguments to another program, declare `<command...>` and call `passThroughOptions()` on the leaf. Everything from the first positional onward forwards verbatim, including flags, so `start --name x git diff --stat` and `start --name x -- git diff --stat` both forward `git diff --stat`.
+ - Bun drops a `--` that comes first after the script path, so `./tool.ts -- git --stat` reaches commander as `git --stat`. `passThroughOptions()` forwards both spellings the same way.
+ - `passThroughOptions()` requires `enablePositionalOptions()` on every ancestor, and throws at parse time when one lacks it.
## Testing
- `runCommand(main, { rawArgs: ["trial", "start", "--name", "x"] })` runs the full command path without `process.argv` or `process.exit`, and throws `CLIError` on a usage error. It does not return a nested leaf's `run` value, so assert on the exported core functions or on side effects.
+ Call `exitOverride()` and `configureOutput()` on every command in the tree. `addCommand` does not copy them to children. With both set, a parse error or `--help` throws a `CommanderError` carrying `code` and `exitCode` instead of exiting, and output lands in the test.
+ ```ts
+ import { CommanderError, type CommandUnknownOpts } from "@commander-js/extra-typings";
+ import { expect, test } from "bun:test";
+ import { program, start } from "./watch-session";
+
+ async function run(command: CommandUnknownOpts, argv: string[]): Promise<string> {
+ let output = "";
+ const configure = (cmd: CommandUnknownOpts): void => {
+ cmd.exitOverride().configureOutput({
+ writeOut: (text) => {
+ output += text;
+ },
+ writeErr: (text) => {
+ output += text;
+ },
+ });
+ for (const sub of cmd.commands) configure(sub);
+ };
+ configure(command);
+ await command.parseAsync(argv, { from: "user" });
+ return output;
+ }
+
+ test("forwards the command after the leaf's options", async () => {
+ await run(program, ["trial", "start", "--name", "x", "--", "git", "diff"]);
+ expect(start.opts().name).toBe("x");
+ expect(start.processedArgs).toEqual([["git", "diff"]]);
+ });
+
+ test.each<{ name: string; argv: string[]; code: string; exitCode: number }>([
+ { name: "unknown subcommand", argv: ["trial", "nope"], code: "commander.unknownCommand", exitCode: 1 },
+ { name: "help", argv: ["--help"], code: "commander.helpDisplayed", exitCode: 0 },
+ ])("$name exits through CommanderError", async ({ argv, code, exitCode }) => {
+ const failure = await run(program, argv).catch((error: unknown) => error);
+ expect(failure).toBeInstanceOf(CommanderError);
+ expect(failure).toMatchObject({ code, exitCode });
+ });
+ ```
+
+ - Pass `{ from: "user" }` so commander reads `argv` as arguments only, with no runtime and script path in front.
+ - `parseAsync` resolves to the command, not the action's return value. Assert on a leaf's typed `opts()` and `processedArgs`, or on the exported core functions the action calls.
+ - Reparsing the same program is safe. Commander restores option defaults before each parse.
+
## Conventions
- Shebang: `#!/usr/bin/env bun`
- - Entry point: call `runMain` inside `if (import.meta.main)` so the module is importable
- - Export the root command and core functions. Keep parsing in the command definitions
- - A distributed plugin declares `citty` in its own `package.json` and regenerates its lockfile with `bun run plugin-lockfiles generate`
+ - Entry point: call `await program.parseAsync()` inside `if (import.meta.main)` so the module is importable.
+ - Export the root program, any leaf a test inspects, and the core functions. Keep argument parsing in the command definitions.
+ - A distributed plugin declares both `commander` and `@commander-js/extra-typings` in its own `package.json` at the same `~15.0.0` range, then regenerates its lockfile with `bun run plugin-lockfiles generate`. `extra-typings` pins its `commander` peer to one minor line, so the two upgrade together. Renovate groups them into one PR.