# CLI

> Guidance for building command-line interfaces and terminal applications in Python. USE FOR: building CLI tools with argparse/click/typer, rich terminal output, TUI applications with textual, CLI testing strategies, output formatting, exit codes DO NOT USE FOR: Python project setup or packaging (use project-system), installing CLI tools (use package-management)

- Skill: `tyler-r-kendrick/cli` (Agent Skill, multi-file: 16 files)
- Install (CLI): `npx skillmds add tyler-r-kendrick/cli`
- Raw SKILL.md: https://api.skillmd.com/api/skills/tyler-r-kendrick/cli/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- License: MIT
- Author: Tyler-R-Kendrick (https://skillmd.com/u/tyler-r-kendrick)
- Updated: 2026-09-10
- Page: https://skillmd.com/skills/tyler-r-kendrick/cli

---


# Python CLI Development

## Overview

Python is one of the most popular languages for building command-line tools, from simple scripts to complex multi-command applications. The ecosystem ranges from the standard library's `argparse` to high-level frameworks like `typer` that generate CLIs from type hints, and rich output libraries like `rich` and `textual` for beautiful terminal interfaces.

## Tool Comparison

| Feature | argparse | click | typer | fire | cement |
|---------|----------|-------|-------|------|--------|
| **Stdlib** | Yes | No | No | No | No |
| **Approach** | Imperative | Decorator-based | Type-hint-based | Introspection | Full framework |
| **Subcommands** | Yes (subparsers) | Yes (groups) | Yes (app + commands) | Automatic | Yes |
| **Auto-completion** | No (add-on) | Yes (plugin) | Yes (built-in) | No | Yes |
| **Testing** | Manual | `CliRunner` | `CliRunner` (via click) | Manual | Built-in |
| **Rich output** | Manual | Plugin ecosystem | Built-in (via rich) | No | Yes |
| **Learning curve** | Low | Medium | Low | Very low | High |
| **Dependencies** | None | click | typer, click, rich | fire | cement |
| **Best for** | Simple tools, no-dependency scripts | Medium-to-large CLIs | Modern CLIs with type safety | Quick prototypes | Enterprise CLIs |

**Recommendation**: Use **typer** for new CLI projects -- it provides the best developer experience with type hints, auto-completion, and rich integration. Use **argparse** when you cannot add dependencies. Use **click** for maximum flexibility and a large plugin ecosystem.

## argparse Patterns

argparse is Python's built-in argument parsing library. It requires no external dependencies.

### Basic Usage

```python
import argparse
import sys


def main() -> int:
    parser = argparse.ArgumentParser(
        prog="mytool",
        description="A tool that does useful things",
        epilog="Example: mytool process --input data.csv --format json",
    )
    parser.add_argument("input", help="Input file path")
    parser.add_argument("-o", "--output", default="-", help="Output file (default: stdout)")
    parser.add_argument("-v", "--verbose", action="store_true", help="Enable verbose output")
    parser.add_argument("--count", type=int, default=1, help="Number of repetitions")

    args = parser.parse_args()

    if args.verbose:
        print(f"Processing {args.input}...")

    return 0


if __name__ == "__main__":
    sys.exit(main())
```

### Subcommands

```python
import argparse


def cmd_init(args: argparse.Namespace) -> int:
    print(f"Initializing project: {args.name}")
    return 0


def cmd_build(args: argparse.Namespace) -> int:
    print(f"Building with config: {args.config}")
    return 0


def main() -> int:
    parser = argparse.ArgumentParser(prog="mytool")
    subparsers = parser.add_subparsers(dest="command", required=True)

    # init subcommand
    init_parser = subparsers.add_parser("init", help="Initialize a new project")
    init_parser.add_argument("name", help="Project name")
    init_parser.set_defaults(func=cmd_init)

    # build subcommand
    build_parser = subparsers.add_parser("build", help="Build the project")
    build_parser.add_argument("-c", "--config", default="release", help="Build config")
    build_parser.set_defaults(func=cmd_build)

    args = parser.parse_args()
    return args.func(args)
```

### Mutually Exclusive Groups

```python
parser = argparse.ArgumentParser()
group = parser.add_mutually_exclusive_group(required=True)
group.add_argument("--json", action="store_true", help="Output as JSON")
group.add_argument("--csv", action="store_true", help="Output as CSV")
group.add_argument("--table", action="store_true", help="Output as table")
```

### Custom Types

```python
import argparse
from pathlib import Path


def existing_path(value: str) -> Path:
    path = Path(value)
    if not path.exists():
        raise argparse.ArgumentTypeError(f"Path does not exist: {value}")
    return path


def port_number(value: str) -> int:
    port = int(value)
    if not (1 <= port <= 65535):
        raise argparse.ArgumentTypeError(f"Invalid port: {value} (must be 1-65535)")
    return port


parser = argparse.ArgumentParser()
parser.add_argument("--config", type=existing_path, help="Config file path")
parser.add_argument("--port", type=port_number, default=8080, help="Server port")
```

## Click Deep Dive

Click is a mature, decorator-based framework for building CLIs. It emphasizes composability and testability.

### Installation

```bash
pip install click
```

### Basic Command

```python
import click


@click.command()
@click.argument("name")
@click.option("--greeting", "-g", default="Hello", help="The greeting to use")
@click.option("--count", "-c", default=1, type=int, help="Number of greetings")
@click.option("--verbose", "-v", is_flag=True, help="Enable verbose output")
def greet(name: str, greeting: str, count: int, verbose: bool) -> None:
    """Greet someone NAME times."""
    if verbose:
        click.echo(f"Greeting {name} {count} time(s)...")
    for _ in range(count):
        click.echo(f"{greeting}, {name}!")


if __name__ == "__main__":
    greet()
```

### Command Groups (Subcommands)

```python
import click


@click.group()
@click.option("--debug/--no-debug", default=False, help="Enable debug mode")
@click.pass_context
def cli(ctx: click.Context, debug: bool) -> None:
    """My multi-command CLI tool."""
    ctx.ensure_object(dict)
    ctx.obj["debug"] = debug


@cli.command()
@click.argument("name")
@click.pass_context
def init(ctx: click.Context, name: str) -> None:
    """Initialize a new project."""
    if ctx.obj["debug"]:
        click.echo("Debug mode is on")
    click.echo(f"Initializing {name}...")


@cli.command()
@click.option("--config", "-c", default="release", help="Build configuration")
@click.pass_context
def build(ctx: click.Context, config: str) -> None:
    """Build the project."""
    click.echo(f"Building with config: {config}")


if __name__ == "__main__":
    cli()
```

### Context and State Passing

```python
import click


class AppConfig:
    def __init__(self, debug: bool = False, output_format: str = "text"):
        self.debug = debug
        self.output_format = output_format


pass_config = click.make_pass_decorator(AppConfig, ensure=True)


@click.group()
@click.option("--debug/--no-debug", default=False)
@click.option("--format", "output_format", type=click.Choice(["text", "json", "csv"]))
@click.pass_context
def cli(ctx: click.Context, debug: bool, output_format: str) -> None:
    ctx.obj = AppConfig(debug=debug, output_format=output_format or "text")


@cli.command()
@pass_config
def status(config: AppConfig) -> None:
    """Show status."""
    if config.output_format == "json":
        click.echo('{"status": "ok"}')
    else:
        click.echo("Status: OK")
```

### File Handling

```python
import click


@click.command()
@click.argument("input_file", type=click.File("r"))
@click.argument("output_file", type=click.File("w"), default="-")  # default to stdout
def process(input_file, output_file) -> None:
    """Process INPUT_FILE and write to OUTPUT_FILE."""
    for line in input_file:
        output_file.write(line.upper())


@click.command()
@click.argument("directory", type=click.Path(exists=True, file_okay=False, resolve_path=True))
def scan(directory: str) -> None:
    """Scan a DIRECTORY for files."""
    click.echo(f"Scanning: {directory}")
```

### Testing with CliRunner

```python
from click.testing import CliRunner
from myapp.cli import cli


def test_init_command():
    runner = CliRunner()
    result = runner.invoke(cli, ["init", "myproject"])
    assert result.exit_code == 0
    assert "Initializing myproject" in result.output


def test_build_with_debug():
    runner = CliRunner()
    result = runner.invoke(cli, ["--debug", "build", "--config", "debug"])
    assert result.exit_code == 0


def test_file_processing():
    runner = CliRunner()
    with runner.isolated_filesystem():
        with open("input.txt", "w") as f:
            f.write("hello\nworld\n")
        result = runner.invoke(process, ["input.txt", "output.txt"])
        assert result.exit_code == 0
        with open("output.txt") as f:
            assert f.read() == "HELLO\nWORLD\n"
```

### Useful Click Decorators and Features

```python
import click

# Password prompt (hidden input)
@click.command()
@click.option("--password", prompt=True, hide_input=True, confirmation_prompt=True)
def set_password(password: str) -> None:
    click.echo("Password set.")

# Choice type
@click.command()
@click.option("--color", type=click.Choice(["red", "green", "blue"], case_sensitive=False))
def paint(color: str) -> None:
    click.echo(f"Painting {color}")

# Range type
@click.command()
@click.option("--count", type=click.IntRange(1, 100), default=10)
def repeat(count: int) -> None:
    click.echo(f"Repeating {count} times")

# Progress bar
@click.command()
def download() -> None:
    items = range(1000)
    with click.progressbar(items, label="Downloading") as bar:
        for item in bar:
            pass  # do work

# Confirmation
@click.command()
@click.confirmation_option(prompt="Are you sure you want to delete everything?")
def delete_all() -> None:
    click.echo("Deleted everything.")
```

## Typer Deep Dive

Typer builds on click but uses Python type hints to define CLI interfaces. It produces beautiful help text and integrates with rich out of the box.

### Installation

```bash
pip install typer

# With all optional dependencies (rich, shellingham)
pip install "typer[all]"
```

### Basic Command

```python
import typer


def main(
    name: str,
    greeting: str = "Hello",
    count: int = 1,
    verbose: bool = False,
) -> None:
    """Greet someone by NAME."""
    if verbose:
        typer.echo(f"Greeting {name} {count} time(s)...")
    for _ in range(count):
        typer.echo(f"{greeting}, {name}!")


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

### Multi-Command Application

```python
import typer
from typing import Annotated, Optional
from enum import Enum
from pathlib import Path

app = typer.Typer(help="My awesome CLI tool.")


class OutputFormat(str, Enum):
    text = "text"
    json = "json"
    csv = "csv"


@app.command()
def init(
    name: Annotated[str, typer.Argument(help="Project name")],
    template: Annotated[str, typer.Option("--template", "-t", help="Template to use")] = "default",
    force: Annotated[bool, typer.Option("--force", "-f", help="Overwrite existing")] = False,
) -> None:
    """Initialize a new project."""
    if force:
        typer.echo(f"Force-creating {name} with template {template}")
    else:
        typer.echo(f"Creating {name} with template {template}")


@app.command()
def build(
    config: Annotated[str, typer.Option(help="Build configuration")] = "release",
    output_dir: Annotated[Path, typer.Option("--output", "-o", help="Output directory")] = Path("dist"),
    format: Annotated[OutputFormat, typer.Option(help="Output format")] = OutputFormat.text,
) -> None:
    """Build the project."""
    typer.echo(f"Building [{config}] -> {output_dir} (format: {format.value})")


@app.command()
def clean(
    all: Annotated[bool, typer.Option("--all", help="Remove all artifacts")] = False,
) -> None:
    """Clean build artifacts."""
    if all:
        confirmed = typer.confirm("Remove ALL artifacts including caches?")
        if not confirmed:
            raise typer.Abort()
    typer.echo("Cleaned.")


if __name__ == "__main__":
    app()
```

### Annotated Parameters (Recommended Pattern)

```python
from typing import Annotated
import typer

app = typer.Typer()


@app.command()
def deploy(
    # Required argument
    environment: Annotated[str, typer.Argument(help="Target environment")],
    # Optional with short flag
    tag: Annotated[str, typer.Option("--tag", "-t", help="Image tag")] = "latest",
    # Boolean flag
    dry_run: Annotated[bool, typer.Option("--dry-run", help="Preview without deploying")] = False,
    # Prompt for input
    token: Annotated[str, typer.Option(prompt=True, hide_input=True, help="Auth token")] = ...,
    # Enum choice
    region: Annotated[
        str,
        typer.Option(help="Cloud region", click_type=typer.Choice(["us-east-1", "eu-west-1", "ap-south-1"]))
    ] = "us-east-1",
) -> None:
    """Deploy the application to a target environment."""
    if dry_run:
        typer.echo(f"[DRY RUN] Would deploy {tag} to {environment} in {region}")
    else:
        typer.echo(f"Deploying {tag} to {environment} in {region}...")
```

### Auto-Completion

```bash
# Generate completion script
my-cli --install-completion bash
my-cli --install-completion zsh
my-cli --install-completion fish

# Show completion script without installing
my-cli --show-completion bash
```

### Typer Testing

Typer uses Click's `CliRunner` under the hood:

```python
from typer.testing import CliRunner
from myapp.cli import app

runner = CliRunner()


def test_init():
    result = runner.invoke(app, ["init", "myproject"])
    assert result.exit_code == 0
    assert "Creating myproject" in result.output


def test_init_with_force():
    result = runner.invoke(app, ["init", "myproject", "--force"])
    assert result.exit_code == 0
    assert "Force-creating" in result.output


def test_build_json_format():
    result = runner.invoke(app, ["build", "--format", "json"])
    assert result.exit_code == 0
    assert "json" in result.output


def test_clean_aborted():
    result = runner.invoke(app, ["clean", "--all"], input="n\n")
    assert result.exit_code == 1  # Aborted
```

### Nested Command Groups (Sub-Applications)

```python
import typer

app = typer.Typer()
users_app = typer.Typer(help="User management commands.")
app.add_typer(users_app, name="users")


@users_app.command("list")
def list_users() -> None:
    """List all users."""
    typer.echo("user1\nuser2\nuser3")


@users_app.command("create")
def create_user(name: str, email: str) -> None:
    """Create a new user."""
    typer.echo(f"Created user {name} ({email})")


# Usage: mycli users list
#        mycli users create "Jane" "jane@example.com"
```

## Rich Library

Rich is a library for beautiful terminal output: colors, tables, progress bars, syntax highlighting, markdown rendering, and more.

### Installation

```bash
pip install rich
```

### Console Markup

```python
from rich.console import Console

console = Console()

# Styled text
console.print("[bold red]Error:[/bold red] Something went wrong")
console.print("[green]Success![/green] Operation completed.")
console.print("[dim italic]This is dimmed and italic[/dim italic]")

# Emoji
console.print(":rocket: Launching...")

# Links
console.print("Visit [link=https://example.com]our website[/link]")

# Print with highlight (auto-detects and highlights numbers, strings, etc.)
console.print({"name": "Alice", "age": 30, "active": True})
```

### Tables

```python
from rich.console import Console
from rich.table import Table

console = Console()

table = Table(title="Deployment Status")
table.add_column("Service", style="cyan", no_wrap=True)
table.add_column("Version", style="magenta")
table.add_column("Status", justify="center")
table.add_column("Uptime", justify="right", style="green")

table.add_row("api-gateway", "2.3.1", "[green]Healthy[/green]", "14d 3h")
table.add_row("auth-service", "1.8.0", "[green]Healthy[/green]", "14d 3h")
table.add_row("worker", "3.0.2", "[red]Degraded[/red]", "2h 15m")
table.add_row("database", "16.1", "[green]Healthy[/green]", "30d 12h")

console.print(table)
```

### Progress Bars

```python
import time
from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, TaskProgressColumn

# Simple progress bar
from rich.progress import track

for item in track(range(100), description="Processing..."):
    time.sleep(0.02)

# Advanced multi-task progress
with Progress(
    SpinnerColumn(),
    TextColumn("[progress.description]{task.description}"),
    BarColumn(),
    TaskProgressColumn(),
) as progress:
    download_task = progress.add_task("Downloading...", total=1000)
    install_task = progress.add_task("Installing...", total=500)

    while not progress.finished:
        progress.update(download_task, advance=5)
        progress.update(install_task, advance=2)
        time.sleep(0.01)
```

### Panels, Trees, and Columns

```python
from rich.console import Console
from rich.panel import Panel
from rich.tree import Tree
from rich.columns import Columns

console = Console()

# Panel
console.print(Panel("This is important information", title="Notice", border_style="yellow"))

# Tree
tree = Tree("[bold]Project Structure[/bold]")
src = tree.add("[folder]src/")
src.add("[file]main.py")
src.add("[file]config.py")
utils = src.add("[folder]utils/")
utils.add("[file]helpers.py")
tests = tree.add("[folder]tests/")
tests.add("[file]test_main.py")
console.print(tree)

# Columns
data = [Panel(f"Item {i}", expand=True) for i in range(12)]
console.print(Columns(data))
```

### Live Display

```python
import time
from rich.live import Live
from rich.table import Table


def generate_table(step: int) -> Table:
    table = Table()
    table.add_column("Step")
    table.add_column("Status")
    for i in range(step + 1):
        table.add_row(f"Step {i}", "[green]Complete[/green]")
    if step < 5:
        table.add_row(f"Step {step + 1}", "[yellow]Running...[/yellow]")
    return table


with Live(generate_table(0), refresh_per_second=4) as live:
    for step in range(6):
        time.sleep(0.5)
        live.update(generate_table(step))
```

### Syntax Highlighting and Logging

```python
from rich.console import Console
from rich.syntax import Syntax
import logging
from rich.logging import RichHandler

console = Console()

# Syntax highlighting
code = '''
def fibonacci(n: int) -> int:
    if n <= 1:
        return n
    return fibonacci(n - 1) + fibonacci(n - 2)
'''
syntax = Syntax(code, "python", theme="monokai", line_numbers=True)
console.print(syntax)

# Rich logging handler
logging.basicConfig(
    level=logging.DEBUG,
    format="%(message)s",
    handlers=[RichHandler(rich_tracebacks=True)],
)
log = logging.getLogger("myapp")
log.info("Application started")
log.warning("Low disk space")
log.error("Connection failed")
```

## Textual for TUI Applications

Textual is a framework for building terminal user interfaces (TUIs) with a CSS-like styling system and reactive widget model.

### Installation

```bash
pip install textual

# With dev tools (for live CSS editing)
pip install "textual[dev]"
```

### Basic Application

```python
from textual.app import App, ComposeResult
from textual.containers import Horizontal, Vertical
from textual.widgets import Header, Footer, Static, Button, Input, DataTable


class MyApp(App):
    """A simple Textual application."""

    CSS = """
    Screen {
        layout: vertical;
    }
    #sidebar {
        width: 30;
        background: $surface;
        padding: 1;
    }
    #main {
        width: 1fr;
        padding: 1;
    }
    Button {
        margin: 1 0;
    }
    """

    BINDINGS = [
        ("q", "quit", "Quit"),
        ("d", "toggle_dark", "Toggle Dark Mode"),
    ]

    def compose(self) -> ComposeResult:
        yield Header()
        with Horizontal():
            with Vertical(id="sidebar"):
                yield Static("Navigation", classes="title")
                yield Button("Dashboard", id="btn-dashboard", variant="primary")
                yield Button("Settings", id="btn-settings")
                yield Button("Logs", id="btn-logs")
            with Vertical(id="main"):
                yield Static("Welcome to MyApp", id="content")
        yield Footer()

    def on_button_pressed(self, event: Button.Pressed) -> None:
        content = self.query_one("#content", Static)
        content.update(f"You clicked: {event.button.id}")

    def action_toggle_dark(self) -> None:
        self.dark = not self.dark


if __name__ == "__main__":
    MyApp().run()
```

### Widgets

```python
from textual.app import App, ComposeResult
from textual.widgets import DataTable, Input, ListView, ListItem, Label, RichLog


class DataApp(App):
    def compose(self) -> ComposeResult:
        yield Input(placeholder="Search...", id="search")
        yield DataTable()
        yield RichLog(id="log")

    def on_mount(self) -> None:
        table = self.query_one(DataTable)
        table.add_columns("Name", "Role", "Status")
        table.add_rows([
            ("Alice", "Engineer", "Active"),
            ("Bob", "Designer", "Away"),
            ("Carol", "Manager", "Active"),
        ])

    def on_input_changed(self, event: Input.Changed) -> None:
        log = self.query_one("#log", RichLog)
        log.write(f"Search: {event.value}")
```

### Reactive Attributes

```python
from textual.app import App, ComposeResult
from textual.reactive import reactive
from textual.widgets import Static


class Counter(Static):
    count: reactive[int] = reactive(0)

    def render(self) -> str:
        return f"Count: {self.count}"

    def watch_count(self, new_value: int) -> None:
        if new_value > 10:
            self.styles.color = "red"

    def on_click(self) -> None:
        self.count += 1
```

### CSS Styling

Textual uses a CSS-like language for styling. Styles can be defined inline, in the `CSS` class variable, or in external `.tcss` files.

```python
class MyApp(App):
    CSS_PATH = "styles.tcss"  # Load from external file
```

```css
/* styles.tcss */
Screen {
    layout: horizontal;
}

#sidebar {
    width: 25%;
    background: $surface;
    border-right: solid $primary;
    padding: 1 2;
}

