BWA Reduction
2026-08-09 · 4,241 words · 18 min · #cli #ux #tools #design #reference

What CLIs Actually Do: A Measured Reference

The guideline literature gives three different answers to what a bare noun should do. Two of them only look like a contradiction until you notice they are talking about different levels of the command tree.

I needed to settle some grammar questions for a CLI I maintain (ctxloom), and the usual approach is to read the style guides. I did that, and then I went and probed the tools those guides are supposedly describing. The two do not match, in ways that change the answers.

None of this came from greenfield design. It came out of a refactor: a surface that had grown to 130 commands over two years, being cleaned up ahead of a substantially larger version that is in alpha testing now. Everything below is what I learned by auditing something already built and already wrong, which is the position most people are actually in.

What follows is the measurement. Rows marked docs are from documentation because I did not have the tool installed; everything else came from running the binary and capturing what it did.

Ordering: noun-verb has won, with one large exception

toolorder
gh, docker, gcloud, az, stripe, flyctlnoun-verb (gh repo list)
kubectlverb-noun (kubectl get pods)
cargo, npmverb-first, no nouns
gitmixed, historically accreted
terraform, wranglerverb-first at the top, noun groups below

The interesting case is kubectl, which is verb-noun and enormous and beloved, so the ordering question is not settled by popularity. What noun-verb buys is grouping: every operation on a thing lives under that thing, so gh repo --help is a complete inventory of what you can do to a repo. Verb-noun buys the opposite grouping, which is what you want when the verb is the stable concept and the nouns are open-ended. kubectl has hundreds of resource kinds and a dozen verbs, so it grouped by the small set.

Pick based on which axis is open-ended. If you will add nouns forever, group by noun.

When a noun contains nouns

noun verb stops being enough the moment a noun owns more than one kind of thing. The tools that hit this all landed on the same shape: noun object verb.

docker  container  ls
docker  image      rm
kubectl config     get-contexts
gh      secret     list
az      storage    account create
aws     s3         ls

The sub-noun is not a category folder for tidiness. It appears when the parent owns several distinct things with their own lifecycles, and the tell is that the sub-noun takes the same verb vocabulary one level down. docker container ls and docker image ls are the same verb applied to different populations.

What took me longest to accept is that a command can carry both direct verbs and sub-nouns without being inconsistent. docker has bare ps and also container ls. A parent verb acts on the parent; a sub-noun is a different population that happens to live underneath. Forcing everything down a level produces things like trust item accept where trust accept read fine, and invents a noun to hold the verb.

The rule I ended up with: direct verbs act on the noun itself, sub-nouns are distinct managed things, and both may coexist.

What a bare noun does: no consensus at all

This is the question I most expected to have an obvious answer, and it does not.

behaviortools
print helpgh, docker, flyctl (docs), wrangler (docs), stripe (docs)
errorgcloud (exit 2), az (docs), aws (docs), terraform (exit 1)
list the thingsgit’s noun-commands, systemctl, heroku (docs, by doctrine)

git remote, git branch and git tag all list, with exit 0. Bare systemctl is list-units. Heroku’s style guide mandates listing and forbids a *:list command outright.

Against that, gh repo and docker image print usage and exit 0, and gcloud errors with a command listing and exit 2.

Five, four, and three. Whatever you pick, most of the field does something else.

I went with list, on the heroku side of that split. What makes it work is a second rule rather than the choice itself: --help is a universal suffix, present on every command at every depth. ctxloom bundle --help, ctxloom trust signer --help, ctxloom run --help. Always, with no exceptions to learn.

It has to be the flag rather than a bare help word, and the reason is addressability. A bare word in suffix position is competing for the same slot as a positional argument, so tool search help cannot mean both “search for the string help” and “explain search” — one of them has to lose, and whichever loses is silently unreachable. A flag lives in a different namespace from operands entirely, so it collides with nothing and stays available even on commands whose arguments are open-ended text.

