Help us improve
Share bugs, ideas, or general feedback.
From aeo-python
Engineer production-grade Python CLI tools with UV for package management, Ruff for linting, Pyright for strict typing, Typer for commands, and Rich for polished output. Addresses fail-fast patterns, pydantic-settings configuration, modular code organization, and professional UX conventions. Apply when creating admin utilities, data processors, or developer tooling.
npx claudepluginhub aeyeops/aeo-skill-marketplace --plugin aeo-pythonHow this skill is triggered — by the user, by Claude, or both
Slash command
/aeo-python:python-cli-engineeringThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Modern patterns for building production-grade Python command-line applications.
Guides Python 3.11+ CLI apps with Typer/Rich, pytest test suites, ruff/mypy fixes, pyproject.toml config, portable scripts, and code reviews. Routes to specialist sub-skills.
Provides patterns for building production CLI tools in Python with Typer/Click, featuring parseable JSON output, predictable command structure, and composability for agentic AI workflows.
Guides CLI architecture for Python packages: framework selection (Click, Typer, argparse), layouts (cli.py/cli/ dir), __main__.py delegation, entry points, exit codes, subcommands.
Share bugs, ideas, or general feedback.
Modern patterns for building production-grade Python command-line applications.
Use when building:
Installation:
curl -LsSf https://astral.sh/uv/install.sh | sh # Install UV
uv init my-cli # Initialize project
uv add typer rich # Add dependencies
For complete tool guides, configuration examples, and advanced patterns: See @references/tech-stack.md
# Custom exceptions (core/exceptions.py)
class AppError(Exception):
"""Base exception - let it bubble up."""
pass
# CLI main entry (cli/main.py)
def main() -> None:
"""Main entry point - ONLY catch at top level."""
try:
app()
except AppError as e:
console.print(f"[red]ERROR: {e}[/red]")
raise typer.Exit(code=1) from e
Key Rules:
except Exception: pass or except: return NoneCRITICAL RULE: 500 lines maximum per module
When module approaches 500 lines:
my_cli/
├── pyproject.toml # UV project config
├── .env.example # Template for .env
├── config.yaml # Application defaults
├── Makefile # Development tasks
└── src/
└── my_cli/
├── __init__.py
├── __main__.py # Entry point
├── cli/ # CLI commands
│ ├── __init__.py
│ └── commands.py # Typer app
├── core/ # Business logic
│ ├── __init__.py
│ ├── config.py # pydantic-settings
│ └── exceptions.py
├── db/ # Database layer
│ ├── __init__.py
│ ├── models.py # SQLAlchemy models
│ └── connection.py
└── services/ # External integrations
└── __init__.py
For complete architecture patterns, module organization, and size management: See @references/architecture.md
Pattern: Merge YAML defaults with .env overrides using pydantic-settings
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
)
# Database
db_host: str = "localhost"
db_port: int = 5432
db_name: str
db_user: str
db_password: str # Must be in .env
# API
api_key: str
api_url: str = "https://api.example.com"
settings = Settings() # Fails if db_password or api_key missing
config.yaml (defaults):
db_host: localhost
db_port: 5432
api_url: https://api.example.com
.env (secrets):
DB_PASSWORD=secret
API_KEY=abc123
For complete configuration patterns, validation, and advanced examples: See @references/configuration.md
uv init my-cli
cd my-cli
uv add typer rich pydantic-settings pyyaml
uv add --dev ruff pyright pytest
# src/my_cli/__main__.py
import typer
from rich.console import Console
app = typer.Typer()
console = Console()
@app.command()
def hello(name: str) -> None:
"""Say hello."""
console.print(f"[green]Hello {name}![/green]")
if __name__ == "__main__":
app()
Copy configuration templates:
uv run python -m my_cli hello World
make lint # Run ruff check + format
make type # Run pyright
make test # Run pytest
make check # lint + type + test
make clean # Remove build artifacts
Always run before committing:
make check
Why: Catches type errors, style issues, and test failures early
Build integration: See patterns/make-integration.md - Integrate CLI with Makefiles Multi-method authentication: See patterns/multi-method-auth.md - Support OAuth, TBA, API keys PostgreSQL JSONB: See patterns/postgresql-jsonb.md - Flexible schema with JSONB columns Pydantic flexibility: See patterns/pydantic-flexible.md - Handle dynamic/unknown fields Schema resilience: See patterns/schema-resilience.md - Robust API schema handling Database migrations: See @reference/database-migrations.md - Alembic migration patterns
@app.command()
def sync(
config_file: Path = typer.Option("config.yaml"),
verbose: bool = typer.Option(False, "--verbose", "-v"),
) -> None:
"""Sync data from source."""
settings = Settings(_env_file=".env")
if verbose:
console.print(f"[yellow]Connecting to {settings.db_host}[/yellow]")
# Implementation
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, declarative_base
Base = declarative_base()
engine = create_engine(f"postgresql://{settings.db_user}:{settings.db_password}@{settings.db_host}/{settings.db_name}")
Session = sessionmaker(bind=engine)
from rich.progress import track
for item in track(items, description="Processing..."):
process(item)
class AppError(Exception):
"""Base for all app exceptions."""
pass
class ConfigurationError(AppError):
"""Config missing/invalid."""
pass
class DatabaseError(AppError):
"""DB operation failed."""
pass
class ExternalServiceError(AppError):
"""External API failed."""
pass
@app.command()
def process() -> None:
"""Process data."""
if not settings.api_key:
raise ConfigurationError("API_KEY not set in .env")
try:
response = api.fetch_data()
except Exception as e:
raise ExternalServiceError(f"API fetch failed: {e}") from e
# Process response (let exceptions bubble)
[tool.pyright]
typeCheckingMode = "strict"
pythonVersion = "3.12"
reportMissingTypeStubs = true
reportUnknownMemberType = true
from typing import Any
from collections.abc import Sequence
# Function signatures
def process_items(items: Sequence[dict[str, Any]]) -> list[str]:
return [item["name"] for item in items]
# Optional values
from typing import Optional
def get_value(key: str) -> Optional[str]:
return cache.get(key) # Returns str | None
# Type guards
from typing import TypeGuard
def is_string_list(val: list[Any]) -> TypeGuard[list[str]]:
return all(isinstance(x, str) for x in val)
tests/
├── conftest.py # Fixtures
├── test_cli.py # CLI command tests
├── test_config.py # Config loading tests
└── test_services.py # Service integration tests
from typer.testing import CliRunner
runner = CliRunner()
def test_hello_command():
result = runner.invoke(app, ["hello", "World"])
assert result.exit_code == 0
assert "Hello World" in result.stdout
uv build # Creates wheel + sdist
ls dist/ # my_cli-0.1.0-py3-none-any.whl
uv pip install dist/my_cli-0.1.0-py3-none-any.whl
my-cli hello World # Now available as command
For detailed guides: See the references/ directory
For project templates: See the templates/ directory
End of Skill