Building Python CLI Tools with Typer and Click
When a bash script outgrows its argument parsing, move it to Python. Here's how to build real CLI tools with Typer and Click, including subcommands and validation.
- #python
- #bash
- #click
- #typer
- #cli
- #automation
There is a moment in the life of every useful bash script where it sprouts subcommands, optional flags, and a usage() function longer than the logic. That is the signal to stop fighting bash and rewrite it as a Python CLI. The argument parsing alone is worth the move.
After 25 years of operational tooling, my rule is simple: flat, flag-driven scripts stay in bash; anything with subcommands or structured options becomes a Python CLI with typer or click. Here is how I build them.
Why leave bash
Bash is excellent glue and a terrible application framework. Once you need tool deploy --to prod and tool rollback --version 3 in the same binary, bash means a sprawling case statement, manual help text, and brittle validation. Python with a CLI framework gives you subcommands, typed options, auto-generated help, and tab completion for free. You also gain the entire Python ecosystem — HTTP clients, YAML parsers, cloud SDKs — that bash can only shell out to.
Typer: the fast path
typer builds the CLI from your function’s type hints. You write a normal typed function; it generates the parser, help text, and validation:
import typer
app = typer.Typer(help="Deploy helper")
@app.command()
def deploy(
env: str = typer.Argument(..., help="target environment"),
replicas: int = typer.Option(1, help="replica count"),
dry_run: bool = typer.Option(False, "--dry-run", help="don't apply"),
):
"""Deploy the application."""
typer.echo(f"deploying to {env} with {replicas} replicas (dry_run={dry_run})")
if __name__ == "__main__":
app()
That is a complete CLI. Run it with no args and Typer prints usage. The : int hint means --replicas abc is rejected with a clear error before your code runs. --help is generated from the docstrings and help= strings. This is dramatically less code than the equivalent bash.
Subcommands in Typer
Each @app.command() is a subcommand automatically:
@app.command()
def rollback(version: int):
"""Roll back to a previous version."""
typer.echo(f"rolling back to v{version}")
Now you have tool deploy and tool rollback, each with its own help, no extra wiring.
Click: more control
click is what Typer is built on. I reach for it directly when I want fine-grained control over the parser — custom types, callbacks, option groups, prompts:
import click
@click.group()
def cli():
"""Deploy helper."""
@cli.command()
@click.argument("env")
@click.option("--replicas", default=1, type=int, help="replica count")
@click.option("--dry-run", is_flag=True, help="don't apply")
def deploy(env, replicas, dry_run):
"""Deploy the application."""
click.echo(f"deploying to {env} (dry_run={dry_run})")
if __name__ == "__main__":
cli()
The decorator style is more explicit than Typer’s type-hint magic. For complex CLIs with custom parameter types or interactive prompts, that explicitness is worth it. For most tools, Typer’s brevity wins. They interoperate — a Typer app is a Click app under the hood — so you are never locked in.
Validation that fails early
The biggest practical win over bash is validation. Use enums and constrained types so bad input is rejected at parse time:
from enum import Enum
class Env(str, Enum):
staging = "staging"
production = "production"
@app.command()
def deploy(env: Env, replicas: int = typer.Option(1, min=1, max=20)):
typer.echo(f"deploying to {env.value}")
Now tool deploy production --replicas 50 fails with “replicas must be <= 20” and a non-existent environment is rejected against the enum — all before your logic runs. In bash you would write that validation by hand; here it is declarative.
Exit codes and errors
Operational tools must exit non-zero on failure so pipelines and schedulers notice. Raise typer.Exit(code=1) (or click.ClickException) rather than letting a traceback leak:
if not config_path.exists():
typer.echo(f"config not found: {config_path}", err=True)
raise typer.Exit(code=1)
Errors go to stderr, the exit code is meaningful, and there is no scary traceback for an expected error condition.
Packaging it
Once it works, package it with uv and an entry point so users run deploy instead of python deploy.py:
# pyproject.toml
[project.scripts]
deploy = "mytool.cli:app"
Then uv tool install . puts deploy on the PATH. That is the difference between a script you copy around and a tool your team installs.
Where AI fits
Scaffolding a CLI is exactly the kind of structured, boilerplate-heavy task AI handles well. I describe the command surface and let it generate the skeleton:
“Generate a Typer CLI for an infra tool with three subcommands:
deploy(env enum staging/production, —replicas int 1-20, —dry-run flag),rollback(version int argument), andstatus(no args). Add docstrings, validation, and non-zero exits on error. Echo errors to stderr.”
The output is usually runnable as-is, and reviewing a CLI skeleton is fast because the structure is so predictable. I save a few of these scaffolding prompts in my prompt library and adapt them per tool.
The dividing line
Keep it in bash when it is flat and flag-driven. Move it to Typer the moment you need subcommands, typed validation, or the Python ecosystem. The rewrite is rarely as big as you fear, and the resulting tool is one your team can actually install and trust.
For more on building and packaging Python automation, see our bash and Python automation guides.
Download the Free 500-Prompt DevOps AI Toolkit
500 battle-tested, copy-paste AI prompts engineered by a senior systems engineer — every one with fill-in placeholders and safety/back-out notes. Drop your email and it's yours.
- 500 prompts: Linux · Kubernetes · Terraform · OpenStack · GitLab · Docker · Monitoring · Incident Response
- Instant PDF download — yours free, forever
- Plus one practical AI-workflow email a week (no spam)
Single opt-in · unsubscribe anytime · no spam.