Files
golang-cli/docs/design.md
T
pim 496557858d feat(app): make -json opt-in via App.JSON (v1.3.0)
App now registers -json only when App.JSON is true, so a CLI whose
commands do not use cli.Emit never advertises a flag it cannot honor.
Driven by the first real consumer (evpnc), whose commands print text
directly and are not yet converted to Emit. The example opts in
(JSON: true). Backward-additive: existing App users that want -json set
JSON: true.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 22:18:27 +02:00

7.4 KiB

golang-cli Design Document

Status Describes shipped behavior as of v1.3.0
Author Pim van Pelt <pim@ipng.ch>
Last updated 2026-06-05
Audience Contributors, and authors of CLIs built on this library

The key words MUST, MUST NOT, SHOULD, SHOULD NOT, and MAY are used as described in RFC 2119, and are reserved for requirements enforced in code. Plain-language "can"/"will"/ "does" are descriptive, not normative.

Summary

golang-cli builds network-daemon-style CLIs: an interactive shell with tab-completion and ?-help, plus a one-shot mode for scripts, both driven by one declarative command tree. It is the shared core extracted from evpnc (vpp-evpn) and maglevc (vpp-maglev), which had each grown their own copy. It is generic over a client type, and renders command output as colorized text or JSON from the same code.

Goals

  1. One tree, one truth. Dispatch, help, and completion all derive from a single command tree, so they cannot drift.
  2. Type-safe over any backend. Command code receives its concrete client, no runtime type assertions.
  3. Drop-in for evpnc and maglevc with no functional regression.
  4. Low ceremony. A tree and a main() in a few lines, not a copied file.
  5. Text or JSON from the same command code.
  6. Linux and OpenBSD are both first-class.

Non-Goals

  • Not a flag parser (uses stdlib flag) and not a TUI toolkit.
  • Does not own transport: it never dials, connects, or speaks a wire protocol.
  • Defines no command set, no output format — those are the caller's.
  • Holds no state beyond two process-global render switches (color, format).

Functional Requirements

FR-1 Command tree and resolution

  • FR-1.1 The command set MUST be one tree of Node[C]; dispatch, ?-help, and completion MUST all derive from it, with no second command table.
  • FR-1.2 Walk MUST prefer a fixed child over the slot child, matching fixed by exact word then unique prefix.
  • FR-1.3 A unique-prefix token MUST resolve to that child; an ambiguous prefix MUST NOT resolve.
  • FR-1.4 A node MAY both run and have children.
  • FR-1.5 A slot node (Dynamic != nil) MUST capture any token as an argument; captured args MUST reach the matched node's Run in order.
  • FR-1.6 An unconsumable token MUST be returned to the caller and reported as an unknown-command error naming the first bad token, not anchored at the root.
  • FR-1.7 At most one slot child per node is reachable; Validate SHOULD flag more than one.

FR-2 Completion and help

  • FR-2.1 Dynamic MUST receive the args captured by earlier slots, so candidates can depend on tokens already typed.
  • FR-2.2 Completion MUST offer fixed children and slot values that have the partial token as a prefix.
  • FR-2.3 If a confirmed token is unknown, completion MUST offer nothing.
  • FR-2.4 Each completion/? Dynamic call MUST be timeout-bounded (default 1s).
  • FR-2.5 ? MUST list the reachable runnable paths with help and live slot values, without submitting the line.

FR-3 Shell and one-shot

  • FR-3.1 No positional args MUST start the REPL; positional args MUST run one command and exit.
  • FR-3.2 A command MUST be able to stop the REPL by returning ErrQuit.
  • FR-3.3 A REPL command error MUST be printed and MUST NOT stop the loop; a one-shot command error MUST exit non-zero.
  • FR-3.4 Ctrl-C MUST abandon the line and continue; Ctrl-D MUST exit.

FR-4 Output

  • FR-4.1 A command MUST be able to describe one result once and have it rendered as text or JSON per a process-global format (Emit).
  • FR-4.2 In JSON mode the framework MUST marshal the command's machine value; supplying it is the command's responsibility.
  • FR-4.3 With color off or JSON selected, no ANSI escapes MUST be emitted.
  • FR-4.4 Color MUST default on in the shell, off one-shot; -color overrides; -json forces it off.

FR-5 App entry point

  • FR-5.1 App MUST register -color and -version; -server only when a server is configured; and -json only when the app opts in (its commands use Emit), so a CLI never advertises a flag it cannot honor.
  • FR-5.2 App MUST NOT dial anything; the client is built in a caller's Connect callback.
  • FR-5.3 -version MUST print and exit without connecting.
  • FR-5.4 The server address MUST resolve from -server, else the configured env var, else the configured default.

FR-6 Authoring helpers

  • FR-6.1 For[C]()/Builder MUST build the tree without repeating [C] per node, returning plain *Node[C] so builder and literal construction mix.
  • FR-6.2 Validate MUST report: >1 slot child per node, an empty word on a non-root node, duplicate fixed words among siblings, and a node that neither runs nor has children; it MUST handle circular slots without looping.

FR-7 Cancel-on-keypress (keypress subpackage)

  • FR-7.1 A streaming command MUST be able to cancel a context on any keypress.
  • FR-7.2 When stdin is not a terminal, it MUST NOT consume input or cancel spuriously; it MUST just wait on the context.
  • FR-7.3 It MUST use cbreak mode (single keystroke, echo off, output post-processing intact) and MUST restore the terminal on return.

Non-Functional Requirements

  • NFR-1 The library MUST be generic over the client type C; no any-typed clients, no reflection on the client.
  • NFR-2 Builder, App, and Emit MUST be optional — a caller MUST be able to use struct-literal Nodes with Shell/Dispatch directly.
  • NFR-3 MUST build for linux and openbsd (SHOULD also for other BSDs and macOS). The OpenBSD readline termios workaround MUST be transparent and a no-op elsewhere.
  • NFR-4 Core dependencies MUST be only chzyer/readline and golang.org/x/sys — no gRPC, protobuf, or web dependency.
  • NFR-5 Releases MUST be semver tags; the import path is stable across v0/v1; a breaking v2 MUST use the /v2 suffix.
  • NFR-6 Requires Go 1.25+ (generics with methods on generic types).
  • NFR-7 make check (gofmt, vet, golangci-lint, tests) MUST pass before a commit; new behavior SHOULD ship with a server-free test.

Architecture

argv ──▶ App.Run ──┬─ no args ─▶ Shell.Run ─▶ readline loop ─▶ Dispatch ─▶ Node.Run
                   │                              ▲  ▲
                   │                   Completer ─┘  └─ '?' listener
                   └─ args ─────────▶ Dispatch ───────────────────────────▶ Node.Run

Both modes share everything below Dispatch. Completion and ? read the same tree the dispatcher walks (FR-1.1) — that is what keeps them from drifting.

Open Questions

  • Quoted arguments. Tokenizing is strings.Fields, so no argument can contain a space; a shell-style splitter would be needed for quoted values.
  • Output sink. Emit writes to os.Stdout; an injectable io.Writer would make ported commands' output unit-testable. Not needed yet.
  • JSON errors. One-shot errors print as text on stderr even under -json; {"error": "..."} may be worth it once a consumer needs it.