The Python ecosystem has thousands of CLI tools. Most of them die at the “it works on my machine” stage. The gap between a working script and a tool people actually install and use is: proper argument parsing, helpful error messages, colored output, documentation, packaging, and distribution. This guide bridges that gap.

Choosing Your Framework: Click vs Typer

Feature Click Typer
Syntax Decorators (@click.command) Type hints (def cmd(name: str))
Learning Curve Medium Low (if you know type hints)
Auto-generated help Yes Yes (better formatting)
Shell completions Plugin required Built-in
Built on Standalone Click (wrapper)
Community Larger, mature Growing fast

Recommendation: Use Typer for new projects. It requires less code, generates better help text, and leverages Python type hints you are already writing.

Project Structure

my-cli-tool/
  src/
    my_cli_tool/
      __init__.py       # Version string
      cli.py            # CLI entry point
      commands/
        __init__.py
        init.py         # 'init' subcommand
        analyze.py      # 'analyze' subcommand
        deploy.py       # 'deploy' subcommand
      core/
        __init__.py
        config.py       # Configuration management
        utils.py        # Shared utilities
  tests/
    test_cli.py
    test_commands.py
  pyproject.toml        # Package metadata and build config
  README.md
  LICENSE

Building the CLI with Typer

# src/my_cli_tool/cli.py
import typer
from typing import Optional
from pathlib import Path
from rich.console import Console
from rich.table import Table

app = typer.Typer(
    name="mytool",
    help="A developer productivity tool for project management.",
    add_completion=True,
)
console = Console()

@app.command()
def init(
    name: str = typer.Argument(..., help="Project name"),
    template: str = typer.Option(
        "default", "--template", "-t",
        help="Project template to use"
    ),
    directory: Path = typer.Option(
        ".", "--dir", "-d",
        help="Target directory"
    ),
    force: bool = typer.Option(
        False, "--force", "-f",
        help="Overwrite existing files"
    ),
):
    """Initialize a new project with the given name and template."""
    target = directory / name

    if target.exists() and not force:
        console.print(f"[red]Error:[/red] Directory '{target}' already exists. Use --force to overwrite.")
        raise typer.Exit(code=1)

    target.mkdir(parents=True, exist_ok=True)
    console.print(f"[green]Created project '{name}' in {target}[/green]")


@app.command()
def analyze(
    path: Path = typer.Argument(".", help="Path to analyze"),
    format: str = typer.Option(
        "table", "--format", "-f",
        help="Output format: table, json, csv"
    ),
    verbose: bool = typer.Option(False, "--verbose", "-v"),
):
    """Analyze a project directory and show statistics."""
    if not path.exists():
        console.print(f"[red]Error:[/red] Path '{path}' does not exist.")
        raise typer.Exit(code=1)

    # Count files by extension
    stats = {}
    for file in path.rglob("*"):
        if file.is_file() and not any(p.startswith('.') for p in file.parts):
            ext = file.suffix or "(no extension)"
            stats[ext] = stats.get(ext, 0) + 1

    if format == "table":
        table = Table(title=f"File Analysis: {path}")
        table.add_column("Extension", style="cyan")
        table.add_column("Count", justify="right", style="green")

        for ext, count in sorted(stats.items(), key=lambda x: -x[1]):
            table.add_row(ext, str(count))

        console.print(table)
    elif format == "json":
        import json
        console.print(json.dumps(stats, indent=2))


@app.command()
def version():
    """Show the current version."""
    from my_cli_tool import __version__
    console.print(f"mytool v{__version__}")


if __name__ == "__main__":
    app()

Adding Rich Output

from rich.progress import track, Progress
from rich.panel import Panel
from rich.syntax import Syntax
import time

@app.command()
def deploy(
    environment: str = typer.Argument(..., help="Target environment"),
    dry_run: bool = typer.Option(False, "--dry-run", help="Show what would happen"),
):
    """Deploy the project to the specified environment."""
    steps = [
        ("Running tests", 2),
        ("Building artifacts", 3),
        ("Uploading to registry", 2),
        ("Deploying to cluster", 4),
        ("Running health checks", 1),
    ]

    if dry_run:
        console.print(Panel(
            "\n".join(f"  [cyan]{step}[/cyan]" for step, _ in steps),
            title="Dry Run - Steps",
            border_style="yellow",
        ))
        return

    with Progress() as progress:
        task = progress.add_task(f"Deploying to {environment}...", total=len(steps))

        for step_name, duration in steps:
            progress.update(task, description=step_name)
            time.sleep(duration * 0.1)  # Simulated work
            progress.advance(task)

    console.print(f"[green]Deployed to {environment} successfully![/green]")

Configuration Management

# src/my_cli_tool/core/config.py
import json
from pathlib import Path
from dataclasses import dataclass, asdict

CONFIG_DIR = Path.home() / ".config" / "mytool"
CONFIG_FILE = CONFIG_DIR / "config.json"

@dataclass
class Config:
    default_template: str = "default"
    auto_format: bool = True
    editor: str = "vim"
    registry_url: str = "https://registry.example.com"

    @classmethod
    def load(cls) -> "Config":
        if CONFIG_FILE.exists():
            data = json.loads(CONFIG_FILE.read_text())
            return cls(**data)
        return cls()

    def save(self):
        CONFIG_DIR.mkdir(parents=True, exist_ok=True)
        CONFIG_FILE.write_text(json.dumps(asdict(self), indent=2))

