ADR 009 Command line shape - nekrozis/qtogo GitHub Wiki

ADR-009: The command line's shape and its parser

Status: Accepted (2026-10-09).

Context

  • The front end was written before this record: the command tree is data in internal/cli/command.go, the grammar lives in parse.go and options.go, the dispatcher in run.go, and rendering in render.go. The shape was chosen as it was built, and the plan has carried "a record is still owed for that decision" since. It is owed now because list-qt is the first business command and every later one โ€” plan, install, cache โ€” will sit on this parser.
  • The reference tool builds its command line with argparse, re-deriving the grammar in each subcommand. The plan's D2 question was whether to take a third-party framework โ€” Cobra and the like โ€” or hold to the standard library, and it was answered standard library (plan ยง9 D2): the tree-as-data plus a parser/dispatcher split is small, and it keeps the front end dependency-free.
  • Amended 2026-10-09. The first cut of list-qt had made host and target options. With the second business command arriving, decision 7 fixed the family shape โ€” identity positional, modifiers as options โ€” and moved list-qt back onto the form the plan's ยง11 already wrote.

Decision

  1. The standard library, and no CLI framework. The grammar is a few hundred lines that the project can read and test directly. A framework would own the command shape, the help text and the error wording, which are three of this project's contracts โ€” ADR-002's exit codes and JSON envelope, the generated help, and the compatibility rows a divergence needs.

  2. The command tree is data, and it is the whole vocabulary. commandTree in command.go names every business verb; a word that is not in it is an unknown command, never a stub that fails later. Help is generated from the same tree, so it cannot advertise a verb the build does not have.

  3. An option may take a value, written either way. --modules qtcharts and --modules=qtcharts are the same; a flag that takes no value refuses =value (usage.unexpected_value); a value-taking option whose value is missing or is itself an option token is usage.missing_argument. A repeated value-taking option keeps every value in order, which is how an ordered list is written on a command line (--base first, then its mirrors).

  4. Business commands run through a seam. A command asks the Services interface for a result and renders it; the front end never reaches past it into discovery, transport or repository. That is what lets a command be tested with neither a process nor a network, and it is what keeps the order of the repository layers in internal/service rather than in a command.

  5. The output and failure contracts are unchanged from ADR-002. Payloads go to stdout, diagnostics to stderr, --json is the machine shape, and the exit code is classified in one place. A command does not invent a code; it returns the failure it was given.

  6. A listing shows version numbers; the version DTO's JSON is unchanged. A list-qt document carries versions as numbers ("5.15.2", what a user writes in a request), not as the repository's raw tokens ("5152"). That is why the payload is built from Dotted() rather than by marshalling model.Version: a listing is a request-shaped report, not a carrier of the repository's spelling. ADR-001 section 1's rule โ€” a marshalled version carries the raw string โ€” stays as it is, for a document that does carry a version as its source representation.

  7. Identity is positional; modifiers are options. What a command acts on is written positionally, and how it behaves is written as options: list-qt <host> <target>, plan install-qt <host> <target> <version> [arch]. A leaf declares its positional arguments in the tree โ€” name, summary, and whether the argument is optional โ€” and only trailing arguments may be optional. Too few is usage.missing_argument, too many is usage.unexpected_argument, and -- still ends option parsing, so a positional value may begin with a dash. Help renders the same declarations, so the usage line and the arity check cannot drift.

    This is the shape the plan's own ยง11 wrote, and the reference tool's: identity positional, behaviour as flags (git, docker, apt read the same way). The first cut of list-qt made host and target options instead; that was an unrecorded deviation from ยง11, and it is corrected here rather than carried forward, because a second spelling for the same idea would spread to every command that follows.

  8. An option keeps the reference tool's spelling where it has one. An option that names the install directory is -O/--outputdir, not --output, so a script written against the reference tool does not have to learn a second name for the same idea. Identity is positional (7); a modifier that already has a name in the tool this project is compatible with takes that name.

Consequences

  • Adding a command is a node in the tree, a case in the dispatcher, a renderer and a service method โ€” four small edits in one place each, and the shape says where.
  • The parser is replaceable by construction: the grammar is confined to parse.go/options.go, so a future change to how a line is read does not reach the commands.
  • The front end costs no dependency, and its help text cannot drift from the vocabulary because both come from the tree.
  • Two option syntaxes (--name value, --name=value) and a rule that a value cannot be another option token are conventions this record fixes; a later change to them is a change to this decision, not a detail.
  • A command's positional arguments are declared in the same node that names its options, so what a command accepts, and in what order, is data the parser, the arity check and the help all read โ€” the same reason the option set is data.

Alternatives considered

  • A framework (Cobra, urfave/cli). Rejected: it would own the command shape, the help text and the error wording, all of which are contracts here, and it adds a dependency for a grammar small enough to test directly.
  • The flag package. Rejected: it models one command's flags, not a tree of verbs, so subcommands and inherited options would be hand-built around it anyway.
  • Parsing inside each command. Rejected: the grammar would be scattered, and "is this a usage error or a failure" would be decided in as many places as there are commands.
  • Reaching into the layers directly from a command. Rejected: a command would then know the repository walk, could not be tested without a network, and would duplicate the order internal/service keeps in one place.
โš ๏ธ **GitHub.com Fallback** โš ๏ธ