Input & UX

Shipping Shell Completion Scripts with a Python CLI

Get completion to users without manual setup: a completion subcommand, lazy-loaded user directories, static scripts generated at release time for Homebrew and Linux packages, and tests.

Updated

Shell completion is one of those features users love and rarely set up. Click and Typer can complete commands, options and values, but the shell only asks your program for completions after something has registered a completion function — and a Python wheel cannot reliably put files where bash, zsh or fish look for them, especially when it is installed by pipx or uv into an isolated environment. The result is a CLI with excellent completion support that most people never see. This guide covers the delivery side: a completion subcommand that prints the right script for each shell, the per-user directories where shells load completion files lazily, static scripts generated at release time for Homebrew and Linux packages, and tests that keep the scripts valid. It belongs to the shell completion topic; enabling completion in the framework itself is covered in enabling tab completion in Click and Typer.

Prerequisites

Where completion scripts can come from

Four ways to deliver completion Routes for getting shell completion scripts to users of a Python command line tool and whether each works with pipx or uv installs. Four ways to deliver completion Route Works for pipx/uv users? Effort for users completion subcommand yes one command Typer --install-completion yes one command (edits rc) Homebrew / distro package n/a — their own install none Wheel data files no — wrong prefix — Support the subcommand everywhere and package managers where you can.

There are four delivery routes, and a well-packaged CLI usually supports the first and third. A completion subcommand prints the script; users evaluate it from their shell profile or save it to a file. Typer's built-in --install-completion edits the user's shell configuration for them. Package managers — Homebrew, Debian and RPM packages, conda — install static script files into system completion directories during installation. A wheel's data files can technically ship scripts too, but they land inside the installation prefix (a pipx or uv tool environment), where no shell looks — so this route rarely works for CLIs.

The recipe

A completion subcommand

Click exposes the machinery that generates scripts, so one command can print the script for any supported shell:

# src/mytool/cli.py
import click
from click.shell_completion import get_completion_class


@click.group()
def cli() -> None:
    """My tool."""


@cli.command()
def status() -> None:
    """Show status."""
    click.echo("ok")


@cli.command()
@click.argument("shell", type=click.Choice(["bash", "zsh", "fish"]))
@click.pass_context
def completion(ctx: click.Context, shell: str) -> None:
    """Print the completion script for SHELL.

    \b
    bash:  mytool completion bash > ~/.local/share/bash-completion/completions/mytool
    zsh:   mytool completion zsh > "${fpath[1]}/_mytool"
    fish:  mytool completion fish > ~/.config/fish/completions/mytool.fish
    """
    root = ctx.find_root()
    prog = root.info_name or "mytool"
    complete_var = f"_{prog.replace('-', '_').upper()}_COMPLETE"
    script_class = get_completion_class(shell)
    click.echo(script_class(root.command, {}, prog, complete_var).source())


def main() -> None:
    cli(prog_name="mytool")

The command derives the program name and the completion environment variable the same way Click does (_MYTOOL_COMPLETE for mytool), so the generated script calls the installed command back correctly. The help text doubles as installation instructions. With Typer, the equivalent output is available without writing a command: mytool --show-completion bash prints the script, and _MYTOOL_COMPLETE=source_bash mytool does the same for automation. For argparse with argcomplete, register-python-argcomplete mytool plays this role, as in tab completion for argparse CLIs with argcomplete.

Lazy-loaded user directories

Rather than eval "$(mytool completion bash)" in a shell profile — which runs your CLI on every new shell — save the script once into the directory each shell loads completion files from on demand:

  • bash (with the bash-completion package, version 2): ~/.local/share/bash-completion/completions/mytool — loaded the first time you press Tab after typing mytool.
  • zsh: a file named _mytool in any directory on $fpath (create ~/.zfunc, add it to fpath before compinit in ~/.zshrc).
  • fish: ~/.config/fish/completions/mytool.fish — loaded automatically.

Saved files start new shells instantly. The trade-off is that they must be regenerated if the script format changes, which happens rarely; regenerating after major upgrades of the CLI is a good habit to document.

Installing completion once Terminal session saving generated completion scripts into the lazily loaded per-user directories for bash and fish. Installing completion once bash $ mkdir -p ~/.local/share/bash-completion/completions $ mytool completion bash > ~/.local/share/bash-completion/completions/mytool $ mytool completion fish > ~/.config/fish/completions/mytool.fish $ mytool st<Tab> mytool status Saved files load on first use — no cost to every new shell.

Static scripts for package managers

Packages built by Homebrew or distribution tooling can install completion files into system directories, so completion works immediately after installation. Generate the files from the CLI at build time instead of maintaining them by hand. Homebrew's formula DSL has a helper for exactly this:

# Formula/mytool.rb (excerpt)
def install
  virtualenv_install_with_resources
  generate_completions_from_executable(bin/"mytool", "completion")
end