# CLI command for configuration
@app.command()
def config(
    key: str = typer.Argument(None, help="Config key to get/set"),
    value: str = typer.Argument(None, help="Value to set"),
    list_all: bool = typer.Option(False, "--list", "-l", help="List all config"),
):
    """Get or set configuration values."""
    cfg = Config.load()

    if list_all or key is None:
        for k, v in asdict(cfg).items():
            console.print(f"  [cyan]{k}[/cyan] = {v}")
        return

    if value is None:
        console.print(f"  {key} = {getattr(cfg, key, 'NOT SET')}")
    else:
        setattr(cfg, key, value)
        cfg.save()
        console.print(f"  [green]Set {key} = {value}[/green]")

Packaging with pyproject.toml

# pyproject.toml
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[project]
name = "my-cli-tool"
version = "1.0.0"
description = "A developer productivity tool for project management"
readme = "README.md"
license = {text = "MIT"}
requires-python = ">=3.10"
authors = [
    {name = "Vishal Anand", email = "vishal@example.com"},
]
classifiers = [
    "Development Status :: 4 - Beta",
    "Environment :: Console",
    "Intended Audience :: Developers",
    "License :: OSI Approved :: MIT License",
    "Programming Language :: Python :: 3.10",
    "Programming Language :: Python :: 3.11",
    "Programming Language :: Python :: 3.12",
]
dependencies = [
    "typer>=0.9.0",
    "rich>=13.0.0",
]

[project.optional-dependencies]
dev = [
    "pytest>=7.0",
    "pytest-cov",
    "ruff",
]

[project.scripts]
mytool = "my_cli_tool.cli:app"

[project.urls]
Homepage = "https://github.com/username/my-cli-tool"
Repository = "https://github.com/username/my-cli-tool"
Issues = "https://github.com/username/my-cli-tool/issues"

Testing CLI Commands

# tests/test_cli.py
from typer.testing import CliRunner
from my_cli_tool.cli import app

runner = CliRunner()

def test_version():
    result = runner.invoke(app, ["version"])
    assert result.exit_code == 0
    assert "mytool v" in result.stdout

def test_init_creates_directory(tmp_path):
    result = runner.invoke(app, ["init", "myproject", "--dir", str(tmp_path)])
    assert result.exit_code == 0
    assert (tmp_path / "myproject").exists()

def test_init_fails_if_exists(tmp_path):
    (tmp_path / "myproject").mkdir()
    result = runner.invoke(app, ["init", "myproject", "--dir", str(tmp_path)])
    assert result.exit_code == 1
    assert "already exists" in result.stdout

def test_init_force_overwrites(tmp_path):
    (tmp_path / "myproject").mkdir()
    result = runner.invoke(app, ["init", "myproject", "--dir", str(tmp_path), "--force"])
    assert result.exit_code == 0

def test_analyze_nonexistent_path():
    result = runner.invoke(app, ["analyze", "/nonexistent/path"])
    assert result.exit_code == 1
    assert "does not exist" in result.stdout

def test_analyze_json_format(tmp_path):
    (tmp_path / "test.py").write_text("print('hello')")
    (tmp_path / "test.js").write_text("console.log('hello')")
    result = runner.invoke(app, ["analyze", str(tmp_path), "--format", "json"])
    assert result.exit_code == 0
    assert ".py" in result.stdout

Publishing to PyPI

# Build the package
pip install build
python -m build
# Creates dist/my_cli_tool-1.0.0.tar.gz and dist/my_cli_tool-1.0.0-py3-none-any.whl

# Upload to PyPI (requires PyPI account + API token)
pip install twine
twine upload dist/*

# Users can now install with:
pip install my-cli-tool
mytool --help

CI/CD with GitHub Actions

# .github/workflows/release.yml
name: Release

on:
  push:
    tags: ['v*']

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        python-version: ['3.10', '3.11', '3.12']
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: ${{ matrix.python-version }}
      - run: pip install -e ".[dev]"
      - run: pytest --cov

  publish:
    needs: test
    runs-on: ubuntu-latest
    permissions:
      id-token: write   # Required for trusted publishing
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: '3.12'
      - run: pip install build
      - run: python -m build
      - uses: pypa/gh-action-pypi-publish@release/v1

Shell Completions

# Typer generates shell completions automatically

# Install for bash:
mytool --install-completion bash

# Install for zsh:
mytool --install-completion zsh

# Install for fish:
mytool --install-completion fish

# Users get tab completion for commands, options, and arguments:
# mytool an[TAB] -> mytool analyze
# mytool analyze --f[TAB] -> mytool analyze --format

Distribution Checklist

  • README with install instructions and usage examples (pip install + basic commands)
  • LICENSE file (MIT for maximum adoption)
  • CHANGELOG.md with versioned release notes
  • Helpful error messages with actionable suggestions (not just stack traces)
  • --help on every command with examples (Typer generates this from docstrings)
  • Shell completions (built into Typer)
  • Colored output for readability (Rich library)
  • Exit codes: 0 for success, 1 for user error, 2 for system error
  • CI pipeline: Test on multiple Python versions, auto-publish on tag
  • Trusted publishing on PyPI (no API tokens needed with GitHub Actions OIDC)

Key Takeaways

  • Use Typer for modern CLIs — type hints for argument parsing, automatic help generation, built-in completions
  • Use Rich for output — tables, progress bars, colored text, panels make CLIs professional
  • Test with CliRunner — test CLI commands like functions, assert on exit codes and output
  • Use pyproject.toml for packaging — it replaces setup.py, setup.cfg, and MANIFEST.in
  • Publish with trusted publishing — GitHub Actions OIDC to PyPI, no API tokens to manage
  • Error messages should be actionable — tell users what went wrong AND how to fix it
  • Ship shell completions — they dramatically improve the user experience

The difference between a script and a tool is polish. Argument parsing, error handling, colored output, documentation, and distribution turn your 50-line script into something that gets starred on GitHub and installed by thousands. The tools exist — Typer, Rich, pyproject.toml, GitHub Actions — use them.