Input & UX

CLI Help Output and Documentation

Write help output people read - option lines, epilog examples, generated man pages and docs, and deprecating flags without breaking anyone's scripts.

Updated

Help output is the documentation that ships with your tool. It is read far more often than anything on a website, it cannot go stale relative to the code it describes, and it is where a user decides whether your tool is worth learning. It also tends to be the least deliberately written text in the whole project.

TL;DR

  • One line per option: what it does, what it accepts, what the default is.
  • Keep the root help screen a map, not a catalogue — group commands by subject area.
  • Put two to four real examples in an epilog; people copy rather than read.
  • Generate reference docs and man pages from the command tree so they cannot drift.
  • Renaming a flag or changing a default is a breaking change: alias, warn, then remove.
What this section covers The help and documentation section covers writing help text, adding examples and epilogs, generating man pages and docs, and deprecating flags. What this section covers Help & documentation the interface users read Help text one line per option, done well Examples epilogs people copy from Generated docs man pages and web pages Deprecation changing flags without breaking scripts help output is documentation that ships with the binary and cannot go stale Everything here is about the text users meet before they ever find your website.

What a help screen is made of

The parts of a help screen A command line help screen broken into its usage line, short description, argument list, option list and epilog with examples. The parts of a help screen mytool sync --help what a new user reads first Usage line generated — keep argument names meaningful Summary one sentence, from the docstring Arguments & options one line each, with defaults Epilog two or three real examples The summary is the only part read on the parent screen Defaults belong in the option line, not in prose Examples answer "what do I actually type" Three of the four are generated; the words inside them are entirely yours.

Three of the four parts are generated for you. The usage line comes from your parameter declarations, the option list from their help strings, and the summary from the first line of the docstring. What you control is the words inside them — and the epilog, which is the only part you write from scratch.

The usage line follows conventions that predate every framework in this handbook, and following them means users already know how to read it:

Where authors go wrong is the summary. It appears twice — on the command's own help screen and, more importantly, next to the command name on its parent's screen — so it is the single most-read sentence in your tool:

@app.command()
def sync(source: Path) -> None:
    """Upload changed files from SOURCE to the configured bucket."""

That first line is the summary. Everything after a blank line becomes the longer description, which means a docstring written for developers ("Implements the sync algorithm described in ADR-14") becomes user-facing text nobody can act on.

Option lines that carry information

A user scanning twelve options is reading twelve lines of about eight words each. The ones that say nothing cost them attention they needed for the ones that do:

# says nothing
retries: Annotated[int, typer.Option(help="Sets the retries.")] = 3

# says something
retries: Annotated[int, typer.Option(help="Attempts per file before giving up.")] = 3

Add show_default=True (Click) or let Typer print it automatically, so the reader does not have to guess what happens when they omit the flag. For values with units, put the unit in the help text and, where it helps, in the metavar:

timeout: Annotated[float, typer.Option(help="Seconds to wait per attempt.", metavar="SECONDS")] = 30.0

Three more habits worth adopting. Start with a verb — "Attempts per file", "Skip verification", "Write output to PATH". Mention the partner flag when two interact ("Ignored unless --watch is set"). And keep each line to one screen width, because the framework will wrap it and a wrapped option line is much harder to scan.

The root screen is a map

How a user actually finds a command The path from the root help screen through a group summary to a command help screen and finally to an example the user copies. How a user actually finds a command mytool --help lists groups, not every verb mytool db --help lists that area's commands mytool db migrate --help options and defaults Copies an example from the epilog scan narrow act Each screen exists to get the reader to the next one, which is why summaries matter more than detail.

A root --help that lists thirty verbs is a catalogue. A root that lists five subject areas is a map, and the difference decides whether a new user finds anything.

Usage: mytool [OPTIONS] COMMAND [ARGS]...

  Manage deployments and the data behind them.

Commands:
  sync     Upload changed files to the configured bucket.
  db       Database maintenance: migrate, seed, dump.
  remote   Manage remote endpoints.
  doctor   Print environment and configuration diagnostics.

Four entries, each with a summary that says what lives underneath. When the list stops fitting on a screen, that is the signal to add a level rather than another top-level verb — the same rule as in structuring multi-command CLIs.

Examples belong in the tool

The question a help screen most often fails to answer is "what do I actually type". An epilog with two to four real invocations answers it:

app = typer.Typer(
    help="Manage deployments and the data behind them.",
    epilog=(
        "Examples:\n"
        "  mytool sync ./data --retries 5\n"
        "  mytool sync ./data --dry-run | tee plan.txt\n"
        "  mytool db migrate --yes            # non-interactive, for CI\n"
    ),
)

For argparse, the same thing needs formatter_class=argparse.RawDescriptionHelpFormatter, or the newlines are collapsed and the examples become a paragraph.

