● Tooling & Integrations
cli-builder
Build production-ready CLI tools with Python using Typer, Rich, and modern patterns. Use when creating command-line tools, adding subcommands, building interactive terminal UIs, or scaffolding CLI projects.
cli-builder
Build production-ready Python CLI tools using Typer + Rich with modern patterns.
When to Use
- Creating a new CLI tool from scratch
- Adding commands/subcommands to an existing CLI
- Building interactive terminal UIs with Rich
- Setting up CLI project structure with pyproject.toml entry points
Stack
- Typer >= 0.12 — CLI framework (built on Click)
- Rich >= 13 — Beautiful terminal output (tables, panels, progress bars)
- Python >= 3.11 — For StrEnum,
X | Yunion types, dataclasses
Project Structure
my-tool/
├── my_tool/
│ ├── __init__.py # __version__ = "1.0.0"
│ ├── cli.py # Typer app + commands
│ ├── config.py # Path constants, no side effects at import
│ ├── models.py # Dataclasses + StrEnums only
│ └── [domain modules] # Business logic, separate from CLI
├── tests/
│ ├── conftest.py # Fixtures (tmp dirs, mock data)
│ └── test_*.py # One test file per module
├── pyproject.toml # Entry point: my-tool = "my_tool.cli:app"
└── Makefile # test, check, fmt, install targets
Patterns
App Setup
import typer
from rich.console import Console
app = typer.Typer(name="my-tool", help="What it does", add_completion=False)
sub_app = typer.Typer(help="Manage sub-things")
app.add_typer(sub_app, name="sub")
console = Console()
Default Command (Dashboard)
@app.callback(invoke_without_command=True)
def main(ctx: typer.Context) -> None:
"""Show dashboard when no subcommand given."""
if ctx.invoked_subcommand is not None:
return
_render_dashboard()
Commands with Options
@app.command()
def setup(
yes: bool = typer.Option(False, "--yes", "-y", help="Skip prompts"),
dry_run: bool = typer.Option(False, "--dry-run", help="Preview only"),
) -> None:
"""One-line description shown in --help."""
...
Subcommands
@sub_app.command("list")
def sub_list(
json_output: bool = typer.Option(False, "--json"),
) -> None:
"""List all sub-things."""
if json_output:
console.print(json.dumps(data, indent=2))
return
table = Table(box=box.ROUNDED)
...
Rich Output
from rich.table import Table
from rich.panel import Panel
from rich import box
# Tables
table = Table(title="Results", box=box.ROUNDED)
table.add_column("Name", style="cyan")
table.add_column("Status", justify="center")
table.add_row("item", "✅")
console.print(table)
# Panels
console.print(Panel("content", title="Title", border_style="blue"))
# Section dividers
console.rule("[bold]Section Name[/]")
# Status messages
console.print("[green]✅ Done![/]")
console.print("[red]❌ Failed:[/] reason")
console.print("[yellow]⚠ Warning:[/] details")
console.print("[dim]Skipped: reason[/]")
Dry-Run Support
@dataclass
class DryRunContext:
dry_run: bool = False
ops: list = field(default_factory=list)
def record(self, verb: str, target: str) -> None:
self.ops.append((verb, target))
def render(self, console: Console) -> None:
if not self.ops:
console.print("[dim]Nothing to do.[/]")
return
table = Table(title="Dry-run", box=box.SIMPLE)
table.add_column("Action")
table.add_column("Target")
for verb, target in self.ops:
table.add_row(verb, target)
console.print(table)
JSON Output for Agents
Always support --json on list/status commands so AI agents can consume output:
if json_output:
console.print(json.dumps([asdict(e) for e in entries], indent=2))
return
# else render Rich table
Confirmation Prompts
if not yes:
typer.confirm(f"Install {len(missing)} tools?", default=True, abort=True)
Cross-Platform Support
import sys
def _is_macos() -> bool:
return sys.platform == "darwin"
def _is_linux() -> bool:
return sys.platform.startswith("linux")
def _is_windows() -> bool:
return sys.platform == "win32"
pyproject.toml Entry Point
[project.scripts]
my-tool = "my_tool.cli:app"
Install with: pip install -e . or uv pip install -e .
Anti-Patterns
- Don't put business logic in cli.py — keep it in domain modules
- Don't use
clickdirectly — Typer wraps it with better ergonomics - Don't use
argparse— Typer is strictly better for modern Python - Don't use interactive menus for commands agents need to call — support
--yes - Don't forget
--jsonon list commands — agents need machine-readable output - Don't hardcode paths — use a
config.pymodule withPathconstants
Testing
from typer.testing import CliRunner
from my_tool.cli import app
runner = CliRunner()
def test_install():
result = runner.invoke(app, ["install"])
assert result.exit_code == 0
assert "Done" in result.output
Makefile
.PHONY: test check fmt install
install:
uv sync --all-extras
test:
uv run pytest
check:
uv run ruff check bm/ tests/
uv run mypy bm/
fmt:
uv run ruff format bm/ tests/
uv run ruff check --fix bm/ tests/
check-all: check test