ADR-005: CLI Framework¶
Status¶
Accepted Date: 2026-02-14 | Amended Date: 2026-10-05 | Amended Date: 2026-10-09
Context¶
Every cosalette application needs a consistent CLI providing at minimum:
--dry-run, --version, --log-level, --log-format, and --env-file. The framework
should provide this CLI scaffolding so that all projects behave identically from the
command line without project authors reimplementing argument parsing.
The CLI choice should align with cosalette's type-hint-driven philosophy — the framework already uses pydantic for configuration (type hints → validated settings) and PEP 544 Protocols for ports (type hints → interface contracts). The CLI framework should continue this pattern: type hints → argument parsing.
Decision¶
Use Typer for CLI scaffolding because its type-hint-driven argument parsing aligns with cosalette's pydantic-settings approach (type-driven configuration everywhere), and Click is pulled in transitively as a dependency anyway.
The framework provides CLI options via Typer, and app.run() handles everything:
$ velux2mqtt --help
Usage: velux2mqtt [OPTIONS]
velux2mqtt v0.1.0 — IoT-to-MQTT bridge powered by cosalette
Options:
--version Show version and exit.
--dry-run Use dry-run adapters (no real hardware).
--log-level TEXT Override log level (DEBUG, INFO, WARNING, ERROR).
--log-format TEXT Override log format (json, text).
--env-file PATH Path to .env file (default: .env).
--help Show this message and exit.
The --dry-run flag is framework-level: it automatically swaps all registered adapters
to their dry-run variants without any project code changes.
Decision Drivers¶
- Type-hint-driven philosophy alignment (pydantic, PEP 544 Protocols, now CLI)
- Consistent CLI across all 8+ projects without per-project implementation
- Framework-level
--dry-runsupport for adapter swapping - Minimal API surface — projects should not need to write CLI code
Considered Options¶
Option 1: argparse (stdlib)¶
Use Python's built-in argparse module.
- Advantages: No dependency, part of the standard library, well-documented.
- Disadvantages: Verbose API for defining arguments. No type-hint-driven parsing. Does not align with the type-driven philosophy of the rest of the framework.
Option 2: Click¶
Use the Click library for CLI creation.
- Advantages: Mature, well-documented, composable commands, widely used.
- Disadvantages: Decorator-heavy argument definition does not leverage type hints. Typer is built on Click and adds the type-hint layer — Click is the lower-level building block.
Option 3: Python Fire¶
Use Google's Fire library for automatic CLI generation from functions/classes.
- Advantages: Zero configuration — generates CLI from any Python object.
- Disadvantages: Too magical — generates CLIs from arbitrary objects, which makes the interface unpredictable. Less control over help text and argument validation. Smaller community than Click/Typer.
Option 4: Typer (chosen)¶
Use Typer for type-hint-driven CLI scaffolding.
- Advantages: Type hints drive argument parsing — aligns with pydantic-settings and PEP 544 Protocols. Built on Click (inherits its maturity and ecosystem). Modern API with excellent auto-completion support. Click is a transitive dependency anyway.
- Disadvantages: Additional dependency (though Click comes transitively). Slightly more opinionated than raw Click.
Decision Matrix¶
| Criterion | argparse | Click | Python Fire | Typer |
|---|---|---|---|---|
| Type-hint alignment | 1 | 2 | 3 | 5 |
| Ecosystem maturity | 5 | 5 | 3 | 4 |
| API simplicity | 2 | 3 | 5 | 5 |
| Auto-completion | 1 | 3 | 2 | 5 |
| Dependency weight | 5 | 3 | 3 | 3 |
Scale: 1 (poor) to 5 (excellent)
Consequences¶
Positive¶
- All cosalette applications get a consistent, professional CLI with zero project-specific code
--dry-runworks across all projects — framework swaps adapters automatically- Type-hint-driven argument parsing maintains philosophical consistency with pydantic and PEP 544 Protocols
- Rich help text and auto-completion support out of the box
Negative¶
- Typer is an additional direct dependency (Click is transitive)
- Projects that need custom CLI commands must learn Typer's API for extension
Amendment (2026-10-05) — Additive¶
Rationale: Typer pulls rich, pygments and markdown-it-py (about 15 MB) into every app image, but they load only for --help and CLI errors. Adopters on 512 MB hosts want to drop them (cos-8jxg.6). Typer has no rich-free distribution: since 0.24 typer-slim is a shim that depends on typer, and typer requires rich. Typer only checks the TYPER_USE_RICH variable, so an image without rich (or without pygments, which rich.syntax imports) crashed on --help and on every usage error.
Additional Sub-Decision: Plain help when rich is not installed¶
Every Typer instance cosalette builds (the app CLI, the schema group and the cosalette package CLI) gets rich_markup_mode=None and pretty_exceptions_enable=False when rich, pygments or markdown-it-py cannot be found (_utils._typer_options()). Help and usage errors then use Click's plain formatter, with the same exit codes. With all three installed nothing changes. cosalette keeps depending on typer, and therefore on rich; apps that want a smaller image exclude rich with a uv override-dependencies entry, documented in the containerize guide.
Additional Considered Options¶
Make Typer an optional extra
Move typer to a cosalette[cli] extra so the default install has no rich.
- Advantages: The default install drops rich, pygments and markdown-it-py without any downstream configuration.
- Disadvantages:
--help,--version, the schema and health subcommands and thecosalettepackage CLI would all need a second implementation or would fail on a default install.; A breaking change for every app for an image-size-only gain.
Additional Positive Consequences¶
- Images can delete rich, pygments and markdown-it-py without breaking --help or CLI error messages.
Additional Negative Consequences¶
- Help output looks different (plain, no panels) in images without rich.
- The default install still contains rich; removing it needs a downstream uv override.
Amendment (2026-10-05) — Additive¶
Rationale: App.cli() built the full Typer CLI for every start, including the schema subcommands, the health probe and the AsyncAPI table formatter, and all of it stayed resident for the life of the process. An adopter measured 2.1 MiB RSS and 34 modules for this on the plain run path, which needs only five options (cos-8jxg.4, cosalette-apps memory footprint proposal, target host Raspberry Pi Zero 2 W).
Additional Sub-Decision: Typer-free run path¶
App.cli() first tries _cli_run.parse_run_args(sys.argv[1:]). It accepts only --dry-run and --log-level, --log-format, --env-file, --config-file (as --opt value or --opt=value, last value wins) with valid log values and no shell-completion variable set. In that case the app runs without importing Typer or Click. Every other argv (a subcommand, --help, --version, --show-devices, completion, an unknown or malformed option, an invalid log level or format) returns None and App.cli() builds the Typer CLI as before. Parity is by delegation: the fast path never produces a usage error itself, so usage messages and exit codes come only from Typer. Both paths share the settings and run helpers in _cli_run, and back-to-back tests compare their results. Typer remains the CLI framework; the fast path is not a second parser surface.
Additional Positive Consequences¶
- A running app no longer keeps Typer, Click, the schema CLI or the AsyncAPI table formatter in memory (about 2 MiB RSS on the adopter's reference app).
Additional Negative Consequences¶
- A new run option must be added to both the Typer callback and
_cli_run.parse_run_args; an option missing from the fast path still works but falls back to Typer, so the omission costs memory, not correctness.
Amendment (2026-10-09) — Corrective¶
Rationale: The first 2026-10-05 amendment tells apps to exclude rich with a uv override-dependencies entry (rich; sys_platform == 'never'). That advice is harmful: uv overrides apply to the whole resolution, including dev dependency groups, so the entry also strips rich from dev tools that need it (pip-audit, cyclonedx, fastmcp, cyclopts). The cosalette-apps size audit (FEP cli-without-rich, cos-6dro) found it in the containerize guide, this ADR and the 0.11.1 what's-new text.
Justification for amendment (not supersession): Only the downstream recipe changes. cosalette's code and its dependency on Typer stay as decided: the plain-help fallback (
_utils._typer_options()) and the Typer-free run path are unchanged, and no app-facing API changes. The faulty advice lives in documentation, not in code, so superseding ADR-005 would split a still-valid record for one sentence of guidance.
Additional Sub-Decision: Slim images skip rich at install time, not at resolution time¶
This replaces the override-dependencies sentence of the first 2026-10-05 amendment. Apps that want an image without rich keep their lockfile unchanged and skip the four packages when they install into the image:
uv sync --frozen --no-dev \
--no-install-package rich --no-install-package pygments \
--no-install-package markdown-it-py --no-install-package mdurl
The flags apply to both uv sync lines of a multi-stage Dockerfile. Nothing is re-resolved, so the lockfile and the dev groups keep rich. uv pip check (and pip check) then reports that typer requires rich; that is the accepted trade-off. Do not use [tool.uv] override-dependencies for this.
The Typer instances cosalette builds need nothing else (_utils._typer_options() falls back to plain output). An app that creates its own typer.Typer() crashes on --help without rich (from rich import box in typer.rich_utils), because Typer checks only the TYPER_USE_RICH variable; such images set ENV TYPER_USE_RICH=0. Verified with typer 0.27.3 and uv 0.6.17. The variable is harmless for apps without their own Typer CLI.
Additional Sub-Decision: Keep Typer; a Click migration is deferred¶
Rebuilding the CLI on Click (decision cos-6dro.3, 2026-10-09) is deferred and Typer stays the CLI framework. Typer vendors Click, so a Click CLI would save only about 0.7 MB beyond dropping rich, which the recipe above already does. The migration touches about 2.5k lines and 255 Typer references in seven modules, would supersede this ADR, and would not help apps that ship their own Typer CLI. Revisit when Typer becomes unmaintained or rich becomes impossible to skip, when an adopter measures a real problem with the remaining Typer overhead, or when a separate dev distribution is reconsidered.
Additional Considered Options¶
uv override-dependencies entry for rich
[tool.uv] override-dependencies = ["rich; sys_platform == 'never'"] in the app's pyproject.toml, as the first 2026-10-05 amendment recommended.
- Advantages: One line of configuration; every
uv syncof the project leaves rich out - Disadvantages: Overrides apply to the whole lock, including dev groups, so dev tools that need rich (pip-audit, cyclonedx, fastmcp, cyclopts) lose it; Changes the lockfile for a concern that only the image build has
uv sync --no-install-package for rich and its dependencies (chosen)
Skip rich, pygments, markdown-it-py and mdurl at install time in the image build.
- Advantages: Lockfile and dev groups unchanged; Confined to the Dockerfile
- Disadvantages:
uv pip checkreports rich as missing; App-owned Typer CLIs also needTYPER_USE_RICH=0
typer-slim
Depend on typer-slim instead of typer. Re-checked on 2026-10-09: the latest release is 0.24.0, a shim that requires typer>=0.24.0 and therefore rich.
- Advantages: Would be a drop-in rename if it were still rich-free
- Disadvantages: Pulls typer and rich anyway, so nothing is saved
Additional Positive Consequences¶
- Slim images drop rich, pygments, markdown-it-py and mdurl without changing the lockfile or breaking dev tools
Additional Negative Consequences¶
uv pip checkin a slim image reports typer's missing rich requirement- Apps with their own Typer CLI must also set
TYPER_USE_RICH=0in the image