#main {
    width: 75%;
    padding: 1 2;
}

Button {
    width: 100%;
    margin: 1 0;
}

Button:hover {
    background: $primary-lighten-2;
}

DataTable > .datatable--header {
    background: $primary;
    color: $text;
}
```

## Output Formatting

### JSON Output Mode

Provide a `--json` flag for machine-readable output:

```python
import json
import typer
from typing import Annotated

app = typer.Typer()


@app.command()
def status(
    json_output: Annotated[bool, typer.Option("--json", help="Output as JSON")] = False,
) -> None:
    """Show system status."""
    data = {
        "status": "healthy",
        "services": [
            {"name": "api", "status": "up", "latency_ms": 42},
            {"name": "db", "status": "up", "latency_ms": 5},
        ],
        "version": "1.2.0",
    }

    if json_output:
        typer.echo(json.dumps(data, indent=2))
    else:
        from rich.console import Console
        from rich.table import Table

        console = Console()
        console.print(f"[bold]Status:[/bold] {data['status']}")
        table = Table()
        table.add_column("Service")
        table.add_column("Status")
        table.add_column("Latency")
        for svc in data["services"]:
            table.add_row(svc["name"], svc["status"], f"{svc['latency_ms']}ms")
        console.print(table)
```

### Color/No-Color Support

Respect the `NO_COLOR` environment variable (see https://no-color.org):

```python
import os
from rich.console import Console

