"""The one module in `core/` that knows about Typer. **Why this is not a violation of the layering.** The invariant D-263 states is that typer/click appear only in `main.py` and `router.py` — and its purpose is that a *service* must never know it was called from a CLI. A shared group class is transport by definition; the alternative is copying the same subclass into every `router.py`, where the copies drift and only some domains end up enumerating their verbs. So the invariant is refined rather than broken: no typer/click in service.py, schemas.py or helpers.py — ever. transport lives in main.py, router.py, and this module. Keep that bound. If something here stops being about *transport*, it belongs somewhere else. """ from __future__ import annotations import typer from typer.core import TyperGroup from tooling.core import console class ReachGroup(TyperGroup): """Shared behaviour for every group in `reach` — the root and each domain. Two things, both of which exist because the primary user is an agent (D-263) rather than a person at a terminal. **No arguments means "what is here?", not "you got it wrong".** Click's `no_args_is_help` prints help and exits **2**, a usage error. But running `reach` or `reach ` bare is the DISCOVERY action — it is how the tool gets learned from nothing — and a caller that branches on exit status would read its own onboarding as a failure. So help is printed and the exit is **0**. This does not weaken D-263's exit-code contract, which governs *failures*; printing a command list is not one. **An unknown name enumerates what exists.** Click's default is `No such command 'x'`, which says you are wrong without saying what would be right — the closed-set gap D-263 measured in pql. The list is already registered on the group, so naming it costs a sort. """ def parse_args(self, ctx: typer.Context, args: list[str]) -> list[str]: if not args and self.no_args_is_help and not ctx.resilient_parsing: console.out(ctx.get_help()) ctx.exit(0) return super().parse_args(ctx, args) def resolve_command(self, ctx: typer.Context, args: list[str]): if args and self.get_command(ctx, args[0]) is None: listed = ", ".join(sorted(self.list_commands(ctx))) ctx.fail(f"unknown command {args[0]!r}\n\nChoose one of: {listed}") return super().resolve_command(ctx, args) # Kept as a name because domain routers read better with it, but there is no # separate behaviour: a domain group IS a reach group. ReachDomainGroup = ReachGroup def domain(name: str, help: str) -> typer.Typer: """Build a domain's Typer app with the house settings applied. Every domain router should use this rather than calling `typer.Typer` directly, so the settings that are easy to forget are not per-router decisions: - `cls=ReachDomainGroup` so unknown verbs enumerate. - `rich_markup_mode=None` — load-bearing, not cosmetic: it keeps `rich` and `pygments` off the import path, and stops typer drawing box-art help even when stdout is a pipe, which would litter hook logs. - `no_args_is_help` so a bare `reach ` says what it can do. Note the caller still needs a `@app.callback()` on the router: Typer collapses a single-command app into a bare command, and without the callback `reach ` fails with "unexpected extra argument". """ return typer.Typer( name=name, help=help, cls=ReachDomainGroup, no_args_is_help=True, add_completion=False, rich_markup_mode=None, )