Input & UX

Rich-Formatted Help for Click CLIs with rich-click

Give an existing Click CLI Rich-formatted help and errors with rich-click: the drop-in import, option and command groups, markup, the rich-click runner, and tests.

Updated

Typer users get boxed, coloured help screens for free because Typer renders help with Rich. Click users get Click's plain, perfectly serviceable text — until the CLI has thirty options and nobody can find anything. rich-click closes the gap without a rewrite: it is a drop-in layer over Click that formats help and error messages with Rich, adds grouping of options and commands into named panels, and supports Rich markup in help strings. For most applications the change is a single import line. This guide applies it to a Click CLI, configures groups and markup, shows how to try it on a CLI you do not own, and tests the result. It belongs to the help output and documentation topic; the Typer equivalent is Rich markup and help panels in Typer.

Prerequisites

  • A Click 8.x CLI.
  • uv add rich-click (examples checked with rich-click 1.9).

The drop-in

rich-click re-exports Click's API, with its own command and group classes that render help through Rich. Changing the import is enough:

# before
import click

# after
import rich_click as click

Every @click.command(), @click.group() and @click.option() keeps working; help, usage errors and --help output now render as Rich panels with aligned columns, highlighted option names and coloured error boxes on capable terminals.

What the import change gives you A comparison of plain Click help output with rich-click help output after changing a single import. What the import change gives you Aspect import click import rich_click as click Help layout plain columns boxed panels Option groups one list named panels Errors plain line red error panel Parsing, exit codes Click Click — unchanged Only formatting changes; behaviour and exit codes stay exactly as they were.

The recipe

# src/backup/cli.py
import rich_click as click

click.rich_click.TEXT_MARKUP = "rich"          # allow [bold], [cyan] in help strings
click.rich_click.SHOW_ARGUMENTS = True         # list positional arguments in their own panel
click.rich_click.OPTION_GROUPS = {
    "backup": [
        {"name": "Destination", "options": ["--to", "--storage-class"]},
        {"name": "Filtering", "options": ["--exclude", "--max-size"]},
    ]
}


@click.command()
@click.argument("sources", nargs=-1, required=True)
@click.option("--to", required=True, help="Local path or [cyan]s3://[/cyan] URL.")
@click.option("--storage-class", type=click.Choice(["STANDARD", "GLACIER"]),
              default="STANDARD", show_default=True)
@click.option("--exclude", multiple=True, help="Skip matching paths.")
@click.option("--max-size", type=int, help="Skip files larger than this (MB).")
@click.option("--verbose", "-v", count=True, help="More detail.")
def backup(sources, to, storage_class, exclude, max_size, verbose):
    """Back up [bold]SOURCES[/bold] to local disk or S3."""
    click.echo(f"backing up {len(sources)} source(s) to {to}")
 Usage: backup [OPTIONS] SOURCES...

 Back up SOURCES to local disk or S3.

╭─ Destination ──────────────────────────────────────────────────────────────╮
│ *  --to             TEXT                Local path or s3:// URL.           │
│                                         [required]                         │
│    --storage-class  [STANDARD|GLACIER]  [default: STANDARD]                │
╰────────────────────────────────────────────────────────────────────────────╯
╭─ Filtering ────────────────────────────────────────────────────────────────╮
│ --exclude   TEXT     Skip matching paths.                                  │
│ --max-size  INTEGER  Skip files larger than this (MB).                     │
╰────────────────────────────────────────────────────────────────────────────╯
╭─ Arguments ────────────────────────────────────────────────────────────────╮
│ *  SOURCES  TEXT  [required]                                               │
╰────────────────────────────────────────────────────────────────────────────╯
╭─ Options ──────────────────────────────────────────────────────────────────╮
│ --verbose  -v  INTEGER RANGE  More detail.                                 │
│ --help                        Show this message and exit.                  │
╰────────────────────────────────────────────────────────────────────────────╯