You could carve out an exception for containers, which have no positionals to compete with, and let the bare word work there. I would not. An affordance that exists on some levels and not others is one the user has to model instead of just using, and the whole value of this rule is that it needs no model. One spelling, everywhere, no exceptions to learn — including the exception that would have been in the user’s favour.

The flag also gets you the “always” for free, which is the part that matters most. Registered once as a universal flag, every command inherits it, including the ones nobody has written yet. That is a structural guarantee rather than a convention someone has to remember at each new command — and per the last section of this post, conventions that depend on remembering are the ones that rot.

The two rules serve two audiences separately, which is why both can win. Someone who knows the tool types a noun many times a day and already knows what it holds; charging them ls every time is three keystrokes for information they were always going to ask for. Someone meeting the tool needs teaching everywhere, without knowing in advance which commands are generous. A suffix they can append to anything gives them that, and its value comes entirely from being unconditional. Help on most commands is help you still have to test for.

That is what makes listing affordable. The teaching surface is not lost when the bare form stops printing help, because it moved somewhere explicit and permanent.

The general form is a preference order rather than a single answer. A bare noun gives the safest useful thing it has: a listing where one exists, a summary where a listing does not fit, help when the noun genuinely has nothing to show. Never an error. Erroring spends the user’s keystroke to tell them the keystroke was wasted, and it is the one option in that table nothing recommends and four tools ship regardless.

Implicit verbs: nobody does it

Zero of the fifteen accept tool noun object as an implicit show. Zero accept a trailing verb. This was the pattern I was actually evaluating, and it is the only question here where the tools all agreed.

git is the closest, and it is a cautionary tale rather than a precedent. git branch X and git tag X take a positional and create, silently, exit 0. That is an implicit verb, and the implicit verb is not show. While probing this I typed git branch nosucharg expecting an error and created a branch in a working repository.

Every developer reading this knows those are creates. I knew it. That is exactly why the pattern is worth arguing against rather than excusing, because knowing it does not protect you from it.

Look at what the arity does. git branch lists, and git branch X writes. Same word, opposite effect class, decided by whether an argument is present. So there is no way to ask “does this branch exist” in that grammar: the natural probing gesture is the creating one. And a typo cannot be caught, because there is no such thing as an invalid argument — every misspelling is a valid new name. The command has no error case to report, so it reports none.

That is the part knowledge does not help with. Understanding the semantics protects you from misunderstanding them; it does not protect you from a fat-fingered branch name, and the design has removed the feedback that would tell you. Compare git switch nosucharg, which fails and says the branch does not exist, because switch has an explicit verb and therefore a well-defined failure.

git itself came round to this. checkout was ambiguous enough between branches and paths that it was split into switch and restore in 2019 — the release notes put it plainly: “Two new commands ‘git switch’ and ‘git restore’ are introduced to split out of the single ‘git checkout’ command.” Both halves have real error cases where the original had a guess.

The industry direction on implicit verbs is away from them.

What tools do instead is imply the object, which is the safe half. gh repo view with no argument means the repo you are standing in. The verb stays explicit and the omitted token is the one with an unbounded value space, so nothing can collide.

That asymmetry is the actual pattern: omit the argument whose value space is open, never the one drawn from a closed set you also parse. A verb comes from a closed set the parser knows. An object name does not. Omitting the verb means guessing which set a bare token came from, and the guess is wrong exactly when someone names a resource after a verb.

Destructive defaults: the guides are ahead of the tools

clig.dev says confirm before anything dangerous, with tiers running to type-the-name for severe cases. Practice is more varied.

tooldestructive default
kubectldelete acts immediately, no prompt. --dry-run=client|server is opt-in
gitno confirmations. git clean refuses without -f
dockerrm/rmi act immediately. Only prune prompts y/N
aws (docs)s3 rm acts. --dry-run on select EC2 operations only
ghrepo delete prompts. --yes skips it, except deleting the repo you are in
gcloud, az, wrangler (docs)prompt, with --quiet/--yes to skip
terraformdestroy requires typing yes
flyctl, heroku (docs)require typing the resource name

