How to Build a Python CLI Tool That People Actually Use

Build a production-quality Python CLI with Typer, publish it to PyPI, add shell completions, colored output, progress bars, and CI/CD — everything between "it works on my machine" and "10,000 installs."

How to Build a Python CLI Tool That People Actually Use illustration
On this page12 sections

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.

Share this article

Stuck on implementation?

Get private, 1-on-1 help with system design, performance, scaling, or any technical challenge.

Book a Session

Related Production Resources

Course

Free learning tracks

Turn this guide into a structured production engineering path.

Lab

Interactive engineering labs

Practice the same ideas through scenario-based simulators.

Reference

Production cheatsheets

Keep the operational commands and checks nearby.

Glossary

Key terms

Review the vocabulary behind the architecture.

Discussion

Questions, corrections, or production notes? Add them here so other learners can benefit.

Continue Reading

Related practical guides from the same production engineering path.

Open Source 13 min read

Monorepo vs Polyrepo: How to Structure Your Codebase at Scale

Google uses a monorepo with 2 billion lines of code. Netflix uses hundreds of separate repos. Both work. Learn when each approach wins, the tooling that makes monorepos viable (Nx, Turborepo), and how to migrate without losing your mind.

Monorepo Nx
Tutorials 13 min read

Concurrency and Parallelism: Threads, Async, and Multiprocessing in Python

The GIL does not make Python single-threaded — it makes it single-core for CPU work. Learn when to use threading (I/O), asyncio (many connections), and multiprocessing (CPU), with benchmarks showing the real performance difference.

Python Concurrency