A style guide · 10 parts · 56 principles

CLI Style

An elegant interface for a more automated age. A numbered style guide for the command line, written for whoever reads it: the person who types a command, or the program that runs one.

A command line is a sentence in an imperative language
app
Subject
clusters
Object
restart
Verb
prod-2
Target
--preview
Modifier
Premise

The command line is more popular today than at any point in its history. Every wave of computing that was supposed to replace it—the graphical desktop, the web, cloud infrastructure, and now AI—ended up multiplying its reach instead. It didn't just survive each shift; it grew through every one of them, because plain text is still the only interface that composes, scripts, and leaves a legible trail.

Yet as its reach expanded, the craft of designing for it remained mostly oral tradition. Graphical platforms have had Human Interface Guidelines for forty years; the terminal had scattered man pages and tribal knowledge. Think of this document as the Human Interface Guidelines for the command line—and, because we now live in a world where software invokes software on our behalf, the Agent Interface Guidelines as well.

You do not need two interfaces. The qualities that make a CLI intuitive for a person—a consistent sentence grammar, typed streams, predictable exit codes, and self-documenting commands—are the exact qualities that make it reliable for an agent. What agents change is the cost of ambiguity: a person can muddle through a surprise prompt or a status line on stdout, while an automated caller will hit that trap at machine speed.

Frameworks like spf13/cobra, spf13/viper, and spf13/pflag were built with these principles in mind and provide much of this out of the box, but nothing here is specific to Go or the spf13 tooling. These fifty-six principles apply to any command-line application in any language.

Conventions

