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
| tool | order |
|---|---|
| gh, docker, gcloud, az, stripe, flyctl | noun-verb (gh repo list) |
| kubectl | verb-noun (kubectl get pods) |
| cargo, npm | verb-first, no nouns |
| git | mixed, historically accreted |
| terraform, wrangler | verb-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.
| behavior | tools |
|---|---|
| print help | gh, docker, flyctl (docs), wrangler (docs), stripe (docs) |
| error | gcloud (exit 2), az (docs), aws (docs), terraform (exit 1) |
| list the things | git’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.
| tool | destructive default |
|---|---|
| kubectl | delete acts immediately, no prompt. --dry-run=client|server is opt-in |
| git | no confirmations. git clean refuses without -f |
| docker | rm/rmi act immediately. Only prune prompts y/N |
| aws (docs) | s3 rm acts. --dry-run on select EC2 operations only |
| gh | repo delete prompts. --yes skips it, except deleting the repo you are in |
| gcloud, az, wrangler (docs) | prompt, with --quiet/--yes to skip |
| terraform | destroy 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.
| class | examples | dry run |
|---|---|---|
| destructive: removes something that existed | delete, purge, prune | on by default; a flag turns it off |
| changing or costing: writes, spends money or time | create, edit, push, pull, upgrade | off by default; --dry-run turns it on |
| read | list, show, search, status | not 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.