A CLI's interface — the commands, flags and arguments people type — is its most durable decision. Code behind it can be rewritten freely; the interface cannot, because it lives in shell history, runbooks, CI configurations, cron entries, colleagues' muscle memory and other people's scripts. Once mytool deploy --dir ./site has been in a README for a month, renaming --dir is a breaking change. Yet most CLIs grow their interface one command at a time, each added by whoever needed it that week, and end up with list in one place and ls in another, -f meaning --force here and --file there, and a destructive command that deletes production data on a typo.
This topic is about designing the interface deliberately: choosing a shape for the command tree, naming commands and flags consistently, deciding which options are global, making destructive operations safe, and following the conventions — POSIX, GNU and decades of Unix practice — that users already know. It sits in the Modern Python CLI Frameworks & Architecture section alongside structuring multi-command CLIs, which covers the code side of the same tree.
TL;DR
- Choose one command-tree shape — flat verbs, noun-then-verb or verb-then-noun — and apply it everywhere.
- Use one vocabulary: the same verb for the same action on every resource, the same flag name for the same meaning on every command.
- Make destructive commands safe by default: plan, show, confirm, apply — with
--dry-runand--yesfor automation. - Keep global options global in meaning, and decide deliberately where they may appear on the command line.
- Follow the conventions users already know: long and short flags,
--opt=value,--to end options,-for stdin, exit status 2 for usage errors.
Choosing a shape for the command tree
Before naming individual commands, decide how the tree is organised. Three shapes cover almost every well-known tool:
Flat verbs (git commit, git push, pip install) suit tools that operate on essentially one kind of thing. They are the easiest to learn and the hardest to grow: when a second kind of thing arrives, verbs start needing qualifiers (git remote add, git stash list) and the flat shape bends.
Noun then verb (gh pr create, gh repo clone, aws s3 cp) suits tools that manage several resource types. Each noun becomes a command group with a predictable set of verbs, --help at each level lists what you can do to that resource, and adding a new resource type does not crowd the top level. This is the best default for most internal tools that wrap an API or platform.
Verb then noun (kubectl get pods, kubectl delete deployment) suits tools with a small, fixed set of verbs applied uniformly to many resource types. It shines when every verb really does apply to every noun, and struggles when some resources support operations others do not.
In Typer and Click, noun-then-verb maps naturally onto nested groups, which is exactly the structure described in building a CLI with subcommands in Click:
import typer
app = typer.Typer(no_args_is_help=True)
sites = typer.Typer(help="Manage deployed sites.", no_args_is_help=True)
builds = typer.Typer(help="Inspect and prune builds.", no_args_is_help=True)
app.add_typer(sites, name="site")
app.add_typer(builds, name="build")
@sites.command("list")
def site_list() -> None:
"""List sites."""
@sites.command("show")
def site_show(name: str) -> None:
"""Show one site in detail."""
@builds.command("list")
def build_list() -> None:
"""List recent builds."""
@builds.command("prune")
def build_prune(older_than: str = "30d", dry_run: bool = False, yes: bool = False) -> None:
"""Delete builds older than a threshold."""
no_args_is_help=True at each level means mytool site with nothing else prints the verbs available for sites — the discoverability that makes the noun-then-verb shape pleasant.
One vocabulary, everywhere
The single biggest improvement to most grown-organically CLIs is a shared vocabulary: the same verb for the same action on every resource, and the same flag for the same meaning in every command. Users then learn the tool once. Naming commands and flags consistently develops a full vocabulary; the core rules are short:
- Verbs:
list(many),show(one),create,update,delete. Pick one word per action and never introduce a second canonical name for it; aliases for muscle memory (lsforlist) are fine if they are documented as aliases. - Flags: lowercase kebab-case long names, always. Short flags only for the few options people type constantly, and only with the meanings Unix tools established:
-vverbose,-qquiet,-ooutput,-fforce,-ndry-run or count,-hhelp. - Booleans: positive names with a generated negation (
--color/--no-color), never double negatives like--disable-no-verify. - Units: in the name when ambiguous (
--timeout-seconds), or accept units in the value (--timeout 30s) with a clear parser.
The design principles that hold up over years of growth are mostly about restraint:
Arguments, options and the anatomy of an invocation
A command line has more structure than a list of words, and designing well means knowing which part each word belongs to:
Positional arguments are for the thing the command acts on — the file, the site, the resource name. Keep to one or two; beyond that, users forget the order. Options are for everything that modifies how the command acts, and they should have sensible defaults so the common case needs none. A required option is often a sign that it should be a positional argument, or that the default is missing. Where an option's value can come from configuration or the environment as well as the command line, follow a single precedence order, as in config precedence: flags, env, files and defaults.
Global options — --verbose, --profile, --config, --no-color — affect every command and belong to the top-level group. Click parses group options only before the subcommand name, so mytool deploy -v fails where mytool -v deploy works — a frequent source of confusion. Global options vs per-command options covers how to decide scope and how to accept common options in either position.
Safe destructive commands
Commands that delete, overwrite, deploy or migrate deserve a different design from commands that read. The pattern that works is plan, show, confirm, apply: compute what would change without changing anything, show it, ask for confirmation, and apply exactly the plan that was shown. --dry-run stops after showing; --yes skips the prompt for automation; and when no terminal is attached, a missing --yes is an error rather than an implicit yes.
import sys
import typer
def confirm_or_exit(message: str, yes: bool) -> None:
if yes:
return
if not sys.stdin.isatty():
typer.echo(f"error: {message} — refusing without --yes (no terminal to ask)", err=True)
raise typer.Exit(2)
if not typer.confirm(message, default=False, err=True):
typer.echo("aborted", err=True)
raise typer.Exit(1)
Sharing one planning function between the dry run and the real run is what makes the preview trustworthy: they cannot drift apart because they are the same code. Adding dry-run and confirmation to destructive commands builds the full pattern, including production guards that ask for more than a y.
Conventions users already know
Command-line users bring decades of expectations from Unix tools, codified loosely by POSIX and extended by GNU. Meeting them makes a tool feel right; breaking them makes it feel foreign. Click and argparse implement most of them automatically — combined short flags (-xvf), long options, --opt=value, options interleaved with arguments, -- to end option parsing. A few are your responsibility:
-as stdin or stdout.mytool lint -should read standard input, the convention described in reading piped input in Python CLIs.- Exit statuses. 0 for success, 1 for failure, 2 for usage errors (Click and argparse already use 2), and specific codes where scripts need them — see choosing exit codes for CLI tools.
- Streams. Results to stdout, everything else — progress, warnings, prompts — to stderr, so pipes carry only data.
- Environment conventions. Honour
NO_COLOR,PAGER,EDITORandTMPDIRwhere they apply.
Following POSIX and GNU argument conventions covers the full list and the handful of places where Click's defaults differ from GNU behaviour.
Output is part of the interface too
It is easy to think of the interface as only the input side — what users type — but scripts depend just as much on what comes back. Three output decisions deserve the same care as flag names.
Human and machine output are different products. Human output can change freely between releases: better wording, colour, alignment, a new column. Machine output cannot. Offer a stable machine format (--json, or --output json if you already have several formats) on every command that returns data, document its schema, and version it. Once people can get JSON, they stop scraping your tables, and you regain the freedom to improve the tables. The details are in emitting JSON output for scripting.
Quiet success, loud failure. A command that succeeds should say little — one line, or nothing if the result is self-evident — and one that fails should say exactly what went wrong and what to do next. Verbosity flags (-v, -q) then adjust from that baseline rather than users learning to ignore walls of output.
Consistent exit statuses per kind of failure. "Not found", "not allowed", "invalid input" and "service unavailable" are different situations for a script. Map them to distinct, documented exit codes across all commands, not ad hoc per command.
Help, examples and completion as design tools
The interface is only as usable as it is discoverable, and three built-in affordances do most of the work.
Help text should lead with what the command does, in one sentence, then list options with short descriptions and defaults. Examples in an epilog — real, copy-pasteable invocations — are often more useful than the option list itself; see adding examples and epilogs to help output.
Error messages are help delivered at the moment of need. "No such option: --enviroment (did you mean --environment?)" — which Click produces for close matches — teaches the interface as users make mistakes.
Shell completion turns a consistent vocabulary into speed: users press Tab after mytool site and see the verbs, after --env and see the environments. It rewards the consistency this topic argues for, because predictable names complete predictably. Setting it up is covered in shell completion for Python CLIs.
Writing the interface down first
For anything beyond a handful of commands, sketch the interface before implementing it. A plain text file listing every command with its arguments and key options — essentially the --help output you intend to have — takes an hour and exposes most inconsistencies before they ship: two verbs for the same action, a flag whose meaning differs between commands, a positional argument list that will not fit the next feature.
mytool site list [--output table|json] [--limit N]
mytool site show SITE [--output table|json]
mytool site deploy SITE DIR [--env dev|staging|prod] [--dry-run] [--yes]
mytool site delete SITE [--dry-run] [--yes]
mytool build list [--site SITE] [--output table|json] [--limit N]
mytool build prune [--older-than DURATION] [--dry-run] [--yes]
Read the sketch as a user would. Are the same things called the same names? Would a script author know which flags produce machine-readable output? Does every destructive command have --dry-run and --yes? The sketch is also the basis of the contract tests that later keep the interface stable across releases, as described in semantic versioning policy for CLI tools.
Evolving an interface that already exists
Most teams read a topic like this with a CLI that already has inconsistencies. The fix is gradual:
- Write down the current interface — generate it from
--help— and mark each inconsistency. - Choose the target vocabulary and shape.
- Add the new names as aliases or new commands in a minor release, keeping the old ones working.
- Deprecate the old names with warnings on stderr that name the replacement, as described in versioning and deprecating CLI flags.
- Remove them in the next major release, listed in the changelog with the migration for each.
Hidden aliases (hidden=True in Click and Typer) keep old names working without cluttering --help, so the interface new users see is already the clean one while old scripts keep running.
Key takeaways
- The interface outlives the code; design it deliberately and write it down before implementing.
- Choose one command-tree shape; noun-then-verb suits most tools that manage several resource types.
- One verb per action and one meaning per flag, across every command.
- Make destructive commands plan, show, confirm and apply, with
--dry-runand--yes. - Keep global options global in meaning and decide where they may appear.
- Follow POSIX and GNU conventions users already know, and implement
-, exit statuses and stream separation yourself. - Fix existing inconsistencies with aliases, deprecation warnings and a major release, never by silent renames.
Frequently asked questions
How many commands is too many at the top level?
When --help scrolls off a screen, users stop reading it. Around ten to fifteen top-level entries is a practical limit; beyond that, group related commands under nouns. Typer's rich_help_panel can also split a long list into labelled sections.
Should commands prompt for missing required arguments?
Only as a convenience on top of flags, and only when a terminal is attached. Every value that can be prompted for must also be passable as an argument or option, or the command cannot be scripted.
Is it acceptable to break conventions when my users are not Unix people?
Conventions still help Windows users, because most modern CLIs they use (git, docker, kubectl, npm) follow the same patterns. Where Windows users expect different behaviour — /? for help, for instance — accept it as an alias rather than replacing the convention.
Should flags or positional arguments carry the main input?
Use a positional argument for the one thing the command obviously acts on — mytool deploy SITE, mytool lint FILE... — because it reads naturally and composes with shell globbing and xargs. Use flags for everything else. When a command has two equally important inputs, such as a source and a destination, two positionals are acceptable if their order follows an established convention (cp SRC DEST); otherwise name them as flags so nobody has to remember the order.
How do I get feedback on an interface before shipping it?
Share the written sketch and ask two or three prospective users to "use" it on paper for a real task: which command would they type? Where they hesitate or guess wrong is where the names need work. It costs an hour and saves a deprecation cycle.
Who should own the interface in a team?
Someone should. Interfaces degrade when every contributor adds commands in their own style, and they stay coherent when one or two people review every new command, flag and output format against the written vocabulary. A short checklist in the pull-request template — "uses existing verbs? flags match other commands? destructive commands have --dry-run and --yes? machine output documented?" — spreads that review without making it a bottleneck. Contract tests that pin the public interface, run in CI, catch the changes that slip through review.
Should a single-purpose tool still use subcommands?
No. A tool that does one thing should do it at the top level (mytool FILE), like grep or black. Add a command group only when there is a second, genuinely different operation.
Related
- Up: Modern Python CLI Frameworks & Architecture
- Down: Naming commands and flags consistently
- Down: Adding dry-run and confirmation to destructive commands
- Down: Global options vs per-command options
- Down: Following POSIX and GNU argument conventions
- Sideways: Structuring multi-command Python CLIs
- Sideways: CLI help output and documentation