OPTION_GROUPS maps a command name to an ordered list of panels; options not listed fall into the default "Options" panel, so --help and rarely used switches end up there naturally. Required options get an asterisk. For groups with subcommands, COMMAND_GROUPS does the same for the list of commands, keyed by the group's name; nested commands are keyed by their full path, such as "mytool sync". The same settings can be passed per command with @click.rich_config(help_config=click.RichHelpConfiguration(...)) if you prefer configuration next to the code it affects.

Grouped options with rich-click Terminal session showing rich-click help with Destination and Filtering panels and a usage error panel. Grouped options with rich-click bash $ backup --help | grep "╭─" ╭─ Destination ─────────────────────────────╮ ╭─ Filtering ───────────────────────────────╮ ╭─ Arguments ───────────────────────────────╮ ╭─ Options ─────────────────────────────────╮ $ backup ~/docs; echo $? │ Missing option '--to'. │ 2 Options not listed in a group fall into the default Options panel.

Errors get the same treatment

Usage errors render in a red-bordered "Error" panel with the usage line above it:

╭─ Error ────────────────────────────────────────────────────────────────────╮
│ Missing option '--to'.                                                     │
╰────────────────────────────────────────────────────────────────────────────╯

Exit codes are unchanged — 2 for usage errors — so scripts are unaffected; only the human-facing text changes.

Command groups and Markdown markup

For a group with many subcommands, COMMAND_GROUPS does for commands what OPTION_GROUPS does for options, and TEXT_MARKUP = "markdown" lets docstrings use Markdown instead of Rich tags — handy when the same docstrings also feed generated documentation:

# src/mytool/cli.py
import rich_click as click

click.rich_click.TEXT_MARKUP = "markdown"
click.rich_click.COMMAND_GROUPS = {
    "mytool": [
        {"name": "Everyday", "commands": ["sync", "status"]},
        {"name": "Maintenance", "commands": ["prune", "doctor"]},
    ]
}


@click.group()
def mytool():
    """Keep **local** and **remote** folders in sync."""


@mytool.command()
def sync():
    """Copy changes in both directions."""


@mytool.command()
def status():
    """Show what would change."""


@mytool.command()
def prune():
    """Delete old snapshots. Uses `--keep` days."""


@mytool.command()
def doctor():
    """Check the environment."""


@mytool.command()
def version():
    """Show the version."""
╭─ Everyday ─────────────────────────────────────────────────────────╮
│ sync    Copy changes in both directions.                           │
│ status  Show what would change.                                    │
╰────────────────────────────────────────────────────────────────────╯
╭─ Maintenance ──────────────────────────────────────────────────────╮
│ prune   Delete old snapshots. Uses --keep days.                    │
│ doctor  Check the environment.                                     │
╰────────────────────────────────────────────────────────────────────╯
╭─ Commands ─────────────────────────────────────────────────────────╮
│ version  Show the version.                                         │
╰────────────────────────────────────────────────────────────────────╯

Commands not listed in any group — version here — land in the default "Commands" panel at the end, which is a good place for housekeeping commands. Group names should match how users think about the tool: the everyday verbs first, maintenance and administration after. Keeping the groups in one mapping near the top of the module also gives reviewers a single place to check when a new command is added; a small test that every registered command appears in some group (or deliberately in none) prevents new commands from landing in the catch-all panel by accident.

Matching your house style

rich-click exposes its styles as module-level settings, so help can match the rest of your tool's output — the same colours as the tables and status lines described in theming Rich output consistently:

click.rich_click.STYLE_OPTION = "bold cyan"
click.rich_click.STYLE_COMMAND = "bold cyan"
click.rich_click.MAX_WIDTH = 100                       # do not sprawl on very wide terminals
click.rich_click.ERRORS_SUGGESTION = "Try 'mytool --help' for usage."
click.rich_click.ERRORS_EPILOGUE = "Docs: https://example.com/mytool"

MAX_WIDTH is worth setting on its own: help text stretched across a 250-column terminal is harder to read than help capped at a comfortable width. The error suggestion and epilogue appear under every usage error, which makes them the right place for a pointer to documentation or a support channel. Set these once, in the module that defines the root command, so every subcommand inherits them.

