Good CLI output is an API.
The reader is not only a person at a terminal. A shell script reads stdout. CI reads the exit code and whatever the command wrote before failing. An agent may read the tail of the log and try to infer whether it should retry, stop, or ask for help. Later, somebody pastes the failure into an issue and tries to reconstruct the machine state from whatever survived.
That means the command has to be predictable in a few boring ways. stdout should stay clean when another tool needs to parse it. stderr should carry diagnostics. exit codes should mean the same thing every time. If the command offers structured output, that shape should stay stable across releases.
good:
stdout: machine-readable result
stderr: progress and warnings
exit code: 0 success, nonzero failure
bad:
stdout: mixed status text and JSON
stderr: silent
exit code: 0 even when the command failed
That is the difference between a command somebody can compose and a command that only works in a demo. The user should not have to guess whether jq will parse the result or whether a CI step will misread a warning as data.
I also care about failure messages because they become part of the interface. “Permission denied” is fine if that is the whole story. “Permission denied while reading ./secrets.json; run with --scope repo or move the file under the workspace” is better because it tells the next step. A good CLI does not just fail. It makes the next move obvious.
The same rule applies to progress output. A command that prints one line per phase, or a --json mode that keeps machine output stable, gives scripts something to depend on. A release tool can parse {"status":"done","path":"dist/app.tar.gz"}. A human can still use the plain-text mode. The command is better when both can live with it.
Related posts

About Jeremy London
Engineering leader and builder in Denver. I write about AI platforms, agents, security, reliability, homelab infrastructure, and the parts of engineering work that have to survive production.