The split falls along blast radius rather than tool philosophy. Deleting a local container is instant everywhere; deleting a hosted application makes you type its name. That is a defensible line and it is the one I would copy: scale the friction to what cannot be undone, not to whether the verb sounds scary.

One pattern worth stealing from gh: --yes skips the confirmation for a named repository, but not for the repository you are currently inside. The dangerous case is the one where the argument was omitted, so that is the case that keeps asking.

Dry run is three different questions

Most tools treat --dry-run as one flag. It is really a different question per verb class, and the classes want opposite defaults.

classexamplesdry run
destructive: removes something that existeddelete, purge, pruneon by default; a flag turns it off
changing or costing: writes, spends money or timecreate, edit, push, pull, upgradeoff by default; --dry-run turns it on
readlist, show, search, statusnot applicable; a dry run of a read is the read

The asymmetry is the point. For a changing verb, acting when you meant to preview costs you rework. For a destructive verb it costs data you cannot get back. Defaults should fail toward the recoverable state, so the two classes take opposite defaults and the flag that crosses the line is explicit in both directions.

That gives the rule I would now build into anything destructive: a destructive verb is an inherent dry run, and --yes is what applies it. Run it bare and it tells you exactly what it would destroy. Nothing is a “preview mode” you have to know to ask for, because the safe thing is what happens when you do not ask for anything.

terraform already works this way at the plan/apply level, and the tools that require typing a resource name are reaching for the same property with a worse mechanism, since a typed name proves you can read but not that you know what else is in scope.

Two details that decide whether a dry run is worth anything:

Reads are real, writes and costs are mocked. The answer must be the truth about the world. But an API call that charges you is an effect, so a dry run that still spends money is not one. Mock at the boundary where the world changes, including the boundary where the bill does.

It must be verbose. A dry run that prints little has wasted the run. The entire product is the account of what would have happened, so terseness there is not a virtue.

Where the guidelines disagree, and where they only appear to

This is the part that stopped me treating the guides as settled.

clig.dev says a command that needs arguments should print concise help when run bare, and warns against catch-all subcommands.

The Heroku CLI style guide says the root of a topic should list that topic’s nouns: “in the case of heroku config, it will list all the config vars for an app. Never create a *:list command.”

12-Factor CLI Apps says “if the user doesn’t pass anything arguments to the CLI, it’s always better to list the subcommands (for multi) or display the help (for single) rather than do some default behavior.”

Those last two look like a flat contradiction. They are not, and the reason is worth more than the contradiction would have been: they answer different questions. The 12-Factor sentence is about a bare invocation of the whole tool; the Heroku sentence is about the root of a topic. Two paragraphs further into the same factor, 12-Factor endorses the Heroku rule outright: “For topic-level commands like $ heroku domains we list all the domains of an app.”

It is tempting to go further and call it one author contradicting himself, and that does not survive checking either. Jeff Dickey wrote 12-Factor CLI Apps and was lead architect of the Heroku CLI, both verifiable. The style guide has no byline; it is institutional documentation. Its own examples contain /Users/jdickey/ paths, so he very likely had a hand in it — but “likely” is not attribution, and it is a thin basis for putting words in a named person’s mouth.

The real divergence is between different authors. clig.dev says a bare command prints help; the Heroku guide says a bare topic lists. Those are genuinely different defaults for the same question, and the measurement table above shows real tools split along exactly that line.

The lesson worth keeping: the levels of a command tree are different objects, and “what does a bare name do” has a different right answer at each. Two guides only look like they disagree if you collapse the levels.

GNU and POSIX conventions get invoked in these arguments too, and they predate subcommands entirely. They govern options and operands. They have nothing to say about noun-verb ordering or bare-namespace behavior, and citing them for it is decoration.

Aliases: two different things wear the same word

Tools mean at least two things by “alias”, and they have opposite costs.

