| name | typer-cli |
| description | Add or modify CLI commands using Typer. Use when: adding new CLI subcommands, wrapping async functions for CLI use, understanding the CLI entrypoint structure, or following the @syncify pattern for async commands. |
Typer CLI
context7: If documentation tools are available, resolve and load the full typer documentation before making any changes to the CLI system:
context7_resolve-library-id: "typer"
context7_query-docs: <resolved-id> "your query here"
Any command or script that must be accessible to users must be exposed through the Typer library.
For full developer documentation see docs/dev/cli.md.
CLI Structure
The CLI is registered in pyproject.toml as a project script pointing to {{cookiecutter.__package_slug}}.cli:app.
The main app is defined in {{cookiecutter.__package_slug}}/cli.py. Domain-specific subcommand groups live in separate {{cookiecutter.__package_slug}}/cli_<domain>.py files and are mounted onto the main app.
Async Commands — @syncify
Typer runs commands synchronously, but this project uses async throughout (database access, HTTP calls, etc.). Use the @syncify decorator from {{cookiecutter.__package_slug}}/cli.py to bridge them:
from {{cookiecutter.__package_slug}}.cli import syncify
@app.command()
@syncify
async def my_command(name: str) -> None:
"""This async function will run correctly from the CLI."""
result = await some_async_operation(name)
typer.echo(result)
Critical: @app.command() must appear before @syncify in decorator order. Do not use asyncio.run() directly — syncify handles the event loop correctly.
{%- if cookiecutter.include_sqlalchemy == "y" %}
Database Access in CLI Commands
Use get_session from {{cookiecutter.__package_slug}}.services.db for database-backed commands. Always use it as an async context manager:
from {{cookiecutter.__package_slug}}.services.db import get_session
@app.command()
@syncify
async def my_db_command() -> None:
"""Example command using database access."""
async with get_session() as session:
result = await session.execute(select(MyModel))
items = result.scalars().all()
for item in items:
typer.echo(item.name)
{%- endif %}
Parameter Pattern
Use Annotated[] for all arguments and options — it keeps the signature clean and is the Typer-recommended style:
import typer
from typing import Annotated
app = typer.Typer()
@app.command()
def process(
input_file: Annotated[str, typer.Argument(help="Path to the input file")],
output_file: Annotated[str | None, typer.Option(help="Path to the output file")] = None,
verbose: Annotated[bool, typer.Option("--verbose", "-v", help="Enable verbose output")] = False,
) -> None:
"""Process the input file and generate output."""
if verbose:
typer.echo(f"Processing {input_file}...")
typer.echo("Done!")
Arguments vs Options
| Type | Typer class | CLI usage |
|---|
| Positional (required) | typer.Argument() | cmd my-cmd value |
| Named flag/option | typer.Option() | cmd my-cmd --flag x |
| Boolean flag pair | typer.Option(None, "--yes/--no") | cmd my-cmd --yes |
Adding a New Command Group
Create {{cookiecutter.__package_slug}}/cli_<domain>.py:
import typer
from typing import Annotated
reports_app = typer.Typer(
help="Report generation commands.",
no_args_is_help=True,
)
@reports_app.command("generate")
def generate_report(
output: Annotated[str, typer.Argument(help="Output file path")],
) -> None:
"""Generate a report and write it to the output path."""
...
Then mount it in {{cookiecutter.__package_slug}}/cli.py:
from .cli_reports import reports_app
app.add_typer(reports_app, name="reports")
Use no_args_is_help=True on subapp Typer() instances so that cmd reports without arguments shows help rather than an error.
Error Handling Pattern
For error output, write to stderr: typer.echo(..., err=True). Exit with a non-zero code on failure: raise typer.Exit(code=1). For clean exits: raise typer.Exit().
Style Checklist