Trying it on a CLI you do not own

rich-click installs a rich-click command that runs any Click application with Rich formatting, without modifying its code:

rich-click mytool --help                 # an installed console script
rich-click mypackage.cli:main --help     # module:object

That is a quick way to preview the result on your own CLI before committing to the dependency — or to make a third-party Click tool's help easier to read for yourself.

UX considerations

  • Group by task. "Destination", "Filtering", "Output" — the questions users have — rather than by type.
  • Keep markup sparse. A highlighted protocol or a bold placeholder helps; whole sentences in colour do not, and colour disappears in logs and pipes anyway.
  • Escape square brackets when TEXT_MARKUP = "rich" and help mentions literal brackets (\[default]), or use "markdown" mode instead.
  • Mind the dependency weight. rich-click pulls in Rich, which adds import time; most of it is only paid when help or an error is rendered, but measure with profiling Python CLI startup time if startup matters.
  • Respect NO_COLOR. Rich honours it automatically, as described in respecting NO_COLOR and FORCE_COLOR.
The settings you will use The rich-click settings most applications configure and what each controls. The settings you will use Setting Controls OPTION_GROUPS options per named panel, per command COMMAND_GROUPS subcommands per named panel TEXT_MARKUP "rich", "markdown" or plain MAX_WIDTH cap on help width ERRORS_EPILOGUE text under every usage error Set them once in the module that defines the root command.

Testing the behaviour

click.testing.CliRunner works unchanged. Render at a fixed width with colour disabled, and assert on panel titles and their contents:

# tests/test_rich_help.py
import re

from click.testing import CliRunner

from backup.cli import backup

ENV = {"COLUMNS": "78", "NO_COLOR": "1"}


def panels(text: str) -> list[str]:
    return re.findall(r"╭─ (.+?) ─", text)


def test_option_groups_render_in_order():
    output = CliRunner().invoke(backup, ["--help"], env=ENV).output
    assert panels(output) == ["Destination", "Filtering", "Arguments", "Options"]
    assert "--storage-class" in output.split("╭─ Destination")[1].split("╰")[0]


def test_markup_is_rendered_not_printed():
    output = CliRunner().invoke(backup, ["--help"], env=ENV).output
    assert "[cyan]" not in output and "Local path or s3:// URL." in output


def test_usage_errors_keep_exit_code_2():
    result = CliRunner().invoke(backup, ["a"], env=ENV)
    assert result.exit_code == 2 and "Missing option '--to'" in result.output

Asserting on panels rather than the exact screen keeps tests stable across rich-click releases, which occasionally adjust spacing, while still catching an option that slipped out of its group.

Conclusion

rich-click brings Typer-style help to Click with one changed import: Rich-rendered help and errors, option and command groups configured in one mapping, optional Rich or Markdown markup, and a rich-click runner to preview the result on any Click CLI. Group options by task, keep markup light, escape literal brackets, and test panel structure at a fixed width — exit codes and behaviour stay exactly as they were.

Frequently asked questions

Does rich-click change how arguments are parsed?

No. Parsing, validation, defaults and exit codes are Click's. rich-click replaces only the formatting of help and error messages.

Can I keep plain Click output for some users?

Rich adapts automatically: without a terminal, or with NO_COLOR, colours disappear while the layout remains. If you need Click's exact plain format — for example because downstream tooling parses --help — do not adopt rich-click for that command.

How does it interact with Click plugins and custom classes?

Custom command or group classes should inherit from rich-click's RichCommand and RichGroup instead of Click's base classes; otherwise they fall back to plain formatting. Most Click plugins work unchanged.

Should I switch to Typer instead?

Not just for help formatting — rich-click gives you that with far less change. Switch when you want Typer's type-hint-driven declarations; see converting a Click app to Typer.

Does rich-click work with shell completion and documentation generators?

Completion is Click's and unaffected. Documentation generators that introspect Click commands (such as sphinx-click or mkdocs-click) read the command objects, not the rendered help, so they keep working — though Rich markup tags in help strings may appear literally in generated docs unless the generator understands them.