Architecture

Designing CLI Interfaces and Conventions in Python

Design a Python CLI’s command-line interface on purpose: command tree shape, naming, option scope, safe destructive commands and POSIX/GNU conventions.

Updated

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.

What this topic covers The interface design topic covers naming commands and flags, dry runs and confirmation for destructive commands, global versus per-command options, and POSIX and GNU argument conventions. What this topic covers Designing the interface what users type, and what it means Naming nouns, verbs, flags Dry run + confirm safe destructive commands Option scope global or per command Conventions POSIX, GNU, -- each branch has its own in-depth guide An interface is a promise: every name you ship is one you will have to keep.

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-run and --yes for 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:

Three shapes of command tree Command structures used by popular tools: a flat verb list, noun-then-verb groups, and verb-then-noun, with examples and when each fits. Three shapes of command tree Shape Example Fits when Flat verbs git commit, git push one kind of thing Noun then verb gh pr create, gh repo clone many resource types Verb then noun kubectl get pods few verbs, many nouns Pick one shape and apply it everywhere; mixing them is what makes a CLI feel arbitrary.

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 (ls for list) 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: -v verbose, -q quiet, -o output, -f force, -n dry-run or count, -h help.
  • 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:

Interface principles that age well Design principles for command line interfaces that hold up as a tool grows, and anti-patterns that cause pain later. Interface principles that age well Do Consistent verbs across resources Long flags always; short flags for the frequent few Safe by default, destructive by request Machine output behind --json Every option also settable by env or config Avoid Positional arguments whose order matters beyond two Flags that change the meaning of other flags Prompts with no non-interactive path Abbreviations users must memorise Different words for the same idea Most of these are cheap to get right early and expensive to fix after users have scripts.

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:

Anatomy of one invocation The parts of a command line invocation: the program, global options, the command path, command options and positional arguments, with the double dash separator. Anatomy of one invocation mytool -v deploy site --env prod -- ./dist argv mytool -v program + global option deploy site command path --env prod command option -- ./dist end of options, then data Globals before the command (Click) Options may be interleaved with args After --, nothing is an option Knowing which part of argv each word belongs to is what makes help text and errors precise.

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, EDITOR and TMPDIR where 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:

  1. Write down the current interface — generate it from --help — and mark each inconsistency.
  2. Choose the target vocabulary and shape.
  3. Add the new names as aliases or new commands in a minor release, keeping the old ones working.
  4. Deprecate the old names with warnings on stderr that name the replacement, as described in versioning and deprecating CLI flags.
  5. 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-run and --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.