kadence
Decisions

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

OptionForAgainst
cac 7.0.03.3 ms — cheapest measured; 41 KB; --help includedNo releases in a long time — risk of abandonment
citty 0.2.2Actively developed (UnJS); 35 KB8.3 ms — twice the cost; 0.x, so the API can still move
commander 15.0.0The de facto standard, best documented17.1 ms — 11% of the budget just to parse arguments
Own parser0 ms--help, subcommands and user errors are not 80 lines, they are permanent debt

Decision

RoleChoiceWhy
Argument parsingcacCheapest; the API is stable precisely because the project is finished
Colourown ANSI constants~10 lines with NO_COLOR and isTTY checks; picocolors costs 7.7 ms for the same
Tablesown rendererColumn widths for three fields; cli-table3 pulls string-width
Frontmatterown parsergray-matter is 13 ms and a js-yaml dependency; we read a few key: value lines before ---
Buildesbuild147 KB, no dependencies; tsup is a wrapper that pulls cac, chokidar, consola
TestsvitestA 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 cac breaks on a new major Node, we move to citty and pay the 5 ms.

On this page