# Rich automatically respects NO_COLOR
console = Console(no_color=os.environ.get("NO_COLOR") is not None)

# Or force no color via CLI flag
import typer

app = typer.Typer()

@app.callback()
def main(no_color: bool = False) -> None:
    if no_color:
        os.environ["NO_COLOR"] = "1"
```

### Table Formatting with tabulate

```python
from tabulate import tabulate

data = [
    ["api-gateway", "2.3.1", "Healthy"],
    ["auth-service", "1.8.0", "Healthy"],
    ["worker", "3.0.2", "Degraded"],
]
headers = ["Service", "Version", "Status"]

# Multiple output formats
print(tabulate(data, headers=headers, tablefmt="grid"))
print(tabulate(data, headers=headers, tablefmt="github"))
print(tabulate(data, headers=headers, tablefmt="plain"))
```

## Exit Codes and Error Handling

### Standard Exit Codes

```python
import sys

EXIT_SUCCESS = 0
EXIT_GENERAL_ERROR = 1
EXIT_USAGE_ERROR = 2        # Invalid arguments
EXIT_NOT_FOUND = 3          # Resource not found
EXIT_PERMISSION_DENIED = 4  # Insufficient permissions


def main() -> int:
    try:
        result = do_work()
        return EXIT_SUCCESS
    except FileNotFoundError as e:
        print(f"Error: {e}", file=sys.stderr)
        return EXIT_NOT_FOUND
    except PermissionError as e:
        print(f"Error: {e}", file=sys.stderr)
        return EXIT_PERMISSION_DENIED
    except Exception as e:
        print(f"Error: {e}", file=sys.stderr)
        return EXIT_GENERAL_ERROR


