ADR-0013 — cli.py is transport-only; __main__.py is the composition root; split at 500 lines
Context and Problem Statement
The CLI must be fully drivable in tests with in-memory use cases: no filesystem, no network, no Path.home(). It must also be the place real adapters get wired. Those two needs pull in opposite directions unless the transport (argparse + printing) is separated from composition (concrete adapters + ambient env).
This split was also motivated by a real defect: after an earlier split, the [project.scripts] entry point still named cli:main; the composition-root move fixed the wiring for good.
Decision Drivers
cli.runtests must be hermetic; they injectUseCasesbuilt on fakes.- Exactly one module may know the concrete adapter set; otherwise wiring knowledge scatters.
Considered Options
- cli.py both parses and wires
cli.pytransport-only +__main__.pycomposition root
Decision Outcome
Chosen option: "cli.py transport-only + __main__.py composition root", because ambient env/Path.home() inside cli would break hermetic tests; the composition root is the one wiring site.
cli.py holds argparse dispatch and plain-text printing only; it receives a UseCases bundle. __main__.py imports every concrete adapter and builds the bundle from os.environ + Path.home(). cli.py stays a single file until it crosses the 500-line ceiling, then splits into a package at concept boundaries (the measured convention, not a speculative split).
Consequences
New commands add a parser + a _cmd_* handler in cli.py and wire their use case in __main__.build_use_cases, nowhere else. The --json contract and the root guard belong to the transport layer, not the use cases.
Pros and Cons of the Options
| Option | Pro | Con |
|---|---|---|
| cli.py both parses and wires | Fewer modules | Ambient env/Path.home() inside cli breaks hermetic tests; adapter imports leak into the transport |
cli.py transport-only + __main__.py composition root |
cli testable with fakes; single wiring site; import-linter enforces it | __main__'s one ambient read (os.environ, Path.home()) is out of the coverage/mutation floor (justified in-band); build_use_cases itself is covered via test/unit/test_main.py |