generate_completions_from_executable runs mytool completion bash (and zsh, fish) and installs the output where Homebrew's shells look. For .deb or .rpm packages, run the same three commands in the build script and install the results to /usr/share/bash-completion/completions/mytool, /usr/share/zsh/vendor-completions/_mytool (Debian; other distributions use site-functions) and /usr/share/fish/vendor_completions.d/mytool.fish.

Completion files as release assets

For standalone binaries and for packagers who build from your releases, attach generated completion files to each release. A short script run in the release workflow produces them from the freshly built CLI:

#!/usr/bin/env bash
# scripts/build-completions.sh — run after installing the built wheel or binary
set -euo pipefail
out=dist/completions
mkdir -p "$out"
mytool completion bash > "$out/mytool.bash"
mytool completion zsh  > "$out/_mytool"
mytool completion fish > "$out/mytool.fish"
tar -czf dist/mytool-completions.tar.gz -C dist completions

Generating from the built artefact, rather than from the source tree, guarantees that the scripts match the version being released, and packagers for distributions you do not maintain yourself — Arch's AUR, Nix, community Scoop buckets — can install them without running your CLI during their build. Because the scripts only connect the shell to the program, the same files keep working across minor releases; regenerating them with every release simply keeps everything in step.

UX considerations

  • Make the first step one line. The README should show a single command per shell. Anything longer is skipped.
  • Prefer saved files to eval. Every eval "$(mytool …)" adds your startup time to every new shell; files are loaded lazily.
  • Be careful with --install-completion. It is convenient, but it edits shell configuration files; document what it changes, and offer the subcommand for users who manage their dotfiles themselves.
  • Keep completion fast regardless of delivery. The script only connects the shell to your program; each Tab still runs the CLI, so the advice in profiling Python CLI startup time applies.
  • Hide the completion command from casual users if it clutters help — hidden=True — but mention it in the installation docs.
Where shells look for completion files Per-user and system directories where bash, zsh and fish load completion scripts. Where shells look for completion files Shell Per user System packages bash ~/.local/share/bash-completion/completions/ /usr/share/bash-completion/completions/ zsh a dir on $fpath, file _mytool vendor-completions or site-functions fish ~/.config/fish/completions/ /usr/share/fish/vendor_completions.d/ Homebrew’s generate_completions_from_executable fills its own equivalents.

Testing the behaviour

Test that each script is generated, names the right program and environment variable, and — for bash — is syntactically valid:

# tests/test_completion_scripts.py
import shutil
import subprocess

import pytest
from click.testing import CliRunner

from mytool.cli import cli

runner = CliRunner()


@pytest.mark.parametrize("shell,marker", [("bash", "_mytool_completion()"),
                                          ("zsh", "#compdef mytool"),
                                          ("fish", "function _mytool_completion")])
def test_scripts_are_generated(shell, marker):
    result = runner.invoke(cli, ["completion", shell], prog_name="mytool")
    assert result.exit_code == 0
    assert marker in result.output
    assert "_MYTOOL_COMPLETE" in result.output


@pytest.mark.skipif(shutil.which("bash") is None, reason="bash not installed")
def test_bash_script_parses(tmp_path):
    script = tmp_path / "mytool.bash"
    script.write_text(runner.invoke(cli, ["completion", "bash"], prog_name="mytool").output)
    subprocess.run(["bash", "-n", str(script)], check=True)


def test_unknown_shell_is_a_usage_error():
    assert runner.invoke(cli, ["completion", "powershell"]).exit_code == 2

bash -n parses the script without running it, which catches the one failure that would otherwise only appear in users' shells: a syntax error introduced by a template change. To test the completions themselves, use the approach in testing shell completion in Python CLIs.

Conclusion

Completion only helps users who have it installed, so make installation trivial. Add a completion SHELL subcommand that prints the script using Click's own generator (or Typer's --show-completion), document saving it into the lazily loaded per-user directories, generate static files at release time for Homebrew and Linux packages, and test that every script is generated, references the right program and parses. The result is completion that works the moment someone installs your tool through a package manager — and one line away for everyone else.

Frequently asked questions

Why not ship the scripts inside the wheel?

Wheels can install data files only under the installation prefix. With pipx or uv, that prefix is a private tool environment that shells never read. System packages and Homebrew can place files in shell-specific directories; Python packaging cannot.

What happens to completion after the CLI is upgraded?

The script calls the installed command each time, so new commands and options complete immediately. Only changes to the script itself — rare, and tied to the framework — require regenerating saved files.

Does PowerShell completion work the same way?

Not with Click's generator, which supports bash, zsh and fish. Typer's --install-completion supports PowerShell; for Click, a small Register-ArgumentCompleter script that calls the program with Click's completion environment variables is the usual approach.

Can the completion subcommand install the file itself?

It can — write to the per-user directory when given --install — and many CLIs do. Print the path you wrote and how to undo it, and never overwrite a file you did not create without asking.

How do I know whether users actually have completion installed?

You usually cannot, and should not try to detect it at runtime. A gentle nudge works better: mention mytool completion --help in the output of mytool --version --verbose or in the first-run message, and keep the README's one-liner prominent.