CLI cheatsheet. Pick by complexity.
Typer (recommended)
uv add typer
import typer
app = typer.Typer()
@app.command()
def hello(name: str, count: int = 1):
"""Greet someone."""
for _ in range(count):
typer.echo(f"Hello {name}")
if __name__ == "__main__":
app()
python script.py hello Alice --count 3
python script.py hello --help
Subcommands
@app.command()
def users(): ...
@app.command()
def posts(): ...
python script.py users
python script.py posts
Nested apps
users_app = typer.Typer()
app.add_typer(users_app, name="users")
@users_app.command()
def list_(): ...
@users_app.command()
def create(): ...
python script.py users list
python script.py users create
Typer options
@app.command()
def run(
name: str = typer.Argument(...), # required positional
count: int = typer.Option(1, "--count", "-c", help="Repeat count"),
verbose: bool = typer.Option(False, "--verbose", "-v"),
file: Path = typer.Option(None, "--file", "-f", exists=True),
):
...
Rich output
from rich import print
from rich.console import Console
from rich.table import Table
console = Console()
console.print("[bold red]Error![/bold red]")
table = Table(title="Users")
table.add_column("ID")
table.add_column("Name")
table.add_row("1", "Alice")
console.print(table)
rich integrates with Typer automatically.
Progress bars
from rich.progress import track
for item in track(items, description="Processing..."):
process(item)
Prompts
name = typer.prompt("Your name")
confirmed = typer.confirm("Are you sure?")
password = typer.prompt("Password", hide_input=True)
Exit codes
import sys
if error:
typer.echo("failed", err=True)
raise typer.Exit(code=1)
Click (for power users)
uv add click
import click
@click.command()
@click.argument("name")
@click.option("--count", default=1, help="Repeat count")
@click.option("-v", "--verbose", is_flag=True)
def hello(name: str, count: int, verbose: bool):
"""Greet someone."""
for _ in range(count):
click.echo(f"Hello {name}")
if __name__ == "__main__":
hello()
python script.py Alice --count 3 -v
Click groups
@click.group()
def cli(): pass
@cli.command()
def users(): ...
@cli.command()
def posts(): ...
if __name__ == "__main__":
cli()
argparse (stdlib, no install)
import argparse
parser = argparse.ArgumentParser(description="My tool")
parser.add_argument("name")
parser.add_argument("--count", type=int, default=1)
parser.add_argument("-v", "--verbose", action="store_true")
args = parser.parse_args()
for _ in range(args.count):
print(f"Hello {args.name}")
Subparsers
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers(dest="command", required=True)
users = subparsers.add_parser("users")
users.add_argument("--list", action="store_true")
posts = subparsers.add_parser("posts")
args = parser.parse_args()
match args.command:
case "users": ...
case "posts": ...
Configuration with pydantic-settings + CLI
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
database_url: str
debug: bool = False
model_config = SettingsConfigDict(cli_parse_args=True)
# python script.py --database-url=... --debug
settings = Settings()
For tools where config = CLI args + env + .env.
Auto-complete
# Typer
python script.py --install-completion
Generates shell completions automatically.
Distribution
[project.scripts]
mytool = "myapp.cli:app"
After uv pip install -e .: mytool ... available.
Decision
| Use | |
|---|---|
| Quick scripts | argparse (stdlib) |
| Modern CLIs | Typer |
| Power features | Click |
| Config-heavy tools | pydantic-settings + Typer |
For new CLIs: Typer is the modern default.
Common mistakes
- argparse for complex hierarchies — code gets ugly. Use Typer.
- No
--helptesting — make sure your CLI’s docs are readable. - Hard-coded paths — use
Pathtypes via Typer. - No exit codes —
0for success, non-zero for failure.
Read this next
If you want my Typer + rich + pydantic-settings CLI starter, it’s at rajpoot.dev .
Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .