- name
- cli-design
- description
- Unix-composable CLI design patterns. Use when building CLI tools, designing command trees, implementing output layers, or testing CLI behavior. Covers stream separation (stdout/stderr), format flags (--json/--plain), exit codes, TTY detection, composability, and error design. Language-agnostic principles; TypeScript implementation patterns in resources/. For API design (REST, HTTP), see api-design. Use when this capability is needed.
- metadata
- {"author":"citypaul"}
# CLI Design: Unix-Composable Command-Line Interfaces
This skill covers **language-agnostic** CLI design principles. The rules about stream separation, exit codes, format flags, and composability apply regardless of implementation language.
For API contract stability and Hyrum's Law, see the `api-design` skill. For config, env vars, and graceful shutdown, see the `twelve-factor` skill.
**TypeScript implementation patterns** are in the `resources/` directory. Load them on demand when building a CLI in TypeScript:
| Resource | Load when... |
|----------|-------------|
| `output-architecture.md` | Implementing Result types, entry point wiring, formatters, logger, JSON envelope schemas |
| `testing-cli.md` | Writing Vitest tests for CLI behavior (streams, exit codes, pipes, contract tests) |
| `stream-contracts.md` | Understanding Node.js buffering, NDJSON, signal handling, crash-only design |
---
## When to Use
- Building any command-line tool (any language)
- Designing command tree, flags, and I/O contracts
- Implementing the output layer (format detection, stream routing)
- Testing CLI behavior (stdout/stderr separation, exit codes)
- Reviewing a CLI for Unix composability
---
## Core Principle
**stdout is for DATA only — the product the user asked for.**
**stderr is for EVERYTHING ELSE — diagnostics, progress, spinners, warnings, errors.**
This separation is what makes `mycli --json | jq ...` work. One spinner character on stdout breaks every downstream pipe.
> "Whatever software you're building, you can be absolutely certain that people will use it in ways you didn't anticipate. Your software will become a part in a larger system — your only choice is over whether it will be a well-behaved part." — clig.dev
---
## The Unix Stream Contract
| Content | Stream | Why |
|---------|--------|-----|
| Primary output (data, results, JSON) | stdout | Pipeable, buffered for throughput |
| Progress bars, spinners, status | stderr | Not data — must not corrupt pipes |
| Warnings, errors, diagnostics | stderr | Visible to user even when stdout is piped |
| Debug/verbose output | stderr | Diagnostic, never data |
**Buffering behavior:**
- **stdout**: line-buffered when connected to a TTY, block-buffered when piped (~2x faster than stderr)
- **stderr**: unbuffered — every write is a syscall (immediate but expensive)
- **Check each stream independently** — stdout being piped does not mean stderr is piped
When stdout is piped, the user doesn't want your status messages in their data. All non-data output must go to stderr.
For a deep dive on buffering behavior and performance implications, see `resources/stream-contracts.md`.
---
## Keep Handlers Pure
The practical rule: **functions that do the work should return data, not write to stdout.** The CLI entry point handles all I/O.
```
Entry point (CLI main) Your logic (handlers)
───────────────────── ─────────────────────
parse args (input) → structured result
detect format (json/plain/human) no printing to stdout
call handler no writing to stderr
format the result no calling exit
write to correct stream just returns data
set exit code
```
This isn't an architecture mandate — it's just clean function design. The benefits are concrete:
- **Testable without subprocess spawning** — call the handler, assert on the returned value
- **Format flexibility for free** — same data renders as JSON, plain text, or coloured tables by swapping one function
- **Reusable** — the same handler works from a CLI, MCP server, HTTP API, or programmatic import
For simple CLIs where the "handler" is just calling a library, this separation already exists naturally — your library returns data, your CLI formats it. No extra layers needed.
If your project uses hexagonal architecture, the mapping is direct: the CLI entry point is a **driving adapter**, and the handler is a **use case** that returns a result through a port. See the `hexagonal-architecture` skill — the patterns reinforce each other, but hex arch is not required to benefit from keeping handlers pure.
For TypeScript implementation patterns (Result types, entry point wiring, formatters, logger interfaces), see `resources/output-architecture.md`.
---
## Format Flag Contract
Three-tier output hierarchy:
### Default: Human-Readable
- Colors, tables, formatted text
- Progress bars and spinners on **stderr**
- Output tailored for terminal width
- May change between versions — this is **not** a contract
### `--plain`: Grep/Awk-Friendly
- One record per line, no formatting, no colors
- Stable between minor versions — this **is** a contract
- Flat table rows, no borders, no grouped sections
- Enables: `mycli list --plain | grep error | wc -l`
> "Encourage your users to use `--plain` or `--json` in scripts to keep output stable." — clig.dev
### `--json`: Structured Data
- stdout contains **ONLY** valid JSON — no spinners, no color, no progress
- stderr continues normally — human diagnostics still visible
- Errors are structured JSON too — not just success responses
- Schema is versioned — breaking changes to JSON output are breaking changes to the CLI
- `--json` implies non-interactive regardless of TTY
**Consistent envelope:**
```json
{ "ok": true, "data": { ... } }
{ "ok": false, "error": { "code": "CONFIG_MISSING", "message": "...", "fix": "..." } }
```
### NDJSON for Streaming
For large datasets, use NDJSON (one JSON object per `\n`):
- Each line is independently parseable
- Include a `type` field per record for multiplexing events
- Final line can be a summary record
- Enables: `mycli run --format ndjson | while read -r line; do ...; done`
For NDJSON specification details, see `resources/stream-contracts.md`.
---
## Exit Codes
| Code | Meaning | When |
|------|---------|------|
| 0 | Success | Operation completed as expected |
| 1 | Domain failure | Tool-specific failure (e.g. quality threshold not met) |
| 2 | Invalid usage | Bad flags, missing required args, validation error |
| 78 | Configuration error | Invalid config file, missing required config |
| 75 | Temporary failure | Network timeout, service unavailable — retry may help |
| 130 | SIGINT | User pressed Ctrl-C (128 + 2) |
| 143 | SIGTERM | Process terminated (128 + 15) |
**Rules:**
- Non-zero exit code **MUST** have a stderr explanation
- Document exit codes in `--help`
- Never use codes above 125 for application errors (reserved for signals: 128 + signal number)
- Exit code 75 (transient) is critical — it tells retry logic the failure may be temporary
- Map non-zero codes to the most important failure modes for your tool
---
## TTY Detection
Check priority order (first match wins):
| Priority | Condition | Effect |
|----------|-----------|--------|
| 1 | `--format json` or `--json` flag | Non-interactive, no color, no animation |
| 2 | `--no-color` flag | Disable color (output may still be interactive) |
| 3 | `NO_COLOR` env (non-empty) | Disable color |
| 4 | `FORCE_COLOR` env | Enable color regardless |
| 5 | `TERM=dumb` | Disable color and animations |
| 6 | `CI=true` | No interactive prompts |
| 7 | stdout is not a TTY (`!isatty(stdout)`) | Plain output, no animations on stdout |
| 8 | Default | Full interactive with colors |
**Check stdout and stderr independently.** When stdout is piped but stderr is a TTY, you can still show spinners on stderr while keeping stdout clean for the pipe consumer.
Optionally support `MYCLI_NO_COLOR` for app-specific color override.
---
## Input Design
### Flags Over Arguments
- **1 positional arg**: acceptable (the "main thing")
- **2 positional args**: suspicious — consider flags instead
- **3+ positional args**: never acceptable
Flags are self-documenting, order-independent, and future-proof.
```bash
# Bad — which is source, which is destination?
mycli copy myapp backup
# Good — explicit
mycli copy --from myapp --to backup
```
### Standard Flags
Always provide long forms. Short flags only for the most common operations.
| Flag | Meaning |
|------|---------|
| `-h`, `--help` | Show help (this should only mean help) |
| `--version` | Print version to stdout |
| `-q`, `--quiet` | Suppress non-essential output |
| `-v`, `--verbose` | More detail in human output |
| `-d`, `--debug` | Diagnostic output to stderr |
| `-f`, `--force` | Skip confirmation prompts |
| `-n`, `--dry-run` | Show what would happen without doing it |
| `--json` | Structured JSON output |
| `--plain` | Stable, grep-friendly plain text |
| `--no-color` | Disable color output |
| `--no-input` | Disable all prompts/interactivity |
| `-o`, `--output` | Output file |
### Prompts and Interactivity
- **All prompts MUST be bypassable** via flags for scriptability
- Confirmation → `--yes` or `--force`
- Selection → `--type=value`
- Text input → `--name=value`
- Passwords → `--password-file=path` or stdin pipe
- If stdin is not a TTY, never prompt — fail with a clear error or use defaults
- **Secrets via files/stdin/env only** — never via flag values (they leak to `ps` output and shell history)
### Conventions
- Support `--` to stop flag parsing: `mycli run -- --flag-for-child-process`
- Support `-` for stdin/stdout file arguments: `curl ... | mycli process -`
- Accept both `--flag=value` and `--flag value`
- If stdin is expected but is an interactive terminal, display help immediately (don't hang like `cat`)
---
## Config Precedence
Highest to lowest priority:
1. **Flags** — per-invocation overrides
2. **Environment variables** — `MYCLI_*` prefix, per-session
3. **Project config** — `.myclirc`, `mycli.config.ts`, or in `package.json`
4. **User config** — `~/.config/mycli/` (follow XDG spec)
5. **Defaults** — sensible built-in values
**Rules:**
- Follow the [XDG Base Directory Specification](https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html) for config file locations
- Env var naming: `MYCLI_*` prefix, uppercase letters + digits + underscores
- Never accept secrets via flags — use env vars, files, or stdin
- Read `.env` where appropriate, but don't use it as a substitute for proper config
- If you modify configuration that belongs to another program, ask consent first
---
## Error Design
Every error needs:
1. **Machine-readable code** — `UPPER_SNAKE_CASE` (e.g. `CONFIG_MISSING`, `AUTH_EXPIRED`)
2. **What went wrong** — context: which resource, operation, input
3. **How to fix it** — exact command or action the user should take
4. **Reference** — docs URL or `mycli help <topic>` (optional)
### Human Mode
```
Error: CONFIG_MISSING — Configuration file not found
No configuration file found at ./mycli.config.ts or ~/.config/mycli/config.ts
Fix: Run `mycli init` to create a default configuration file
Docs: https://mycli.dev/docs/configuration
```
- Put the most important information **last** (the eye is drawn to the end)
- Use red sparingly and intentionally
- Suggest corrections for typos ("Did you mean 'deploy'?")
- Group similar errors under one header — don't repeat 50 similar-looking lines
- Write debug logs to a file, not the terminal (unless `--debug`)
### JSON Mode
Errors are structured too — not just success responses:
```json
{
"ok": false,
"error": {
"code": "CONFIG_MISSING",
"message": "No configuration file found at ./mycli.config.ts",
"fix": "Run `mycli init` to create a default configuration file",
"transient": false
}
}
```
The `transient` boolean tells retry logic whether the failure may be temporary.
---
## Composability Patterns
Design for real-world pipes:
```bash
# Filter structured output
mycli list --json | jq '.data[] | select(.status == "failed")'
# Stream results for large datasets
mycli run --format ndjson | while read -r line; do echo "$line" | jq '.file'; done
# Feed stdin
cat previous-results.json | mycli report --format markdown
# Combine with other tools
mycli run --json | mycli diff --baseline previous.json
# Silent mode for CI — only exit code matters
mycli check --quiet || echo "Check failed!"
# Chain: create outputs an identifier, next command uses it
mycli create --json | jq -r '.data.id' | xargs mycli deploy --id
# Column selection for efficiency
mycli list --json --fields name,status,id | jq '.data[]'
# Parallel processing
mycli list --json --fields id | jq -r '.data[].id' | xargs -P4 mycli process --id
```
**Key patterns:**
- Create commands output identifiers so subsequent commands can chain
- List commands support `--fields` for column selection (reduces output size, critical for agent efficiency)
- `--quiet` for CI scripts that only care about the exit code
- NDJSON for streaming large datasets without buffering everything in memory
- `--dry-run` with `--json` outputs planned changes as structured data
---
## Subcommand Design
- **noun verb** pattern is most common: `mycli config set`, `mycli report generate`
- Be consistent across all subcommands — same flag names for same things
- No ambiguous pairs (`update` vs `upgrade` is confusing)
- No catch-all subcommands (you can never add subcommands with conflicting names)
- No arbitrary abbreviations — aliases must be explicit and stable
- With no args: list subcommands (multi-command CLI) or show help (single-command CLI)
### Help
- `mycli --help` — top-level help
- `mycli help <subcommand>` — subcommand help
- `mycli <subcommand> --help` — same as above
- If run with missing required args, show concise help + 1-2 examples + "use --help for more"
- **Examples are the most-read section** — lead with them
- Include flag types, defaults, and allowed values for finite sets
---
## Output Stability Contract
**Stdout is a public API.** Breaking changes to stdout format are breaking changes to the CLI.
| Change | Impact |
|--------|--------|
| Adding new optional JSON fields | Safe (additive) |
| Adding new subcommands | Safe |
| Adding new flags with preserving defaults | Safe |
| Removing or renaming flags | **Breaking** |
| Removing or renaming JSON fields | **Breaking** |
| Changing exit codes | **Breaking** |
| Changing default behavior | **Breaking** |
View on GitHub