if __name__ == "__main__":
    sys.exit(main())
```

### Error Handling in Typer

```python
import typer
from rich.console import Console

app = typer.Typer()
err_console = Console(stderr=True)


@app.command()
def deploy(environment: str) -> None:
    """Deploy to an environment."""
    valid_envs = {"dev", "staging", "production"}
    if environment not in valid_envs:
        err_console.print(f"[red]Error:[/red] Unknown environment '{environment}'")
        err_console.print(f"Valid environments: {', '.join(sorted(valid_envs))}")
        raise typer.Exit(code=2)

    if environment == "production":
        confirmed = typer.confirm("Deploy to PRODUCTION?", abort=True)

    typer.echo(f"Deploying to {environment}...")
```

### Error Handling in Click

```python
import click
import sys


class AppError(click.ClickException):
    """Custom error with colored output."""

    def format_message(self) -> str:
        return f"Error: {self.message}"


@click.command()
@click.argument("config_path", type=click.Path(exists=True))
def run(config_path: str) -> None:
    try:
        load_config(config_path)
    except ValueError as e:
        raise AppError(str(e))
    except Exception as e:
        click.echo(f"Fatal: {e}", err=True)
        sys.exit(1)
```

## CLI Testing Strategies

### Testing with pytest and CliRunner

```python
import pytest
from typer.testing import CliRunner
from myapp.cli import app