An alternate name for a concept is a memory tax. If add works on one noun and create on another, knowing what you want does not tell you what to type, so you check the help every time. This is the kind worth deleting.

An abbreviation of the canonical name is not. ls for list. Every noun accepts both, so there is nothing to remember, and the long form is discoverable while the short form is fast.

Docker’s history is instructive: it kept docker ps and docker images as top-level shortcuts after moving to docker container ls and docker image ls. The shortcuts are the ones people type. The structured forms are the ones that make the surface learnable. Both survived because they are different tools for different moments.

rm is where that clean rule breaks, and the break is instructive.

rm means destroy in unix shells, so it has to sit on the destroyer or it inverts what those users already know. Put it on a verb called delete while a second verb called remove detaches a member and leaves it alive, and you get this:

rm      ->  delete    destroys
remove  ->            detaches, survives

rm is the conventional abbreviation of the word remove. So the alias and its own expansion point at different verbs with opposite destructiveness. Someone who learns that rm destroys, reasons that remove must be the long form, and types remove gets a detach. The alias teaches a wrong expansion — the exact “name promises an effect it does not have” defect this whole exercise is about, committed by the exercise.

In a unix shell there is no command called remove, so rm is simply the destroyer’s name and nothing mis-teaches. The collision is self-inflicted: two words in one vocabulary meaning opposite things.

The fix is to stop fighting over rm and move the other verb. Membership becomes attach/detach, which frees remove to be the destroyer it already is everywhere else and makes rm a true abbreviation of its own canonical verb. Nothing to except, nothing to explain in a footnote.

rm is also not universal, which changes the answer. cmd.exe has no rm and destroys with del; PowerShell’s Remove-Item carries both as aliases. So remove takes rm and del both — not two abbreviations of one word competing, but two shell traditions that disagree about the spelling and agree about the meaning.

Both ship, and the reasoning is the interesting part: a borrowed convention every user already has beats an internal consistency rule they have never read. rm is not competing with my vocabulary, it is competing with thirty years of muscle memory, and my vocabulary loses that fight regardless of what I write down.

So the rule needs the exception stated rather than hidden: abbreviations of the canonical name, plus borrowings so universal that violating them costs more than the inconsistency does. The test for the second category is narrow. If you cannot name the external convention and say why it is stronger than yours, you are rationalizing a synonym.

The honest alternative was to ship no short form at all, and I would not argue hard against it.

Names that misreport their effect

The failure I found most often, in my own tool and others, is a verb that promises a mutation and performs none.

remote update in the tool I maintain checked for updates and wrote nothing unless you passed --apply. The name says it changes something. It does not.

git remote update is the obvious comparison and it is not the same problem. In git the noun is doing real work: a remote is a relationship you hold with another repository, and git remote update refreshes that relationship. The object named is the object acted on.

Mine was worse in a way the effect-class complaint obscures. remote update in my tool inspects locally installed content and compares it upstream. It does not touch the remote at all. And the noun is carrying two different objects:

remote create | delete | list | show | default | discover    a REGISTERED SOURCE
remote pull   | update | upgrade                             the LOCAL CLOSURE

Six verbs act on a remote as a thing you registered. Three act on the dependency graph that happens to have arrived from one. remote pull does not pull the remote; it installs content you already pinned.

That is a noun problem masquerading as a verb problem, and it is the failure mode I would most warn against, because a verb audit finds it late if it finds it at all. You can spend an hour deciding whether update should be check without noticing that whichever verb you pick is still attached to the wrong noun. A verb can only be as clear as the noun it modifies, so audit the nouns for overloading first. One noun holding two object types will generate confusing verbs indefinitely, and each one looks like an isolated naming mistake.

The fix was to split the noun. The registration half keeps remote; the closure half becomes deps and takes hold/unhold with it, which had been sitting under bundle because a held entry is a lockfile entry and bundle was the nearest noun that existed.

