← showcases · case 02 · Greenfield · surface: CLI

relay — output is the interface

A deployment CLI with a documented output grammar: stdout carries data, stderr carries diagnostics, color and width are runtime facts, and every error ends with the next command. Components here are output blocks, not widgets.

stdout / stderr contract TTY · pipe · NO_COLOR · CI width is runtime exit codes as interface

Context

Who: developers deploying services, often from a laptop, a CI runner, or a pipe into another tool. Jobs: deploy, check status, read failures, script the same operations. Constraints: the terminal may be interactive or a pipe, ANSI may be disabled, and the width is unknown until runtime. The CLI controls the colors it sets — but only when it is allowed to set them.

Principles

RuleRationale
stdout is data, stderr is commentaryPiped output stays parseable; progress and warnings never corrupt the data stream.
Color is a runtime decisionTTY, NO_COLOR, CI, and dumb terminals each change what is honest to emit; meaning never depends on color anyway.
Width is read, not assumedWrapping and truncation follow the real terminal; 80 columns is a guess, not a contract.
An error ends with the next commandRecovery is part of the interface; “what happened / why / next” is one block.
Exit codes are a public APIScripts branch on them; they are specified and tested like any other contract.

Sessions

Whole-use transcripts: a success, a failure, and a pipe. These are the CLI's “scenes”.

$ relay deploy --env staging
relay · staging · app=checkout sha=4f3c2b1
→ build docker buildx … done in 42s
→ push registry.relay.dev/checkout:4f3c2b1 done in 9s
→ migrate 3 migrations applied
→ release canary 10% → 50% → 100%
✓ deployed in 1m 58s
next: relay logs --env staging --follow
2026-09-17T09:12:04Z relay deploy env=staging sha=4f3c2b1 result=success
$ relay deploy --env prod
✗ preflight failed: branch main is 12 commits behind origin/main
what happened deploy stopped before any change
why preflight requires an up-to-date branch
next git pull --rebase origin main && relay deploy --env prod
2026-09-17T09:14:31Z relay deploy env=prod result=preflight-failed exit=2
$ relay status --env staging | jq -r '.services[].name'
api
worker
postgres

Color modes

Switch the rendering context; the same transcripts adapt. In CI, progress spinners disappear and machine-readable log lines appear instead.

Auto — color when stdout is a TTY and the terminal is capable. Piped — color off, spinners off, data only. NO_COLOR — color off, layout unchanged. CI — color off, timestamps and result fields on, spinners off.

Width

The CLI reads the terminal width; slide to see help and output re-wrap. Nothing is designed for one magic width.

80
$ relay deploy --help
usage: relay deploy [flags]
deploy the current revision to an environment
examples:
relay deploy --env staging
relay deploy --env prod --canary 10
flags:
--env <name> target environment (staging, prod) [required]
--canary <pct> start at this traffic percentage [default: 10]
--dry-run print the plan, change nothing
--timeout <dur> abort after this duration [default: 10m]

Errors and exit codes

Error anatomy

  1. What happened — one line, past tense, no jargon.
  2. Why — the rule or state that caused it.
  3. Next — a runnable command or a named owner.

Exit codes

CodeMeaningTypical cause
0successoperation completed
1usage errorunknown flag or missing argument
2preflight failedenvironment refused the deploy
3runtime failureremote system failed mid-operation
4timeoutoperation exceeded --timeout

Machine mode

--json is a documented contract, not a debug dump: closed state vocabulary, ISO-8601 UTC timestamps, no diagnostics on stdout, and the same exit codes.

$ relay status --env staging --json
{
"env": "staging",
"revision": "4f3c2b1",
"checked_at": "2026-09-17T09:20:11Z",
"services": [
{ "name": "api", "state": "healthy", "replicas": "3/3" },
{ "name": "worker", "state": "healthy", "replicas": "2/2" },
{ "name": "postgres", "state": "degraded", "replicas": "1/1" }
]
}

Typography

The terminal owns the font. The CLI cannot choose a face, so it owns alignment, weight, and case instead — and columns are computed in characters, never in pixels.

command$ relay deploy --env staging bold, the only emphasis
dataapi worker postgres regular, tabular columns
metadatanext: relay logs --follow dim, still legible
failure✗ preflight failed color + glyph + word
emphasisrelay status --env staging no italics — some fonts lack them
  • One size, one fixed-pitch face; there is no font axis to design with.
  • Never align with spaces alone when a pipe is possible — provide a column or table mode.
  • Case is content, not emphasis: do not SHOUT commands or status.

Components and states

The components of a CLI are output blocks. Each has defined states, and every state is reachable through the mode matrix above.

Step line · running / done / failed

→ build docker buildx … running
→ push registry.relay.dev/checkout:4f3c2b1 done in 9s
✗ release canary aborted at 10% failed

Result line · success / skipped

✓ deployed in 1m 58s
– migrate skipped (no change) safe to skip, says so

Interactive prompt · waiting / confirmed

Roll back checkout to 9ab3210? [y/N]
y
✓ rollback queued · relay watch --env staging

Table · two widths of the same data

service state replicas
api healthy 3/3
worker healthy 2/2
postgres degraded 1/1

Motion and accessibility

Motion budget

  • Spinners and progress bars are the only motion; result lines never animate.
  • Non-TTY, NO_COLOR, and CI disable spinners and animated progress entirely.
  • prefers-reduced-motion freezes the spinner at a static frame.

Accessibility

  • Every status keeps a word or glyph, so a screen reader hears meaning without color.
  • Output is a linear stream: no cursor-addressing, no two-column layout that breaks reading order.
  • Errors are complete sentences with the next command, not codes alone.
Width
Wraps to the real terminal and never assumes 80 columns.
Keyboard
Every interactive prompt has a default answer and an escape.
Exit codes
A closed, documented set that scripts can branch on.

Contrast

When the CLI is allowed to colorize, it controls the palette and the pairs are computed for both common background assumptions. If a pair fails, the token changes — not the report.

RoleValueon dark terminalon light terminal

Both assumptions are shipped because the CLI cannot detect the background reliably; every role also carries a word or symbol.