mytool sites ls, mytool build list, mytool user show-all. --output-file on one command, --out on another, -o meaning output format on a third. --force spelled -f in delete and -F in deploy, where -f means --file. Each name was reasonable when it was chosen; together they make a tool that users have to look up every time, and that nobody can script against without reading --help for each command. Naming is the cheapest part of a CLI to get right and one of the most expensive to fix later, because every name is a public interface. This guide sets out a small vocabulary for commands and flags, the rules that keep names consistent as a tool grows, how to handle aliases and legacy names, and — the part that makes it stick — a test that walks your command tree and fails when a name breaks the rules. It belongs to the designing CLI interfaces and conventions topic.
Prerequisites
- A Typer or Click CLI with more than a handful of commands (the test below works with both).
- A decision about the shape of the command tree; see the topic overview.
A vocabulary for commands
Most CLI commands do one of a handful of things to a resource. Pick one word for each, write it down, and use it everywhere:
list returns many items in summary form and supports filters and --limit. show returns one item in detail, identified by a positional argument. create, update and delete change things and follow the safety pattern in adding dry-run and confirmation to destructive commands. Domain-specific verbs — deploy, sync, rollback, login — are fine where they describe the action better than a CRUD verb, as long as each has one name.
The anti-pattern is synonyms: list in one group and ls or get in another, delete here and remove or rm there. Users do not know which variant a given command uses, so they guess, and guess wrong. If people want short forms, add them as documented aliases of the canonical name — never as the canonical name of some commands but not others.
Rules for flags
- Long names in lowercase kebab-case, always:
--dry-run,--output-file. Never--dryRunor--output_file. Typer convertsdry_runparameters to--dry-runautomatically. - Short flags only for frequently typed options, and with their conventional meanings:
-hhelp,-vverbose,-qquiet,-ooutput,-fforce,-ndry run (or count, as inhead -n),-yyes,-Cdirectory. Do not reuse a conventional letter for something else; users' fingers will not forgive you. - Positive booleans with generated negation:
--color/--no-color,--verify/--no-verify. The default goes in the positive name's help. Avoid names that are already negative (--disable-cache), because their negation (--no-disable-cache) is unreadable. - Units in the name when a number is ambiguous:
--timeout-seconds 30, or accept a duration string with units (--timeout 30s) and parse it strictly. - One meaning per name across the whole tool. If
--outputmeans "format" in one command, it must not mean "file path" in another. Use--output-fileor-o FILEconsistently for the path,--formatfor the format, and never mix them. - Singular names for repeatable options:
--tag a --tag b, not--tags a --tags b.
The recipe: shared definitions
Consistency is easiest when common options are defined once. In Typer, Annotated type aliases carry the flag names, help text and validation, and every command reuses them:
# src/mytool/options.py
from enum import Enum
from pathlib import Path
from typing import Annotated
import typer
class Format(str, Enum):
table = "table"
json = "json"
FormatOpt = Annotated[Format, typer.Option("--format", "-o", help="Output format.")]
LimitOpt = Annotated[int, typer.Option("--limit", "-n", min=1, help="Show at most N items.")]
DryRunOpt = Annotated[bool, typer.Option("--dry-run", help="Show what would change; change nothing.")]
YesOpt = Annotated[bool, typer.Option("--yes", "-y", help="Do not ask for confirmation.")]
OutFileOpt = Annotated[Path | None, typer.Option("--output-file", help="Write results to FILE.")]
# src/mytool/cli.py
import typer
from mytool.options import DryRunOpt, Format, FormatOpt, LimitOpt, YesOpt
app = typer.Typer(no_args_is_help=True)
sites = typer.Typer(no_args_is_help=True, help="Manage sites.")
builds = typer.Typer(no_args_is_help=True, help="Inspect builds.")
app.add_typer(sites, name="site")
app.add_typer(builds, name="build")
@sites.command("list")
def site_list(fmt: FormatOpt = Format.table, limit: LimitOpt = 20) -> None:
"""List sites."""
@sites.command("delete")
def site_delete(name: str, dry_run: DryRunOpt = False, yes: YesOpt = False) -> None:
"""Delete a site."""
@builds.command("list")
def build_list(fmt: FormatOpt = Format.table, limit: LimitOpt = 20) -> None:
"""List recent builds."""
# A documented, hidden alias: muscle memory works, --help stays clean.
sites.command("ls", hidden=True, help="Alias for 'list'.")(site_list)
Every list command now has identical --format/-o and --limit/-n options, with the same help text and validation, and a new list command gets them by writing two type annotations. The technique is covered in more depth in using Annotated options in Typer and, for Click, sharing common options across commands.
UX considerations
- Names are for typing and reading. Prefer short, common English words over clever ones.
syncbeatsreconcileunless your users already say "reconcile". - Match your users' domain words. If the team calls them "environments", do not call them "stages" in the CLI. The interface should use the vocabulary people already speak.
- Keep aliases few and documented. One short alias per frequently used command (
lsforlist) is helpful; a cloud of synonyms is not. Hidden aliases keep--helpclean while old scripts and muscle memory keep working. - Plan for renames. When a name must change, add the new one, deprecate the old with a warning naming the replacement, and remove it in the next major version, per semantic versioning policy for CLI tools.
Testing the behaviour
Rules written in a style guide drift; rules checked in CI hold. Typer apps expose their underlying Click command tree, which a test can walk to check every command and option name:
# tests/test_naming.py
import re
import typer
from mytool.cli import app
VERBS = {"list", "show", "create", "update", "delete", "deploy", "sync", "login", "logout", "prune"}
KEBAB = re.compile(r"^--[a-z][a-z0-9]*(-[a-z0-9]+)*$")
RESERVED_SHORT = {"-v": "--verbose", "-q": "--quiet", "-o": "--format", "-n": "--limit",
"-y": "--yes", "-f": "--force", "-h": "--help"}
def walk(cmd, path: tuple[str, ...] = ()):
"""Yield (path, command) for every command. Duck-typed: works for Click and Typer trees."""
yield path, cmd
for name, sub in getattr(cmd, "commands", {}).items():
yield from walk(sub, path + (name,))
def is_group(cmd) -> bool:
return hasattr(cmd, "commands")
def test_the_walk_sees_every_command():
names = {" ".join(path) for path, _ in walk(typer.main.get_command(app))}
assert {"site list", "site delete", "build list"} <= names
def test_leaf_commands_use_known_verbs():
for path, cmd in walk(typer.main.get_command(app)):
if path and not is_group(cmd) and not cmd.hidden:
assert path[-1] in VERBS, f"'{' '.join(path)}' uses a verb outside the vocabulary"
def test_long_options_are_kebab_case():
for path, cmd in walk(typer.main.get_command(app)):
for param in cmd.params:
for opt in [o for o in getattr(param, "opts", []) + getattr(param, "secondary_opts", [])
if o.startswith("--")]:
assert KEBAB.match(opt), f"{' '.join(path)}: {opt} is not kebab-case"
def test_short_flags_keep_their_conventional_meaning():
for path, cmd in walk(typer.main.get_command(app)):
for param in cmd.params:
opts = getattr(param, "opts", [])
for short, expected_long in RESERVED_SHORT.items():
if short in opts:
assert expected_long in opts, (
f"{' '.join(path)}: {short} is paired with {opts}, expected {expected_long}")
The walk is duck-typed — it follows any object with a commands mapping — because recent Typer releases build their command tree from their own vendored copy of Click, so isinstance(cmd, click.Group) with the standalone click package would silently see only the root and every test would pass vacuously. The first test guards against exactly that by checking the walk reaches known commands. The verb test then enforces the vocabulary on leaf commands (hidden aliases are exempt). The second catches --dryRun and --output_file. The third catches the subtle one — -f quietly meaning --file in one command — by requiring each reserved short flag to be paired with its conventional long name wherever it appears. Extend the reserved list as your own conventions settle.
Conclusion
Consistent names are a small discipline with a large payoff: users learn the tool once, scripts are predictable, and completion becomes genuinely fast. Choose one verb per action, lowercase kebab-case long flags, conventional short flags used only with their conventional meanings, positive booleans with negations, and one meaning per flag across the tool. Define shared options once as type aliases or decorators, keep aliases few and hidden, rename only through deprecation — and let a test walk the command tree so the rules survive the next contributor.
Frequently asked questions
Should command names be singular or plural?
Singular for resource groups (site list, build prune) reads more naturally as noun-then-verb and matches tools like gh. Whatever you choose, apply it to every group; mixing sites list and build list is exactly the inconsistency this guide is about.
What about subcommands with multiple words?
Use kebab-case (rotate-logs), matching flag style. Typer converts function names with underscores to kebab-case automatically; set explicit names when you want something different.
How should I name the tool itself?
Short enough to type hundreds of times a day, distinctive enough not to collide with existing commands on your users' machines, and searchable. Check command -v yourname on a few machines and search PyPI before settling. Two to eight lowercase letters with an optional hyphen is the comfortable range; if the natural name is long, ship a short second entry point as an alias, as described in best practices for Python CLI entry points.
Should flags be abbreviable, like GNU's --verb for --verbose?
Click does not support prefix abbreviation, and that is a feature: abbreviations become ambiguous as soon as you add a new flag with the same prefix, silently breaking scripts. argparse allows them by default; set allow_abbrev=False for the same reason.