Skip to content

ADR 0066: discern --help groups commands by post-processing Cliffy's help

Superseded by ADR 0279. The byte-splice is gone: operatorHelp no longer post-processes Cliffy's rendered help, and the "header/options/examples stay 100% Cliffy" constraint and the byte-identity consequences fell with it — the root help now renders from public command APIs through the design system's terminal context. What survives, decided here: the COMMAND_GROUPS map as the grouping's single source, the operator-first group order, and the forcing-function guard that keeps every visible command grouped.

Vocabulary amendment: Current pointers use finishdone, graduateaccept, integrateupdate; the decision and reasoning are unchanged.

Status: accepted. Restructures the root --help from a flat, registration-ordered command dump into named, ordered groups read from a COMMAND_GROUPS map (src/cli_help.ts), applied by rewriting Cliffy's rendered help rather than by a framework grouping primitive, and pinned by a forcing-function guard in the spirit of ADR 0051.

Context

§

In an already-installed project discern --help lists ~22 top-level verbs in one flat block, in command-registration order. That order is installer-first (upgrade/doctor and the doc browsers register before the engine verbs), so the verbs a person touches every loop — status, prepare, done — scatter below the ones they touch monthly, and the headline pitched scaffolding (setup) even though setup is hidden once the project is bootstrapped. The list reads as a dump, not an operator's map.

The obvious fix — group the commands under headings — runs into the framework. discern's CLI is Cliffy, and Cliffy (1.2.x) does not group commands:

  • .group(name) groups OPTIONS, not commands. Its documented effect is "all options added after .group() are grouped in the help output"; it sets a builder field consumed only by the options renderer. Calling it before a batch of .command()s compiles and runs, but changes nothing in the command list.
  • The default HelpGenerator renders commands flat under one Commands: label, in getCommands() order, and it is not exported from @cliffy/command — its constructor is private and it lives in an internal _-prefixed module. It cannot be extended to teach it command groups, and importing the internal module directly would be a version-pinned reach into a private file, against this repo's no-external-import rule.
  • Registration spans two files — installer verbs in src/main.ts, engine verbs in src/engine/dispatch.ts via attachEngineCommands — so even a registration-order trick would entangle the two and still produce no headings.

A decision was needed: how to get grouped, operator-first command help out of a framework whose help model has no notion of command groups.

Decision

§

Let Cliffy render its canonical help, then post-process the string to rewrite just the command list into named groups. The grouping lives in a COMMAND_GROUPS map (the SSOT); operatorHelp(root) calls the framework's own getHelp() (no custom handler is installed, so there is no recursion), locates the flat Commands: block, and replaces it with sub-sections rendered from the map and the live command objects (getName() + getShortDescription()), then appends a "discern <command> --help for detail" footer.

The explicit nos:

  • Not .group(). Wrong granularity — it groups options. Using it for commands would be a silent no-op.
  • Not a custom HelpHandler over the internal generator. Reaching into Cliffy's internal _help_generator.ts (or reimplementing it) would couple us to a private, versioned surface and risk the root help drifting stylistically from every subcommand's --help.
  • Not a registration reorder. It cannot produce headings, and it would force the two registration sites to coordinate ordering.
  • The header/options/examples stay 100% Cliffy. We only touch the command list. The usage shape (.usage("<command> [options]")), the golden-path example (.example()), and the description are set through Cliffy's first-class APIs; the redundant Version: header row is dropped in the same post-process (--version still reports it).

The post-processor leans on two stable facts about the rendered help, both documented at the call site: the section headings are column-0 lines carrying Commands: / Examples:, and an example is always registered (so Examples: reliably bounds the command block). A visible command with no group still renders, under a defensive Other heading, so a missing assignment is loud rather than vanished.

Consequences

§
  • The help reads as an operator's map. The agentic-loop verbs lead; the worktree lifecycle reads start → update → accept; setup/maintenance and inspect/explore sink. A first-time reader sees the 3–4 commands that matter without parsing all 22, and the group notes signal that the loop and worktree verbs are the agent's to run, not the human's.
  • No stylistic drift. Because the surrounding help is the framework's own output untouched, the root help's header, options, and examples stay byte-identical to every subcommand's --help. Only the command block is ours.
  • The grouping cannot silently rot. COMMAND_GROUPS is a hand-maintained satellite of the command registry, so a forcing-function test (tests/engine_help_groups_test.ts) asserts the visible top-level commands are EXACTLY the grouped set — a new verb with no home, or a stale entry, fails the gate.
  • We own a thin coupling to Cliffy's rendered output. The splice depends on the Commands:/Examples: labels and the always-present example. A future Cliffy that renamed those labels would break the splice — caught by the guard test, which renders operatorHelp and checks the headings — and the fallback is graceful (no Commands: line ⇒ the help passes through unchanged).
  • If Cliffy gains native command groups, revisit. This post-processor is the smallest honest path today; first-class command grouping in the framework would let us delete it and keep COMMAND_GROUPS as the data.
choose openEsc close