Choose examples that teach a habit, not just a syntax: the piped form shows that stdout is data, and the --yes form shows the automation path exists. And make sure they work — an example that errors is worse than none, which is a good argument for running them in a test.

Documentation generated from the tree

Reference documentation retyped into a README drifts within one release. Generating it from the command definitions makes drift impossible:

typer mytool.cli utils docs --output docs/reference.md    # Typer's own generator

click-man and sphinx-click do the equivalent for Click, producing man pages and Sphinx pages respectively. Whichever you use, run it in CI and fail the build when the generated output differs from what is committed — that turns "the docs are out of date" into a failing check rather than a discovery.

What generation cannot produce is the why: when to use a command, what the trade-offs are, how it fits a workflow. Keep those hand-written and keep the reference generated, and neither has to pretend to be the other.

Keeping documentation and behaviour in step Practices that stop command line documentation drifting from the tool, and the habits that guarantee it will. Keeping documentation and behaviour in step Stays true Reference pages generated from the command tree Examples in the epilog, tested by running them A snapshot test over help output Exit codes documented in one place, read by the docs Drifts within a release Option lists retyped into a README Screenshots of help output Examples nobody runs A changelog written from memory at release time The test for documentation is whether a wrong statement would fail a build.

Changing the interface later

Flags are a public API. The changes people underestimate are the ones that leave the name intact — changing a default, or changing what a flag means — because nothing in a user's script looks different and the behaviour has moved underneath them.

The mechanics are cheap: accept the old name as a hidden alias, warn on stderr naming the replacement and the removal version, and remove it at a major release. The deprecation guide covers the implementation and the changelog entries that go with it.

Width, colour and the other terminal

Help output is read in three places — an interactive terminal, a pipe into less or grep, and a CI log — and it should look sensible in all three.

Both frameworks size output to the terminal and fall back to a fixed width when there is none. The one thing worth overriding is the upper bound: a help screen stretched across a 200-column window is much harder to read than one clamped to around 100.

app = typer.Typer(context_settings={"max_content_width": 100})
CONTEXT_SETTINGS = {"max_content_width": 100, "help_option_names": ["-h", "--help"]}

@click.group(context_settings=CONTEXT_SETTINGS)
def cli() -> None:
    ...

help_option_names is worth setting while you are there. -h is what most people type first, and neither framework enables it by default.

Colour follows the same rule as the rest of your output: on for a terminal, off for anything else, and off when NO_COLOR is set. Both frameworks handle the detection; what they cannot know is whether your epilog's ANSI art is essential, which is an argument for not having any.

Errors are help output too

The text a user sees when they get an invocation wrong belongs in the same category, and it is read more carefully than the help screen — because at that moment they are stuck.

Both frameworks produce a decent default:

Usage: mytool sync [OPTIONS] SOURCE
Try 'mytool sync --help' for help.

Error: Invalid value for '--retries': 'x' is not a valid integer.

Three things make that work: the usage line reminds them of the shape, the hint names the command to get more, and the error names the offending option and value. When you write your own errors, keep the same three ingredients:

raise typer.BadParameter(
    "expected a duration like 30s, 5m or 2h",
    param_hint="--timeout",
)

param_hint is what puts the flag name in the message. Without it the user gets a sentence with no indication of which of their twelve arguments was wrong.

For failures that are not usage errors — a missing file, an unreachable service — the equivalent courtesy is naming the input and suggesting the next step, which is covered in friendly error messages.

Documenting the parts that are not flags

A complete CLI has three surfaces beyond its options, and all three are routinely undocumented.

Environment variables. If your tool reads MYTOOL_TOKEN or honours NO_COLOR, say so. The natural place is the root epilog and the man page's ENVIRONMENT section:

Environment:
  MYTOOL_TOKEN     API credential; required for remote commands.
  MYTOOL_CONFIG    Path to a config file, equivalent to --config.
  NO_COLOR         Disable colour output entirely.

Configuration files. Users need to know which paths are searched and in what order, because "the tool is ignoring my setting" is almost always a question about search order. Documenting the precedence chain — and offering a config show --origin command — removes most of those questions.

Exit codes. Anyone scripting your tool needs the list, and a tool that documents its codes is one people are willing to branch on:

Exit codes:
  0   success
  1   the operation failed
  2   the command line was invalid
  65  input data was malformed
  78  configuration was invalid
  130 cancelled by the user

All three are cheap to write once and expensive to leave out, because each one generates a recurring class of question that nothing in the option list answers.

Keeping help honest in CI

Two small checks stop help output regressing, and both are worth having before the tool has many users.

def test_root_help_lists_every_command(cli):
    result = cli.invoke(app, ["--help"])

    assert result.exit_code == 0
    for name in ("sync", "db", "remote", "doctor"):
        assert name in result.stdout

