ADR-004 — The CLI layer and the build
cac imports in 3.3 ms against 17.1 ms for commander, and everything else is hand-rolled.
Date: 2026-09-02 · Status: accepted · Scope: stack
Context
ADR-003 freed the core of dependencies, but the CLI layer has jobs where writing
your own stops paying off: argument parsing, --help generation, and suggestions
when someone mistypes a command.
Options considered
| Option | For | Against |
|---|---|---|
cac 7.0.0 | 3.3 ms — cheapest measured; 41 KB; --help included | No releases in a long time — risk of abandonment |
citty 0.2.2 | Actively developed (UnJS); 35 KB | 8.3 ms — twice the cost; 0.x, so the API can still move |
commander 15.0.0 | The de facto standard, best documented | 17.1 ms — 11% of the budget just to parse arguments |
| Own parser | 0 ms | --help, subcommands and user errors are not 80 lines, they are permanent debt |
Decision
| Role | Choice | Why |
|---|---|---|
| Argument parsing | cac | Cheapest; the API is stable precisely because the project is finished |
| Colour | own ANSI constants | ~10 lines with NO_COLOR and isTTY checks; picocolors costs 7.7 ms for the same |
| Tables | own renderer | Column widths for three fields; cli-table3 pulls string-width |
| Frontmatter | own parser | gray-matter is 13 ms and a js-yaml dependency; we read a few key: value lines before --- |
| Build | esbuild | 147 KB, no dependencies; tsup is a wrapper that pulls cac, chokidar, consola |
| Tests | vitest | A dev dependency; it never touches the runtime budget |
On the cac risk: it is confined to one module that only declares commands and
hands parsed arguments to the core. Replacing the parser would not touch a single
core module — a few hours, not a rewrite.
Amendment, same day, after implementation
cac does not match multi-word commands. Declaring cli.command('task add <title>') appears in --help and looks like it works, but no action ever fires:
matchedCommand stays empty and the CLI exits quietly with code 0. Found on the
first end-to-end run.
The workaround is a task <action> [title] pattern with the action parsed inside
the handler. The cost is writing per-subcommand --help ourselves.
This does not change the decision — cac is still the cheapest by measurement.
But the risk was named wrongly: we expected abandonment and got a functional
limit instead.
Consequences
- Total import cost of runtime dependencies is 3.3 ms instead of 40+, and the core stays embeddable as a library.
- Own colour, tables and frontmatter are code we maintain. Each must stay trivial; if one starts growing, that is the signal to take a library.
- What would make us revisit it: if
cacbreaks on a new major Node, we move tocittyand pay the 5 ms.