runner = CliRunner()


class TestInitCommand:
    def test_basic_init(self):
        result = runner.invoke(app, ["init", "myproject"])
        assert result.exit_code == 0
        assert "myproject" in result.output

    def test_init_with_template(self):
        result = runner.invoke(app, ["init", "myproject", "--template", "fastapi"])
        assert result.exit_code == 0
        assert "fastapi" in result.output

    def test_init_invalid_name(self):
        result = runner.invoke(app, ["init", ""])
        assert result.exit_code != 0

    def test_help_text(self):
        result = runner.invoke(app, ["init", "--help"])
        assert result.exit_code == 0
        assert "Initialize" in result.output


class TestOutputFormats:
    def test_json_output(self):
        result = runner.invoke(app, ["status", "--json"])
        assert result.exit_code == 0
        import json
        data = json.loads(result.output)
        assert "status" in data

    def test_text_output(self):
        result = runner.invoke(app, ["status"])
        assert result.exit_code == 0
        assert "Status:" in result.output
```

### Testing with Filesystem Isolation

```python
from click.testing import CliRunner
from myapp.cli import app


def test_file_creation():
    runner = CliRunner()
    with runner.isolated_filesystem():
        result = runner.invoke(app, ["init", "testproject"])
        assert result.exit_code == 0

        # Verify files were created
        from pathlib import Path
        assert Path("testproject/pyproject.toml").exists()
        assert Path("testproject/src").is_dir()
