# Typer CLI Completion

> 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.

- Skill: `laurigates/typer-cli-completion` (Agent Skill)
- Install (CLI): `npx skillmds@latest add laurigates/typer-cli-completion`
- Raw SKILL.md: https://api.skillmd.com/api/skills/laurigates/typer-cli-completion/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: DevOps & Infra
- Author: laurigates (https://skillmd.com/u/laurigates)
- Updated: 2026-09-09
- Page: https://skillmd.com/skills/laurigates/typer-cli-completion

---


# 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`](https://github.com/sarugaku/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 process | Outcome |
|---|---|
| Your interactive zsh / bash | Works — shellingham detects the shell |
| An agent runtime (Claude Code, a `node` host) | Fails — `node` isn't a shell |
| GitHub Actions runner | Fails — `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 Python | Fails — 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:

```python
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:

```bash
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`):

```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:

```python
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

- [`git-repo-agent/src/git_repo_agent/main.py`](https://github.com/laurigates/git-repo-agent/blob/main/src/git_repo_agent/main.py) — the `completion` command
- [`git-repo-agent/tests/test_completion.py`](https://github.com/laurigates/git-repo-agent/blob/main/tests/test_completion.py) — four regression tests

## 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

[Typer]: https://typer.tiangolo.com/
[Click]: https://click.palletsprojects.com/

