cli · git:20260928.21b7769 · 2026-09-28 · sha256 0158c18ec7a5c304

cli git:20260928.21b7769A

Immutable. This exact content never changes and is served at /api/v1/blob/0158c18ec7a5c304.

---
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.
---

# CLI

New scripts parse arguments with [citty](https://github.com/unjs/citty). Older scripts still use cleye.

## Basic Usage

```ts
#!/usr/bin/env bun

import { defineCommand, runMain } from "citty";

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
  },
});

if (import.meta.main) {
  await runMain(main);
}
```

`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.

## Args

Each key in `args` takes a `type`:

- `string`: `--name value` or `--name=value`
- `boolean`: `--flag`
- `enum`: `--mode a`, validated against `options`, typed as their union
- `positional`: matched in declaration order

`required: true` or a `default` narrows the type to exclude `undefined`. A camelCase key (`dryRun`) accepts the kebab-case flag (`--dry-run`).

Typing gaps to work around:

- 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`.

## Subcommands

Pass commands to `subCommands`. A group is a command whose `subCommands` holds its actions, so `tool trial start` nests with no extra wiring:

```ts
import { defineCommand, runMain } from "citty";

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);
  },
});

const end = defineCommand({
  meta: { name: "end", description: "End a trial." },
  run() {},
});

const trial = defineCommand({
  meta: { name: "trial", description: "Start or end a trial." },
  subCommands: { start, end },
});

export const main = defineCommand({
  meta: { name: "watch-session" },
  subCommands: { trial },
});
```

- 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.

## 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.

## 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`