Files
settled-reach/tooling/core/cli.py
T
jpmschweitzerandClaude Opus 5 b5beda0df7 feat(config): T-1275 — bare reach is discovery, so it exits 0
Bare `reach` and bare `reach <domain>` printed help and exited 2, Click's
usage-error convention. Running reach with no arguments 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. Now they
exit 0.

D-263's exit-code contract is untouched: it governs failures, and printing a
command list is not one. Verified across the whole matrix, because this change
flirts with the exit-0 trap that record opens with — bare 0, bare domain 0,
--help 0, unknown domain 2, unknown verb 2, real failure 1. All five are now
pinned as a sixth conformance invariant, since an exit code regresses silently
and nothing else would notice. Proven to fail by putting the 2 back.

The implementation also collapses a duplicated class. core/cli.py holds
ReachGroup with both shared behaviours — no-args-prints-help-and-exits-0, and
unknown-name-enumerates — and LazyDomainGroup now extends it instead of
subclassing TyperGroup directly, keeping only the laziness and the
domain-specific wording. The enumeration logic previously existed twice in
slightly different forms, which is how the root and the domains would have
drifted into disagreeing about their own conventions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 15:49:08 +02:00

88 lines
3.6 KiB
Python

"""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 <domain>` 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 <domain>` 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 <domain> <verb>` 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,
)