typer-cli-completion

v2026.09.24

Generate Typer/Click shell completions from a non-shell parent, bypassing shellingham. Use when `--install-completion` prints "Shell None is not supported", or when generating them in CI or Docker.

GitHub
安装命令
npx skhub add laurigates/typer-cli-completion
Markdown
SKILL.md

Typer/Click CLI Completion: Bypass shellingham

Python CLIs built on Typer (and Click under it) ship --install-completion and --show-completion, which rely on shellingham to detect the parent shell. shellingham walks the parent process tree (/proc/$PPID/comm-style), not $SHELL. When the parent is anything other than a known shell, detection raises ShellDetectionFailure and Typer prints:

Shell None is not supported.

Setting $SHELL=/bin/zsh does not help. shellingham ignores the env var by design — the point is to detect the real running shell, not to trust what the environment claims.

When the failure mode bites

Parent processOutcome
Your interactive zsh / bashWorks — shellingham detects the shell
An agent runtime (Claude Code, a node host)Fails — node isn't a shell
GitHub Actions runnerFails — bash -e from Runner.Worker doesn't always classify
Docker entrypoint (sh -c …)Often fails — depends on the entrypoint chain
A uv-installed tool invoked as a subprocess from PythonFails — parent is python
A dotfiles manager running a completion generator (chezmoi apply)Fails — parent is chezmoi

The common shape: anything that generates completions non-interactively. That is precisely when you want to generate them — at install time, in a container build, from a dotfiles apply — so the happy path is the one case that does not need the feature.

The fix: an explicit completion <shell> subcommand

Take the shell name as an argument and call Click's completion machinery directly:

import typer
from rich.console import Console

app = typer.Typer(name="my-cli")
console = Console()
EXIT_CONFIG_ERROR = 2  # or whatever your CLI uses


@app.command()
def completion(
    shell: str = typer.Argument(
        ...,
        help="Shell type: bash, zsh, or fish.",
    ),
) -> None:
    """Print a shell completion script to stdout.

    Bypasses ``shellingham`` so it works from non-shell parents
    (agent runtimes, CI, Docker, chezmoi).
    """
    from click.shell_completion import get_completion_class

    cls = get_completion_class(shell)
    if cls is None:
        console.print(
            f"[red]Shell '{shell}' is not supported.[/red] "
            "Choose one of: bash, zsh, fish."
        )
        raise typer.Exit(code=EXIT_CONFIG_ERROR)

    click_cmd = typer.main.get_command(app)
    comp = cls(
        cli=click_cmd,
        ctx_args={},
        prog_name="my-cli",
        complete_var="_MY_CLI_COMPLETE",
    )
    print(comp.source())

After this lands, generation works from any parent process:

my-cli completion zsh > ~/.zfunc/_my-cli

complete_var is the env var Click reads at completion time to dispatch the runtime callback. Use the conventional Click form: _<PROG_NAME>_COMPLETE, with - replaced by _, uppercased. Getting it wrong fails silently — the script installs and simply never completes.

Wiring to a completion registry

A dotfiles-style registry that iterates a tool → command table and writes ~/.zfunc/_<tool> needs one line per CLI once the subcommand exists. The chezmoi form (.chezmoidata/completions.toml):

[packages.completion_tools.zsh_completions]
  "my-cli" = "my-cli completion zsh"

No bespoke parser is needed, because Typer already knows every flag the CLI exposes.

Required regression test

Assert the subcommand emits a usable script for each supported shell and exits non-zero for unsupported ones:

from typer.testing import CliRunner
from my_cli.main import app

runner = CliRunner()


def test_completion_zsh_emits_script() -> None:
    result = runner.invoke(app, ["completion", "zsh"])
    assert result.exit_code == 0, result.output
    assert "compdef" in result.output


def test_completion_bash_emits_script() -> None:
    result = runner.invoke(app, ["completion", "bash"])
    assert result.exit_code == 0, result.output
    assert "complete" in result.output


def test_completion_unsupported_shell_exits_nonzero() -> None:
    result = runner.invoke(app, ["completion", "tcsh"])
    assert result.exit_code != 0
    assert "not supported" in result.output.lower()

Per-shell markers to assert on: zsh emits #compdef / compdef, bash emits complete -o nosort -F, fish emits complete -c ….

Why not patch Typer's --install-completion?

Typer's callbacks are wired tightly to shellingham and expose no fallback hook, so overriding that path means monkey-patching a private API. The explicit subcommand is one Click call in your own code. It is also why the fix belongs downstream rather than upstream: Typer's maintainers have repeatedly declined this, on the grounds that shellingham should work — which it does, in the one case where you do not need it.

Canonical implementation

Related

  • python-plugin:python-packaging — [project.scripts] entry points, which is what gives the CLI a prog_name to complete
  • agent-patterns-plugin:agent-cli-worktree-safety — sibling conventions for Typer CLIs that drive an agent SDK
发现
标签

此技能尚未发布标签。

版本
最新版本元数据

版本

v2026.09.24

发布时间

Sep 24, 2026

分类

未分类

许可证

MIT

源路径

python-plugin/skills/typer-cli-completion

默认分支

main

最新提交

1668324

Tree SHA

b2d4cc3