def test_every_option_has_help_text():
    for command in app.registered_commands:
        for param in command.params:
            assert param.help, f"{command.name}: --{param.name} has no help text"

The second one is the useful one. It is three lines, it catches the option someone added in a hurry, and it is the difference between a help screen that stays complete and one that quietly develops gaps. Pair it with a snapshot of the root help screen — see snapshot testing CLI output — and any change to what users read shows up as a reviewable diff.

A worked rewrite

The fastest way to see the difference is to take a real help screen and fix it. Here is one that a tool might ship without thinking about:

Usage: mytool sync [OPTIONS] SRC

  Sync command.

Options:
  --retries INTEGER
  --timeout FLOAT
  --mode TEXT
  --force
  --help              Show this message and exit.

Six problems, all of them common. The summary says nothing. No option documents its default. SRC is an abbreviation the user has to guess at. --mode accepts "TEXT", which could mean anything. --force gives no hint of what it overrides. And there are no examples.

The declarations that fix it:

@app.command()
def sync(
    source: Annotated[Path, typer.Argument(
        exists=True, file_okay=False, metavar="SOURCE",
        help="Directory to upload.",
    )],
    retries: Annotated[int, typer.Option(
        min=1, max=10, help="Attempts per file before giving up.",
    )] = 3,
    timeout: Annotated[float, typer.Option(
        metavar="SECONDS", help="Seconds to wait for each upload.",
    )] = 30.0,
    mode: Annotated[SyncMode, typer.Option(
        case_sensitive=False, help="What to do about files missing locally.",
    )] = SyncMode.keep,
    force: Annotated[bool, typer.Option(
        help="Upload even when the remote copy looks newer.",
    )] = False,
) -> None:
    """Upload changed files from SOURCE to the configured bucket."""

Which renders as:

Usage: mytool sync [OPTIONS] SOURCE

  Upload changed files from SOURCE to the configured bucket.

Arguments:
  SOURCE  Directory to upload.  [required]

Options:
  --retries INTEGER RANGE  Attempts per file before giving up.  [1<=x<=10; default: 3]
  --timeout SECONDS        Seconds to wait for each upload.  [default: 30.0]
  --mode [keep|delete]     What to do about files missing locally.  [default: keep]
  --force / --no-force     Upload even when the remote copy looks newer.  [default: no-force]
  --help                   Show this message and exit.

Nothing was added to the implementation — the extra information came from the enum, the range, the metavar and four sentences. That is the general shape of improving help output: most of it is already available to the framework, and the rest is one line of English per option.

The remaining gap is examples, which the epilog covers. Two lines under this command turn "what does --mode delete do to my files" into something a cautious user can test:

Examples:
  mytool sync ./data --dry-run          # show what would change
  mytool sync ./data --mode delete      # mirror: remove remote files gone locally

Where to go next

Key takeaways

  • The command summary is the most-read sentence in your tool; write it for users, not developers.
  • Every option line should say what it does, what it takes and what the default is.
  • The root help screen should route, not enumerate.
  • Examples in an epilog answer the question the option list cannot.
  • Generate reference documentation; hand-write the reasoning; check both in CI.

Frequently asked questions

How long should help output be?

The root screen should fit on one terminal screen. A command's own screen can be longer, but if it runs past two screens the command probably has too many options — or some of them belong in a config file, where they can be set once instead of typed.

Should help text be localised?

Rarely worth it for developer tooling, where English is the working language and the flag names are English anyway. If your audience genuinely needs it, both Click and Typer accept translated strings, and the practical difficulty is not translation but keeping examples and error messages consistent with it.

Is Rich-formatted help a good idea?

Typer's default Rich help is pleasant for humans and occasionally awkward in logs and narrow terminals. It degrades reasonably, and rich_markup_mode=None turns it off if you need plain output. What matters more than the styling is the wording underneath it.

Where should the exit codes be documented?

In the help epilog for the root command, and in the man page's EXIT STATUS section. Users of scripts look for them there before they look on a website, and a tool that documents its codes is one people are willing to branch on.

Do I need a man page?

If your tool is installed by a system package manager, yes — users expect man mytool to work. For a tool installed with pipx, it is a nice extra rather than an expectation, and generating one from the command tree costs a build step.

Should hidden commands appear in help?

Internal or experimental commands can be hidden (hidden=True in both frameworks) and still work when typed, which is a reasonable way to ship something before committing to it. Be aware that hidden is not private: users find these commands and come to depend on them, so anything hidden for more than a release or two should either be documented or removed.

How do I document a command that only makes sense with another?

Say so in the summary, in both directions: "Requires a prior mytool init" on one and "Prepares a directory for mytool sync" on the other. Cross-references in help text are cheap and they are the only navigation a reader has while inside the terminal.