```

### Testing with Environment Variables

```python
def test_respects_no_color():
    runner = CliRunner()
    result = runner.invoke(app, ["status"], env={"NO_COLOR": "1"})
    assert result.exit_code == 0
    # Verify no ANSI escape codes in output
    assert "\033[" not in result.output
```

### Snapshot Testing

```python
import pytest
from syrupy.assertion import SnapshotAssertion
from typer.testing import CliRunner
from myapp.cli import app

runner = CliRunner()


def test_help_output(snapshot: SnapshotAssertion):
    result = runner.invoke(app, ["--help"])
    assert result.output == snapshot
```

## Best Practices

1. **Write `--help` for everything**. Every command and option should have a clear help string. Users will read it before your docs.

2. **Support `--json` output**. Machine-readable output makes your CLI composable with other tools (`jq`, scripts, CI pipelines).

3. **Write to stderr for diagnostics**. Use `stderr` for progress, warnings, and errors. Reserve `stdout` for data output:
   ```python
   import sys
   print("data output")              # stdout -- pipeable
   print("Warning: ...", file=sys.stderr)  # stderr -- visible but not piped
   ```

4. **Use exit codes consistently**. Return 0 for success, non-zero for errors. Document your exit codes.

5. **Respect `NO_COLOR`**. Check the `NO_COLOR` environment variable and disable colors/formatting when set.

6. **Test with CliRunner**. Both Click and Typer provide `CliRunner` for testing without spawning subprocesses.

7. **Add shell completion**. Typer generates it for free. For Click, use `click-completion` or `click`'s built-in support.

8. **Use `Annotated` parameters in Typer** (not positional defaults) for clearer, more maintainable code.

9. **Provide `--verbose` and `--quiet` flags**. Let users control output verbosity. Consider using Python's `logging` module with configurable levels.

10. **Version flag**. Always provide `--version`:
    ```python
    from importlib.metadata import version

    app = typer.Typer()

    def version_callback(value: bool) -> None:
        if value:
            typer.echo(f"myapp {version('my-package')}")
            raise typer.Exit()

    @app.callback()
    def main(
        version: Annotated[
            bool, typer.Option("--version", callback=version_callback, is_eager=True)
        ] = False,
    ) -> None:
        pass
    ```