Naming it exposes a rule worth stating outright. Every other noun in the tool is singular, so deps looks like an exception needing justification. It is not. The singular ones are instance types: you have many bundles, you name one, and the verb says how many you are touching. There is no “a deps”. The closure is one object made of many parts, and every verb acts on the whole by default — pull installs everything pinned, check inspects everything, upgrade re-resolves the entire graph. Scoping to a single entry is the exception there, which is the reverse of how the instance nouns behave.

So instance nouns are singular, aggregate nouns are plural, and the plural is the signal telling you which kind you are holding. A tool with only instance nouns never has to notice the second half.

apt is the canonical version of this and gets it right by convention rather than by naming: apt update refreshes the index, apt upgrade installs. Millions of people have memorized that pair, which is evidence that a confusing name can be survived, not that it is good.

The test I now apply: if the verb ran with no flags and did nothing observable, would a reader be surprised? If yes, the name is wrong, whatever the convention.

Build the vocabulary before the commands

This is the one I would tell my past self, and it is the reason for everything above.

I had a glossary. It defined the project’s nouns carefully: what a session is, what a profile is, what an agent is as distinct from the vendor’s use of that word, and why each term beat its alternatives. It had a section on the invariants those terms carry.

It said nothing about verbs.

So the noun half stayed disciplined for two years and the verb half drifted, because there was nothing to check a new command against. When I finally enumerated the surface, it had 47 distinct words in verb position across 130 leaves. Eleven of them were not verbs at all, they were sub-nouns. And there were seven different spellings of “undo”: uninstall, unhold, unregister, reject, forget, revoke, delete.

Every one of those was locally reasonable. unhold pairs with hold. forget reads better than delete for a consent record. reject is the right word for a review decision. Each was a defensible call by someone who had not been handed a list of the words already in use, because no such list existed.

A glossary of nouns without verbs is half a vocabulary, and it is the half that does not drift on its own. Nouns are load-bearing in the data model, so they get argued about. Verbs get chosen one command at a time by whoever is writing that command, and nothing forces two authors months apart to make the same choice.

Write both. For each verb, say what effect class it names and what it does not, the way you would define a noun. Then a new command is a lookup rather than a judgment call, and the review question becomes “is this word in the vocabulary” instead of “does this name seem fine to you”.

The order matters too. Doing this after the surface exists means every fix is a breaking change, which is how you end up with apt update and apt upgrade memorized by millions of people instead of named clearly once.

A vocabulary nothing enforces is a convention, and conventions rot

Writing it down is necessary and it is not sufficient. I know this because the project already had a document saying, in as many words, anything not in this table is a violation; new commands must pick from the spine first. Commands drifted anyway. Prose cannot fail a build, and the person adding a command six months later is not reading a design document, they are copying the nearest existing command.

What actually holds is a test that walks the command tree and fails on a verb that is not in the vocabulary. It is maybe thirty lines. It cannot be forgotten, it cannot be skipped in review, and it produces the one thing prose never does: a red build with the offending word in the message, at the moment the word is introduced rather than a year later during an audit.

The same shape works for structural rules. The most useful test I have in this area asserts that every namespace either has its own behavior or is explicitly guarded, which is what stopped a class of commands from silently doing nothing when given an unknown subcommand. The rule had been written down before that. Writing it down had not helped.

Two properties make these tests worth having rather than annoying. They must derive from the tree rather than from a hand-maintained list, because a list of commands to check is itself something that goes stale and a walk of the real tree cannot. And the failure message should name the vocabulary entry the author should have used, so the test teaches instead of merely blocking.

The general version: every convention you intend to keep needs a mechanical check, and if you cannot think of one, you have a preference rather than a rule. Be honest about which you are writing down.

The tool this came from

ctxloom is at ctxloom.dev, and a much larger version is in alpha now. This audit was part of clearing the ground for it.

The guides are worth reading. They are not worth citing as settled, because the tools they describe do not agree with them, and in one case do not agree with each other under the same author’s name. Probe the tools you actually admire and copy what they do, then write down why before someone has to guess.