Principles are numbered and grouped into ten parts with permanent anchors (like #preview-is-a-diff) so you can cite them in design reviews and pull requests. All examples use a fictional application named app.

Four guidelines read differently for Human and Agent — color, progress, terminal detection, and prompts. Those carry a marked aside, and they are where most otherwise-good applications break today. The other fifty-two need no split: the right answer is the same for both readers, and they are stated plainly.

An accompanying SKILL.md is published at spf13/cli-style—install it into your coding agent so it builds and audits CLIs against this guide automatically.

Grammar

shape carries meaning

Speak in sentences.

Subject, object, verb, target, modifier. app noun verb target --modifier. A command line is a sentence in an imperative language; make it parse like one. What can be said aloud can be guessed, and what can be guessed can be composed by a script that never read your manual.

app
Subject
clusters
Object
restart
Verb
prod-2
Target
--preview
Modifier

Use subcommands, not mode flags.

Hierarchy carries meaning that flags can't. If you're reaching for a mode flag, what you wanted was a subcommand. The tree is a map that can be explored a level at a time, and the path is the address: no hidden mode silently changes what the same words mean.

app deploy --rollback --to-version 3
app deploy rollback 3

Make invalid combinations impossible.

A subtree owns its flags. If two options can't coexist, design the grammar so they cannot be combined in the first place: split conflicting actions into separate subcommands, and collapse mutually exclusive choices into a single valued flag. Where shorthand flags like --quiet and --verbose must coexist on the same command, define a clear precedence (for example, --verbose overrides --quiet, or last flag wins) rather than failing with a flag-conflict error.

app backups --restore snap-9f2c --schedule "0 3 * * *"
app backups restore snap-9f2c
app backups schedule "0 3 * * *"
 
app clusters list --json --yaml
app clusters list --output json
 
app deploy prod-2 --quiet --verbose # --verbose wins by documented precedence

Name the application for typing.

Short, lowercase, no punctuation, easy to say. The name will be typed thousands of times and spoken in hallways. Check that it isn't already taken by something on a normal system: shadowing an existing command is a bug you inflict on everyone.

AppCloud_CLI-v2 test open
app

Few verbs, many nouns.

Reuse the same verb across every noun. If list works on clusters, it works on users, keys, and regions too. Eight verbs applied to twenty nouns is a smaller thing to learn than forty bespoke commands, and it teaches itself: anyone who has mastered half your application has already mastered the rest.

a sufficient verb set
create get list describe update delete run preview

The target is positional; everything else is a flag.

Pass the target of the command as a positional argument and every other option as a named flag. Never put an optional positional argument before a required one, and never change what a positional argument means based on the one before it. If a reader has to count positions to know what 3 or true means, it should have been a flag.

app clusters create prod-2 us-east-1 3 true
app clusters create prod-2 --region us-east-1 --nodes 3 --ha

Honor --, the end of flags.

Everything after it is data, even if it starts with a dash. Filenames, search patterns, and arguments forwarded to a wrapped program all depend on it. Free to support, expensive to omit.

app logs search prod-2 -- --timeout

Convention

borrow what is already known

Convention over configuration.

The common path takes no flags. Every flag a user must pass is a decision you declined to make for them. Make the default right and the first attempt is usually the correct one.

app build --src ./src --out ./dist --target linux --format binary
app build

One flag, one meaning, everywhere.

Consistent inside your application, and consistent with everyone else's. A novel spelling of a common idea is a tax you charge every person who types it and every agent that tries to guess it.

--json machine output on stdout
--preview show the diff, change nothing
--yes -y assume yes to confirmations
--quiet -q errors only
--verbose -v more detail, on stderr
--help -h always, at every level

Long flags are for scripts; short flags are for hands.

Long flags are what get written into scripts, documentation, and issue comments, because they explain themselves. Short flags are a convenience for the handful of options typed daily, and a permanent allocation from a very small namespace. Spend them carefully.

app deploy prod-2 --preview --verbose # in a script or runbook
app deploy prod-2 -p -v # typed at a terminal

Precedence is a contract.

Published, stable, inspectable. Anyone can ask the application where a value came from and get a straight answer: the layer that won, named, instead of bisecting their dotfiles.

flags > environment > project > user > system > defaults
$ APP_REGION=us-east-1 app clusters list --region eu-west-1
prod-eu-1 eu-west-1 ready # --region flag overrides APP_REGION

Match configuration to lifespan.

How long a value should live tells you where it belongs. That's the whole decision, and it's almost always obvious once asked.

this once flag
this shell, or CI environment
this repository project file, committed
this person user config
this machine system config

Put configuration where the platform says.

Use the operating system's standard directories on Linux, macOS, and Windows, and honor XDG_CONFIG_HOME, XDG_CACHE_HOME, and XDG_STATE_HOME when set. Don't scatter dotfiles across the home directory, don't invent a new location, and keep cache separate from configuration so clearing one never destroys the other.

Linux ~/.config/app/ ~/.cache/app/
macOS ~/Library/Application Support/app/ ~/Library/Caches/app/
Windows %APPDATA%\app\ %LOCALAPPDATA%\app\Cache\

Run with no configuration at all.

A fresh install on an empty machine must do something useful. Configuration files are an optimization for repeat users, never a prerequisite. An application that can't start without a config file can't be tried, by anyone or by anything.

Error: /etc/app/config.toml not found; run `app init` first
app status # works out of the box with sensible defaults

Read stdin, write stdout; compose.

Read stdin when no input file is given. Write to stdout rather than to a file chosen on the user's behalf. An application that composes is worth several that don't; composition is how your readers build things you never anticipated.

app clusters list --json | jq -r '.[].name' | app clusters restart --stdin
Part III↑ Contents

Outcome

describe effects, not internals

Name the outcome, not the mechanism.

--preview beats --dry-run. --force describes your internals; --overwrite describes what happens to someone's files. Name the flag after the thing the user cares about, which is never your implementation. When the name states the effect, reading it is knowing it: no translation step, for any reader.

app deploy prod-2 --dry-run --force
app deploy prod-2 --preview --overwrite

Use one word whenever possible.

--preview is easier to type and clearer than --dry-run; --fresh beats --no-cache-write. Single-word commands and flags force you to find the real word for the concept, and they eliminate separator and casing ambiguity — nobody has to guess whether you spelled it --no-cache, --nocache, or --NoCache.

--dry-run --no-cache-write --skip-validation --force-overwrite
--preview --fresh --unchecked --overwrite

Show the change, not the summary.

Show the delta you would produce; don't narrate the steps you might take. "Would create 3 resources" is a log line. The resources themselves are an answer: a person can review it like a patch, a pipeline can evaluate it, before anything is applied.

Would update 1 cluster
~ cluster prod-2: nodes 3 -> 5 (us-east-1)

Be idempotent, or refuse.

Re-running a command either reaches the same state safely or fails cleanly and changes nothing. Half-applied is the one unacceptable outcome. Retries are how everything recovers: a dropped connection, a Ctrl-C, a flaky agent. Making them safe makes the whole failure model simple.

$ app clusters create prod-2 --region us-east-1
cluster prod-2 already exists in us-east-1 (unchanged) # exits 0

Match confirmation to consequences.

Scale friction to the cost of recovery, not the size of the command. Undoable changes should run immediately with zero prompts. Recoverable changes (restarting a service, rotating a key) proceed by default and support --preview. Unrecoverable deletions confirm once (y/N at a terminal, --yes in automation). Catastrophic actions—or bulk operations with destructive blast radius if mis-scoped—print what will be destroyed and require naming the target itself (interactively or via --confirm <target>), never a blanket --yes.

Undoable run immediately; show how to undo
Recoverable run by default; support --preview
Unrecoverable prompt y/N at TTY, or accept --yes
Catastrophic print blast radius; require --confirm <target>
 
app clusters delete prod-2 --yes
app clusters delete prod-2 --confirm prod-2

Do only what the name promises.

A command called status does not refresh a cache, rotate a token, or send telemetry. Hidden side effects are how an application loses the trust it needs to be given credentials and let loose, by a person or by a program with far more authority than attention.

app status # read-only: no token rotation, no cache writes, no mutations

Legibility

be knowable without being read

Recognition over recall.

Nobody should have to remember what you named things. Complete subcommands, flags, and live resource names at the prompt, and list valid choices whenever an option takes a fixed set. What can be discovered can be used; what must be recalled from memory will be mistyped or hallucinated.

$ app clusters restart <TAB>
prod-1 prod-2 staging-eu # completed from live state

Make the application self-documenting.

Generate help text, completions, man pages, and machine schema from one source — the command tree itself — so they cannot drift. Documentation that ships inside the binary can't go stale relative to the binary, and what's installed is always what's described.

app help # compiled from the command tree
app completion zsh # generated from the same tree
app help --json # machine schema from the same tree

Put real examples in every command's help.

A flag table explains what is legal; examples show what is idiomatic. Include three concrete invocations someone can copy and run—the common path, a preview or modifier path, and a piped or scripted path. For both people and agents, three working commands teach the grammar faster than fifty lines of flag descriptions.

Examples:
app clusters restart prod-2
app clusters restart prod-2 --preview
app clusters restart prod-2 --wait --timeout 5m

Help everywhere.

Running the bare application prints help rather than an error. Support app help as a subcommand alongside -h (terse, fits on a screen) and --help (complete) at every level. Help that was asked for is a success and exits zero; help printed because someone got the command wrong is a failure and exits two.

app # prints concise help, exits 0
app help clusters # full subcommand help, exits 0
app clusters -h # terse screen-sized summary, exits 0
app clusters --unknown # prints error + hint on stderr, exits 2

Version and capability are queryable.

The version subcommand prints what a bug report needs: version, commit, and platform. Pair app version --json with app help --json so a script or agent that has never seen your application before can inspect its build and full command tree in one call.

app version --json {"version":"2.4.1","commit":"a8f3c1","platform":"linux/amd64"}
app help clusters --json {"name":"clusters","verbs":["create","get","list","restart"]}

End help with where to go next.

One line: the documentation URL and how to report a bug. It costs a line and saves a search.

Learn more: https://example.com/docs/clusters
Report bugs: https://github.com/example/app/issues

Output

what you print is an interface

Streams are typed.

stdout is data. stderr is everything else: logs, progress, warnings, prompts. No exceptions, no "just this one status line." Typed streams are what let a person pipe the output somewhere and still watch what the application is doing, and let a parser read stdout without stripping the chatter out first. Telemetry never corrupts the payload.

stdout pure data -> pipes, files, parsers
stderr chatter -> progress, warnings, errors, prompts

Print less than you think you should.

Terse by default, --verbose to expand. Verbosity is a request, never a default. Every line printed is spent from someone's finite budget — attention on a screen, context in a window — and a wall of successful output is exactly what hides the one line that mattered.

INFO Initializing client... INFO Validating auth... INFO Restarting prod-2...
restarted prod-2 (3 nodes, 1.4s)

Machine output is a mode, not a fallback.

--json on every command that returns data, including errors. Generate it from the same values as the human rendering rather than assembling it separately: two code paths become two behaviors, and the machine path is the one nobody notices breaking.

$ app clusters get prod-2 --json
{"name":"prod-2","region":"us-east-1","status":"ready","nodes":3}

Say little on success.

Print what was produced, or print nothing. "Successfully completed operation" tells no one anything the exit code didn't. Silence is a valid and often correct response to a job well done.

Successfully deleted key "deploy-ci"! Operation completed without errors.
deleted key deploy-ci

Color is an enhancement, never information.

Divergent

Anything conveyed by color must also be carried by a word or a symbol. Color is a courtesy one reader can accept, and gibberish to everything else.

Human

A person watching a terminal absorbs a red error faster than the word "error." Take the gift.

Agent

A pipe, a log aggregator, and a screen reader all strip or mangle the codes. If the color carried the meaning, the meaning is gone.

So decorate, never encode. Turn it off when stdout isn't a terminal, when NO_COLOR is set, and when --no-color is passed.

prod-1 us-east-1 3/3 nodes # green only: status disappears in a pipe
prod-2 us-east-1 2/3 nodes # red only: looks identical to prod-1 without ANSI
✓ healthy prod-1 us-east-1 3/3 nodes
✗ degraded prod-2 us-east-1 2/3 nodes (node-3 unreachable)

Progress goes on stderr, and only for a terminal.

Divergent

Spinners and bars are for a human watching. Without a TTY, print nothing, or one line per meaningful milestone.

Human

A live progress bar is reassurance: the application is working, here is how far along it is.

Agent

A progress bar redrawn into a log file produces megabytes of escape codes and no information. Without eyes, motion is just noise.

TTY stderr: [====> ] 45% draining prod-2
non-TTY stderr: draining prod-2... done (14.2s)

Communicate responsively.

Silence on a slow operation looks identical to a hung process or a blocked stdin read. Never leave the caller guessing whether the application is working or wedged: when an operation waits on the network, a lock, or a remote state transition, emit what you are doing on stderr and update it as phases change. Pair status updates with --timeout so automated callers can bound the wait.

stderr: Waiting for cluster prod-2 to drain (2/3 nodes)...

Design the empty result.

Zero results is a legitimate answer, not an error, and exits zero. On a terminal, say what was searched and suggest the next move. Under --json, return []; when piped as plain text, emit zero lines so wc -l counts zero rather than one.

app clusters list --region eu-west-1 # TTY: "No clusters in eu-west-1."
app clusters list --region eu-west-1 --json # stdout: []
app clusters list --region eu-west-1 | wc -l # stdout: 0

Keep plain output line-oriented.

One record per line, stable field order, no decorative box drawing in the default view. Tables are for terminals; lines are for pipelines. If your pretty format can't be cut, sorted, and grepped, provide one that can.

+--------+-----------+-------+
| prod-2 | us-east-1 | ready |
prod-2 us-east-1 ready 5

Stream results as you get them.

Don't collect everything in memory to print it perfectly aligned at the end. Output that appears incrementally can be watched, piped into a running pipeline, and interrupted usefully.

app logs tail prod-2 | grep --line-buffered ERROR

Interaction

assume no one is watching

Every prompt has a flag equivalent.

Every prompt, confirmation, and editor input needs a flag equivalent, and every confirmation needs --yes. Never open an editor or a pager unasked without a flag to supply the content directly. Any workflow that can be completed interactively must also work in a single non-interactive command line — in CI, in a script, and in an agent.

app init # hangs on prompt or opens $EDITOR in CI
app init --name billing --region us-east-1 --yes

Detect the terminal; never assume one.

Divergent

Check each stream independently. stdout redirected to a file while stderr is still a terminal is the normal case, not the exotic one, and it should produce clean data in the file and a live display on screen. The right behavior for every stream depends on what is actually attached, so look before you draw.

Human

A terminal attached to stderr gets live progress and color even while stdout is piped into a file or another command.

Agent

Run without a TTY (or with TERM=dumb), the application skips straight to the form that composes: plain, uncolored, uninterrupted data.

app clusters list > clusters.txt # stdout: plain records; stderr: live terminal status

When interactive, prompt rather than fail.

Divergent

If required parameters are missing and a person is at a terminal, ask for them instead of exiting with an error. Prompt only for what was left out — never re-ask what was already supplied by flag or configuration.

Human

Asking which cluster you meant and continuing beats failing with a usage error and forcing you to retype the command.

Agent

Without a TTY there is no one to answer. Fail immediately with exit code 2 and name the missing flag so an automated caller never hangs on stdin.

Print the equivalent non-interactive command afterward, so the person who answered three prompts today can script it tomorrow, and an agent reading shell history learns the flag form.

? Cluster to restart: prod-2
Hint: next time run `app clusters restart prod-2`

Exit promptly when interrupted.

Ctrl-C means now. Clean up what must be cleaned up — temporary files, a partial write, a terminal left in raw mode — and get out. An application that ignores the first interrupt teaches everyone to reach for a harder signal that skips cleanup entirely.

^C Canceling deploy... cleaned up temp state, terminal restored. # exits 130
Part VII↑ Contents

Failure

the part everyone reads

Fail forgivingly.

Accept near-misses: edit distance on subcommands and flags, aliases for everything you ever renamed. Being strict about spelling has never once prevented a bug. "Did you mean restart?" saves a person a trip to --help and gives an agent the exact token to retry.

Error: unknown command "restat" for "app clusters"
Did you mean "restart"?

Errors say what to do next.

What failed, why, and the command that would fix it. An error that only reports state is half an error.

Error: unauthorized
Error: not signed in to cluster "prod-2"
run: app auth login --cluster prod-2

Exit codes are part of your API.

Zero means it worked. Two means the command line was wrong. Beyond that, define your codes, document them, never renumber. Distinguishing "the thing doesn't exist" from "the network failed" turns a retry loop from a guess into a decision.

0 success
1 general runtime error
2 invalid usage or flags
3 authentication or permission failure
4 target resource not found
5 conflict or precondition failed

One sentence first, detail underneath.

The first line names what failed, in the user's terms. Stack traces, request IDs, and internal context go below, or behind --verbose. Nobody reads down; everybody reads the first line.

Error: could not reach cluster "prod-2" (connection timed out after 5s)
endpoint: https://prod-2.internal:6443 · request-id: req_8f2a9c

Errors have a machine form too.

Under --json, a failure is structured output on stderr: a stable code, a message, and a remediation field, not a paragraph of English where a parser expected an object. Recovery should be a step to take, not a string to guess at.

{"error":{"code":"AUTH_REQUIRED","message":"not signed in to prod-2","run":"app auth login --cluster prod-2"}}

An unexpected crash asks to be reported.

If the failure is your bug and not the user's mistake, say so plainly and give the URL, the version, and the command that triggered it. Distinguish clearly between "you did something wrong" and "we did something wrong." Confusing the two makes people stop reporting anything.

Crash: internal panic in app v2.4.1 (linux/amd64, commit a8f3c1)
command: app clusters restart prod-2 --preview
report: https://github.com/example/app/issues/new

Make state changes atomic.

Write files to a temporary path and rename into place; apply configuration and state updates completely or not at all. A process can be killed at any instant, and an interrupted operation that corrupts the state it was updating is the failure people remember for years.

# write to config.toml.tmp -> fsync -> rename to config.toml
Part VIII↑ Contents

Time

what you ship, you keep

Add, don't subtract.

Aliases outlive renames; old flags keep working. Your users' scripts are your real API. Now so are their agents' learned habits and training weights, which no migration notice can reach in and update.

app clusters remove prod-2 # alias kept working after rename to `delete`

Deprecate with a date and a migration plan.

State what's going away, when it will be removed, and the exact replacement to use instead. Warn on stderr so the notice never corrupts stdout, and keep the old name working for the entire announced window. A deprecation without a date is a threat; one without a migration plan is an abandonment.

Warning: `--region-id` will be removed on 2027-06-01; use `--region` instead.

The machine format is the strictest promise you make.

Human output can be redesigned; people adapt. Structured output cannot. Adding a field is safe. Everything else — removing, renaming, retyping, reordering an array that carries meaning — breaks something silently and at a distance. Version the format if you must change it, and serve both.

{"cluster_name":"prod-2","nodes":"3"} # renamed key, changed int to string
{"name":"prod-2","nodes":3,"region":"us-east-1"} # existing keys & types preserved

Distribution

getting it, and getting rid of it

Ship one file that runs anywhere.

A single self-contained binary, no runtime to install first. Every dependency standing between someone and their first successful command is a place they give up, and a CI container or agent provisioning its workspace has even less patience for a five-step setup than a person does.

pip install app-cli && app-setup-runtime
curl -fsSL https://example.com/install.sh | sh

As easy to remove as to install.

Document what you wrote and where. Better: put everything in two predictable places, so deleting the binary and one directory removes the application completely. Nobody trusts software that won't leave.

rm /usr/local/bin/app && rm -rf ~/.config/app ~/.cache/app

Trust

secrets, networks, and consent

Secrets never come from arguments.

Arguments are visible in the process list, shell history, CI logs, and agent transcripts. Accept credentials from stdin, a file path, an environment variable, or the system keychain. If you offer a flag, have it take a path or a reference, never the secret itself.

app auth login --token sk_live_9f2c...
app auth login --token-file ./token
app auth login --token-stdin

Be honest about what leaves the machine.

Say what you send and to where, and let it be turned off with both a flag and an environment variable. An application that's quiet about its network activity will eventually be found out, and the trust doesn't come back.

DO_NOT_TRACK=1 APP_TELEMETRY=0 --no-telemetry

Write only where you were asked to write.

Don't drop credentials, caches, or state into the working directory, and don't edit a file the user didn't name, especially not their shell profile. If installing completions means modifying a dotfile, print the line and let them add it.

app completion zsh > ~/.zsh/completion/_app
In short

Much of this guide would have been true twenty years ago. Some of it is inherited Unix convention that was settled before any of us arrived: typed streams, exit codes, single binaries, no secrets in arguments. The rest I worked out over a decade of building command-line applications and teaching other people to build them.

What changed is not the substance. A second reader arrived, one with no eyes, a fixed context budget, no terminal attached, and a habit of retrying. It reads the same help text and runs the same commands, and it turns out that the interface which serves it best is the one that was well designed all along.

The stakes moved, though. A design mistake used to cost one person a few minutes and a trip to the manual. Now it is executed thousands of times an hour by something that cannot see the prompt it is stuck on, cannot tell your progress bar from your data, and will retry the half-applied write. Bad command-line design used to be a papercut. It is becoming a failure mode.

Four guidelines are where the two readers genuinely disagree, and they are where most otherwise-good applications break today:

Everything else is the same good advice it always was. It just has twice the audience now, and one of them never gets tired of running your application wrong.

Colophon

I created Cobra and Viper while building Hugo. Go was young, cloud infrastructure was being rewritten from scratch, and we needed a clean way to express command trees, flags, and configuration that could scale from a single command to a deep subcommand hierarchy. Over the next decade, Kubernetes, Docker, GitHub, Helm, and thousands of other projects built their interfaces on those foundations.

Many of the principles in this guide are older than all of us. Streams, pipes, exit codes, and filters come straight from Bell Labs and fifty years of Unix practice. The rest—the noun-verb grammar, the configuration precedence contract, outcome-oriented flags, and self-documenting command trees—was forged in the open with my teams at MongoDB, Docker, and Google, and with the maintainers who pushed these patterns into production across the industry.

For years these conventions lived scattered across library defaults, conference talks, and design reviews. They belong in one place, stated plainly, for the engineers and the agents building what comes next.

© 2026 Steve Francia (spf13) · spf13 labs LLC · All rights reserved. Brief quotations with attribution and a link to cli.style are permitted.