Blog

The CLI is a product surface

2026-06-12

An isometric illustration of an espresso machine with a group head, a portafilter handle and a pressure dial

For most of what Pithy does, the CLI is the product. You will type pithy add, pithy migrate and pithy deploy far more often than you will read our source.

So the tools it is built from were picked deliberately, and mostly on criteria that do not appear in a feature comparison.

Citty, for cold start

Citty handles commands, arguments and help.

The reason is startup time. A hello-world Citty command starts in roughly 50-100ms. The equivalent on Commander is closer to 250-400ms, and Oclif is heavier still.

That gap does not matter for a tool you run twice a day. It matters a great deal for one you run in a loop, and it compounds every time you sit waiting to find out whether the last change worked.

The rest follows from growth. Subcommands load lazily via native () => import(), so adding the twentieth command does not slow the first. The help renderer is overridable, which matters because our help has its own voice and layout. Shell completions for bash, zsh and fish come built in.

picocolors, for size and for NO_COLOR

picocolors is about 1KB with zero dependencies, and it handles NO_COLOR, FORCE_COLOR and TTY detection on its own.

That last part is the actual reason. Every terminal tool eventually has to decide whether to emit escape codes, and the correct answer depends on environment variables, on whether stdout is a TTY, and on which CI you are running in. Get it wrong and you either lose color in a terminal that wanted it, or fill a log file with raw escape sequences.

It is a solved problem with a wrong answer in every codebase that solved it itself.

clack, for how it fails

@clack/prompts does the interactive parts — questions, selections, spinners.

The criterion that decided it was cancellation. Pressing Ctrl+C in a clack prompt produces a clean exit. In several alternatives it produces a hung process, a half-written file, or a stack trace.

You will cancel a prompt. Everybody does — you start a command, realize you wanted a different flag, and interrupt. A CLI where that leaves your config half-edited is one you stop trusting quickly.

It also degrades correctly when nothing is attached to stdin, which matters more here than usual, for the reason below.

Zod, and where it lost

Zod validates arguments and config, and the honest note in our stack documentation is that it was not the technically strongest candidate:

Valibot is technically superior for many cases (smaller, faster, modern API) but Zod’s ecosystem dominance wins on a project that wants contributors.

Bundle size is the usual argument for Valibot, and it is a real one in a browser. In a CLI running on a Node install that is already hundreds of megabytes, it buys nothing you can measure.

What Zod buys instead is that most TypeScript developers already know it, and that the same schemas work in the CLI and in the runtime — which is what lets a capability’s config be validated identically whether you are typing it at a prompt or a Worker is parsing it at boot.

.refine() and .superRefine() also let error messages be written in our own voice. “Production deploys require --token.” rather than “Validation failed at root.token.”

--json is a contract

The decision that shapes the most code is that every command owes machine-readable output to an agent driving it.

That has consequences throughout:

  • Diagnostic logging goes to stderr, so stdout stays clean and parseable.
  • The CLI is the one place in the entire codebase where writing to stdout is correct — a lint rule enforces that everywhere else and exempts packages/cli for exactly this reason.
  • A command that would prompt a human must instead refuse under --json, with an error naming the exact command to run. pithy add does this when prerequisites are missing.

That last one is the test of whether you meant it. A CLI that prompts when nobody can answer hangs your CI job. A CLI that guesses does something you did not ask for. Refusing with instructions is the only option that works for a caller with no hands.

Errors are two lines

Every failure renders the same way: a problem line, then an action line.

Production deploys require --token.
Set PITHY_TOKEN, or pass --token.

The action line is not decoration. It is a structured field on the error, carried through to the terminal, to --json, to logs and to the audit trail — and deliberately stripped from HTTP responses, because it names files and bindings in your project and a browser is not the audience.

One error shape, four renderings, one of which is a person at a keyboard who needs to know what to do next.