python-cli-typer

SKILL.md

Python CLI with Typer

Build modern, type-safe command-line applications using Typer with automatic help generation, shell completion, and rich terminal output.

When to Use This Skill

  • Creating new CLI applications in Python
  • Adding command-line interfaces to existing projects
  • Migrating from argparse or click to Typer
  • Building CLI tools with subcommands
  • Adding rich terminal output and progress bars

Quick Start

Installation

uv add typer
# Optional: rich output support
uv add "typer[all]"

Minimal Example

#!/usr/bin/env python3
"""Simple CLI application."""
import typer

app = typer.Typer()


@app.command()
def hello(name: str) -> None:
    """Say hello to NAME."""
    typer.echo(f"Hello, {name}!")


if __name__ == "__main__":
    app()

Running the CLI

# Run directly
uv run python cli.py hello World

# Or install as package and run
uv run my-cli hello World

CLI Structure Patterns

Single Command CLI

"""Single command application."""
import typer


def main(
    name: str = typer.Argument(..., help="Name to greet"),
    count: int = typer.Option(1, "--count", "-c", help="Number of greetings"),
    uppercase: bool = typer.Option(False, "--uppercase", "-u", help="Uppercase output"),
) -> None:
    """Greet someone multiple times."""
    greeting = f"Hello, {name}!"
    if uppercase:
        greeting = greeting.upper()
    for _ in range(count):
        typer.echo(greeting)


if __name__ == "__main__":
    typer.run(main)

Multi-Command CLI

"""Multi-command application with subcommands."""
import typer

app = typer.Typer(help="Module management CLI")


@app.command()
def list() -> None:
    """List all modules."""
    typer.echo("Listing modules...")


@app.command()
def add(name: str, version: str = "latest") -> None:
    """Add a new module."""
    typer.echo(f"Adding {name}@{version}")


@app.command()
def remove(name: str, force: bool = typer.Option(False, "--force", "-f")) -> None:
    """Remove a module."""
    if force:
        typer.echo(f"Force removing {name}")
    else:
        typer.echo(f"Removing {name}")


if __name__ == "__main__":
    app()

Nested Subcommands

"""CLI with nested command groups."""
import typer

# Main app
app = typer.Typer(help="Infrastructure orchestrator CLI")

# Subcommand groups
module_app = typer.Typer(help="Module management commands")
workflow_app = typer.Typer(help="Workflow management commands")
config_app = typer.Typer(help="Configuration commands")

# Register subcommand groups
app.add_typer(module_app, name="module")
app.add_typer(workflow_app, name="workflow")
app.add_typer(config_app, name="config")


# Module commands
@module_app.command("list")
def module_list() -> None:
    """List all modules."""
    typer.echo("Modules: ...")


@module_app.command("add")
def module_add(name: str) -> None:
    """Add a module."""
    typer.echo(f"Adding module: {name}")


# Workflow commands
@workflow_app.command("run")
def workflow_run(name: str) -> None:
    """Run a workflow."""
    typer.echo(f"Running workflow: {name}")


@workflow_app.command("status")
def workflow_status() -> None:
    """Check workflow status."""
    typer.echo("Workflow status: OK")


# Config commands
@config_app.command("show")
def config_show() -> None:
    """Show current configuration."""
    typer.echo("Config: ...")


@config_app.command("set")
def config_set(key: str, value: str) -> None:
    """Set a configuration value."""
    typer.echo(f"Setting {key}={value}")


if __name__ == "__main__":
    app()

Usage:

orchestrator module list
orchestrator module add my-module
orchestrator workflow run deploy
orchestrator config set debug true

Arguments and Options

Arguments (Positional)

import typer
from typing import Optional

app = typer.Typer()


@app.command()
def process(
    # Required argument
    filename: str = typer.Argument(..., help="File to process"),

    # Optional argument with default
    output: str = typer.Argument("output.txt", help="Output file"),

    # Optional argument that can be None
    config: Optional[str] = typer.Argument(None, help="Optional config file"),
) -> None:
    """Process a file."""
    typer.echo(f"Processing {filename} -> {output}")
    if config:
        typer.echo(f"Using config: {config}")

Options (Flags)

import typer
from typing import Optional
from pathlib import Path

app = typer.Typer()


@app.command()
def deploy(
    # Required option
    environment: str = typer.Option(..., "--env", "-e", help="Target environment"),

    # Optional with default
    timeout: int = typer.Option(300, "--timeout", "-t", help="Timeout in seconds"),

    # Boolean flag
    dry_run: bool = typer.Option(False, "--dry-run", "-n", help="Simulate without changes"),

    # Short and long form
    verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),

    # Path option with validation
    config: Optional[Path] = typer.Option(
        None,
        "--config",
        "-c",
        help="Config file path",
        exists=True,
        file_okay=True,
        dir_okay=False,
        readable=True,
    ),

    # Multiple values
    tags: Optional[list[str]] = typer.Option(None, "--tag", "-t", help="Tags to apply"),
) -> None:
    """Deploy to an environment."""
    typer.echo(f"Deploying to {environment}")
    typer.echo(f"Timeout: {timeout}s, Dry run: {dry_run}")
    if config:
        typer.echo(f"Config: {config}")
    if tags:
        typer.echo(f"Tags: {', '.join(tags)}")

