Press TAB and your painted CLI completes itself — command names, flags, their choices, and the dynamic values you hang on an argument. Completion is the third reflection of your argparse parser, after parse and help: the same declarations that drive -h drive TAB, so what completes is exactly what the parser accepts — never a flag it would reject.
Completion is opt-in: painted prints the shell glue and you install it once. Nothing is edited on your behalf — the glue is a small function that calls your program back for candidates as you type.
# zsh — save the function on your $fpath, then restart the shell
yourapp completion zsh > "${fpath[1]}/_yourapp"
# bash — source the function from your ~/.bashrc
eval "$(yourapp completion bash)"
Or let painted write the file for you: yourapp completion --install detects your shell from $SHELL and drops the glue in your completions directory (--dry-run previews it first). It writes only a file painted owns and prints the one line to add if your shell isn't already looking there — it never edits a dotfile on your behalf.
A multi-command app built with run_app gets the completion command for free — yourapp completion <TAB> even completes its own shell argument (zsh, bash). The same machinery drives a single-command run_cli tool: once the glue is installed, TAB completes its flags too.
Every command name (with its one-line summary), every flag your parser declares, and every static choice complete with no extra work — they are already in the parser that powers -h. In zsh, each candidate's help text rides along as a description; bash shows the values alone.
--json JSON output (implies --static) --kind filter by kind --plain Plain text, no ANSI codes --quiet Minimal output (zoom=0) --since only rows after this time --verbose Increase detail level (-v=detailed, -vv=full)
Static choices cover the values you know at parse time. For values that are runtime data — a record id, a branch name, a vertex — hang a completer on the argument. It receives a CompletionContext and returns bare strings or described Candidates; painted normalizes either and never invents a result the completer didn't yield.
from painted.cli import Candidate, CompletionContext, complete_via
def complete_branch(ctx: CompletionContext) -> list[Candidate]:
# ctx.prefix is the partial token; ctx.args is what's already typed.
return [Candidate(name, subject) for name, subject in recent_branches()]
def add_args(parser):
complete_via(parser.add_argument("branch", help="..."), complete_branch)
complete_via attaches the completer in one line and returns the argument; it's the typed front door for the argcomplete-style action.completer attribute, which still works if you prefer it. Return Candidate(value, description) to show context in zsh, or a bare string when the value speaks for itself. Scope to the line via ctx.args — a --to completer can narrow to branches that aren't the --from already typed. A completer that raises degrades to no candidates rather than spilling a traceback into the shell.
An argument with no choices and no completer is an open slot — a free-text value the parser can't enumerate. painted classifies it and lets the shell complete paths there (zsh _files, bash's default), so ~ expansion, hidden-file rules, and your own zstyle all keep working. painted never reads the disk; the shell already knows how. To take a free-text value with no path fallback, give the argument a completer that returns an empty list — the explicit opt-out.
def add_args(parser):
# open slot -> shell completes paths here
parser.add_argument("path", help="file to read")
# free text, no file fallback -> explicit opt-out
complete_via(parser.add_argument("--message", help="..."), lambda ctx: [])
Pressing TAB imports none of painted's renderer and runs none of your fetch — completion answers from the parser's declarations alone. So completion is instant no matter how expensive your program is to run, and typing TAB can never trigger the work your command would do. That guarantee is structural, not a promise: the completion path physically cannot reach the rendering code.
Completion is the third reflection of one argparse parser. A single walk over the parser's actions (walk_args → ArgSpec) feeds both projectors: help renders each spec to a Def, completion renders it to a Candidate. There is no second source of truth to drift — the flag you see under -h is the flag that completes.
The honesty rule governs every candidate: it exists only because the parser, or a declared .completer, produced it. painted under-lists rather than suggest a flag the parser would reject. A candidate you can't act on is worse than one that's missing.
The render-free guarantee is enforced by construction. The producer, the transport, and the walk import only stdlib and each other; an AppCommand is read by attribute, never constructed. Pressing TAB cannot pull core.block or core.doc — the no-renderer-on-TAB property is a structural fact the module boundaries keep true.
Placement in the ecosystem is deliberate and honest. The .completer attribute is the argcomplete convention, so you attach a completer the same way — though painted calls it with a single CompletionContext, so the function body differs. What painted adds is the render-free promise and zsh descriptions sourced from the same help text — distinct value, not a reimplementation. Full design: docs/COMPLETION_DESIGN.md.
build_parser → walk_args → ArgSpec ╭─────────────╮ ╭───────╮ ╭─────────────╮ │ parse │ │ help │ │ complete │ │ → Namespace │ │ → Def │ │ → Candidate │ ╰─────────────╯ ╰───────╯ ╰─────────────╯