| name | typer-cli |
| description | Add or modify CLI commands in Oscilla 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 the mcp_context7 tool is available, resolve and load the full typer documentation before making any changes to the CLI system:
mcp_context7_resolve-library-id: "typer"
mcp_context7_get-library-docs: <resolved-id>
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:
[project.scripts]
oscilla = "oscilla.cli:app"
After installation, the oscilla command is available. The main app is defined in oscilla/cli.py. Domain-specific subcommand groups live in separate oscilla/cli_<domain>.py files and are mounted onto the main app.
Current Command Tree
oscilla
├── game # Launch the interactive TUI game loop
├── validate # Validate game package manifests (also: oscilla validate)
├── version # Print installed version
├── hello # Friendly greeting (development helper)
├── data-path # Print the user data directory path
├── test-data # Install test/development data into the database
└── content # Content authoring subcommands (oscilla/cli_content.py)
├── list # List all manifests of a given kind
├── show # Inspect one manifest with cross-references
├── graph # Visualize world/adventure/dependency graphs
├── trace # Trace all execution paths through an adventure
├── schema # Export JSON Schema for manifest kinds
└── create # Scaffold a new manifest file
The content subapp is defined in oscilla/cli_content.py and mounted in cli.py:
from .cli_content import content_app
app.add_typer(content_app, name="content")
Async Commands — @syncify
Typer runs commands synchronously, but Oscilla uses async throughout (database access, HTTP calls, etc.). Use the @syncify decorator from oscilla/cli.py to bridge them:
from oscilla.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.
Database Access in CLI Commands
Use get_session from oscilla.services.db for database-backed commands. Always use it as an async context manager:
from oscilla.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)
For commands that need database migrations applied first, call migrate_database() at the top of the command (see the game command as a reference).
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!")
@app.command()
@syncify
async def fetch(
url: Annotated[str, typer.Argument(help="URL to fetch data from")],
) -> None:
"""Fetch data from a URL asynchronously."""
data = await fetch_url(url)
typer.echo(data)
Arguments vs Options
| Type | Typer class | CLI usage |
|---|
| Positional (required) | typer.Argument() | oscilla my-cmd value |
| Named flag/option | typer.Option() | oscilla my-cmd --flag x |
| Boolean flag pair | typer.Option("--yes/--no") | oscilla my-cmd --yes |
Output Format Pattern
Many existing commands support --format text|json|yaml. When adding a command that returns structured data, follow this pattern from cli_content.py:
output_format: Annotated[
str,
typer.Option("--format", "-F", help="Output format: text | json | yaml."),
] = "text"
Use _emit_structured_output(data, output_format) (defined in cli_content.py) for JSON/YAML output, and Rich tables/panels for text output.
Adding a New Command Group
Create oscilla/cli_<domain>.py:
import typer
from typing import Annotated
reports_app = typer.Typer(
name="reports",
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 oscilla/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 oscilla reports without arguments shows help rather than an error.
Output: Rich vs typer.echo
- Use
typer.echo() for simple single-line output.
- Use
rich.console.Console and Rich components (tables, panels) for formatted multi-line output — this is the pattern used throughout cli_content.py.
- For error output, write to stderr:
Console(stderr=True) or typer.echo(..., err=True).
- Exit with a non-zero code on failure:
raise SystemExit(1).
Error Handling Pattern
Follow the pattern used in existing commands: print a Rich-formatted error to stderr and raise SystemExit(1):
from rich.console import Console
_err_console = Console(stderr=True)
@app.command()
def my_command(name: str) -> None:
"""..."""
item = lookup(name)
if item is None:
_err_console.print(f"[bold red]{name!r} not found.[/bold red]")
raise SystemExit(1)
typer.echo(item)
Style Checklist