Enums for Choices

import typer
from enum import Enum


class Environment(str, Enum):
    development = "development"
    staging = "staging"
    production = "production"


class LogLevel(str, Enum):
    debug = "DEBUG"
    info = "INFO"
    warning = "WARNING"
    error = "ERROR"


app = typer.Typer()


@app.command()
def deploy(
    env: Environment = typer.Option(
        Environment.development,
        "--env",
        "-e",
        help="Target environment",
        case_sensitive=False,
    ),
    log_level: LogLevel = typer.Option(
        LogLevel.info,
        "--log-level",
        "-l",
        help="Logging level",
    ),
) -> None:
    """Deploy with environment choice."""
    typer.echo(f"Deploying to {env.value} with log level {log_level.value}")

Input and Output

User Prompts

import typer

app = typer.Typer()


@app.command()
def setup() -> None:
    """Interactive setup wizard."""
    # Simple prompt
    name = typer.prompt("What's your project name?")

    # Prompt with default
    author = typer.prompt("Author name", default="Anonymous")

    # Hidden input for secrets
    api_key = typer.prompt("API Key", hide_input=True)

    # Confirmation prompt
    confirmed = typer.confirm("Create project?", default=True)

    if confirmed:
        typer.echo(f"Creating project: {name} by {author}")
    else:
        typer.echo("Cancelled")
        raise typer.Abort()

Styled Output

import typer

app = typer.Typer()


@app.command()
def status() -> None:
    """Show status with colors."""
    # Colored text
    typer.echo(typer.style("Success!", fg=typer.colors.GREEN, bold=True))
    typer.echo(typer.style("Warning!", fg=typer.colors.YELLOW))
    typer.echo(typer.style("Error!", fg=typer.colors.RED, bold=True))

    # Shortcuts
    typer.secho("Green text", fg=typer.colors.GREEN)
    typer.secho("Bold red", fg=typer.colors.RED, bold=True)
    typer.secho("Blue background", bg=typer.colors.BLUE)


@app.command()
def report() -> None:
    """Show a formatted report."""
    typer.secho("=" * 40, fg=typer.colors.CYAN)
    typer.secho("  DEPLOYMENT REPORT", fg=typer.colors.CYAN, bold=True)
    typer.secho("=" * 40, fg=typer.colors.CYAN)
    typer.echo()
    typer.secho("✓ Module A deployed", fg=typer.colors.GREEN)
    typer.secho("✓ Module B deployed", fg=typer.colors.GREEN)
    typer.secho("✗ Module C failed", fg=typer.colors.RED)

Rich Output (with rich library)

import typer
from rich.console import Console
from rich.table import Table
from rich.progress import track
from rich.panel import Panel
import time

app = typer.Typer()
console = Console()


@app.command()
def modules() -> None:
    """List modules in a table."""
    table = Table(title="Modules")
    table.add_column("Name", style="cyan")
    table.add_column("Version", style="green")
    table.add_column("Status", style="magenta")

    table.add_row("module-a", "1.0.0", "✓ Deployed")
    table.add_row("module-b", "2.1.0", "✓ Deployed")
    table.add_row("module-c", "0.5.0", "⏳ Pending")

    console.print(table)


@app.command()
def process(count: int = 100) -> None:
    """Process with progress bar."""
    for _ in track(range(count), description="Processing..."):
        time.sleep(0.01)
    console.print(Panel.fit("✓ Processing complete!", style="green"))

Error Handling

Exit Codes

import typer

app = typer.Typer()


@app.command()
def validate(config: str) -> None:
    """Validate a configuration file."""
    if not config.endswith(".yaml"):
        typer.secho("Error: Config must be a YAML file", fg=typer.colors.RED, err=True)
        raise typer.Exit(code=1)

    try:
        # Validate logic...
        typer.secho("✓ Configuration is valid", fg=typer.colors.GREEN)
    except Exception as e:
        typer.secho(f"Error: {e}", fg=typer.colors.RED, err=True)
        raise typer.Exit(code=2)

Abort and Exceptions

import typer

app = typer.Typer()


@app.command()
def delete(name: str, force: bool = False) -> None:
    """Delete a resource."""
    if not force:
        typer.confirm(f"Delete {name}?", abort=True)

    typer.echo(f"Deleting {name}...")


@app.command()
def dangerous() -> None:
    """A dangerous operation."""
    typer.secho("This will destroy everything!", fg=typer.colors.RED)
    raise typer.Abort()

Callbacks and Context

App Callback (Global Options)

import typer
from typing import Optional

app = typer.Typer()

# Global state
state = {"verbose": False, "config": None}


@app.callback()
def main(
    verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
    config: Optional[str] = typer.Option(None, "--config", "-c", help="Config file"),
) -> None:
    """
    Orchestrator CLI - Manage infrastructure modules.

    Use --verbose for detailed output.
    """
    state["verbose"] = verbose
    state["config"] = config
    if verbose:
        typer.echo("Verbose mode enabled")


@app.command()
def deploy(module: str) -> None:
    """Deploy a module."""
    if state["verbose"]:
        typer.echo(f"Config: {state['config']}")
        typer.echo(f"Deploying module: {module}")
    else:
        typer.echo(f"Deploying {module}...")

Context for State

import typer
from typing import Optional

app = typer.Typer()


@app.callback()
def main(ctx: typer.Context, debug: bool = False) -> None:
    """Main entry point."""
    ctx.ensure_object(dict)
    ctx.obj["debug"] = debug


@app.command()
def run(ctx: typer.Context, name: str) -> None:
    """Run a task."""
    if ctx.obj["debug"]:
        typer.echo(f"DEBUG: Running {name}")
    typer.echo(f"Running {name}")

Project Structure

Recommended Layout

src/orchestrator/
├── __init__.py
├── __main__.py          # Entry point: python -m orchestrator
├── cli/
│   ├── __init__.py
│   ├── main.py          # Main app and callback
│   ├── module.py        # Module subcommands
│   ├── workflow.py      # Workflow subcommands
│   └── config.py        # Config subcommands
└── core/
    ├── __init__.py
    └── ...              # Business logic

__main__.py

"""Entry point for python -m orchestrator."""
from orchestrator.cli.main import app

if __name__ == "__main__":
    app()

cli/main.py

"""Main CLI application."""
import typer

from orchestrator.cli import module, workflow, config

app = typer.Typer(
    name="orchestrator",
    help="Infrastructure orchestrator CLI",
    no_args_is_help=True,
)

# Register subcommand groups
app.add_typer(module.app, name="module")
app.add_typer(workflow.app, name="workflow")
app.add_typer(config.app, name="config")


@app.callback()
def main(
    ctx: typer.Context,
    verbose: bool = typer.Option(False, "--verbose", "-v"),
) -> None:
    """Infrastructure Orchestrator - Manage modules and workflows."""
    ctx.ensure_object(dict)
    ctx.obj["verbose"] = verbose


@app.command()
def version() -> None:
    """Show version information."""
    typer.echo("orchestrator v1.0.0")

cli/module.py

"""Module management commands."""
import typer

app = typer.Typer(help="Module management commands")


@app.command("list")
def list_modules() -> None:
    """List all registered modules."""
    typer.echo("Listing modules...")


@app.command()
def add(name: str, version: str = "latest") -> None:
    """Add a new module."""
    typer.echo(f"Adding module: {name}@{version}")


@app.command()
def remove(name: str) -> None:
    """Remove a module."""
    typer.echo(f"Removing module: {name}")

pyproject.toml Entry Point

[project.scripts]
orchestrator = "orchestrator.cli.main:app"

Testing CLI

"""Test CLI commands."""
from typer.testing import CliRunner

from orchestrator.cli.main import app

runner = CliRunner()


def test_version() -> None:
    """Test version command."""
    result = runner.invoke(app, ["version"])
    assert result.exit_code == 0
    assert "1.0.0" in result.stdout


def test_module_list() -> None:
    """Test module list command."""
    result = runner.invoke(app, ["module", "list"])
    assert result.exit_code == 0


def test_module_add() -> None:
    """Test module add command."""
    result = runner.invoke(app, ["module", "add", "my-module", "--version", "1.0.0"])
    assert result.exit_code == 0
    assert "my-module" in result.stdout


def test_missing_required_arg() -> None:
    """Test error on missing required argument."""
    result = runner.invoke(app, ["module", "add"])
    assert result.exit_code != 0


def test_verbose_flag() -> None:
    """Test verbose flag propagation."""
    result = runner.invoke(app, ["--verbose", "module", "list"])
    assert result.exit_code == 0

Resources

Guidelines

  • Use type hints for all arguments and options
  • Provide help text for every command, argument, and option
  • Use no_args_is_help=True on the main app
  • Group related commands with add_typer()
  • Use enums for restricted choices
  • Return proper exit codes (0=success, 1+=error)
  • Test CLI with typer.testing.CliRunner
  • Keep business logic separate from CLI code
Weekly Installs
1
First Seen
12 days ago
Installed on
mcpjam1
claude-code1
junie1
windsurf1
zencoder1
crush1