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>
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
- One tree, one truth. Dispatch, help, and completion all derive from a single command tree, so they cannot drift.
- Type-safe over any backend. Command code receives its concrete client, no runtime type assertions.
- Drop-in for
evpncandmaglevcwith no functional regression. - Low ceremony. A tree and a
main()in a few lines, not a copied file. - Text or JSON from the same command code.
- 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
WalkMUST 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'sRunin 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;
ValidateSHOULD flag more than one.
FR-2 Completion and help
- FR-2.1
DynamicMUST 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/
?Dynamiccall 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-CMUST abandon the line and continue;Ctrl-DMUST 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;
-coloroverrides;-jsonforces it off.
FR-5 App entry point
- FR-5.1
AppMUST register-colorand-version;-serveronly when a server is configured; and-jsononly when the app opts in (its commands useEmit), so a CLI never advertises a flag it cannot honor. - FR-5.2
AppMUST NOT dial anything; the client is built in a caller'sConnectcallback. - FR-5.3
-versionMUST 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]()/BuilderMUST build the tree without repeating[C]per node, returning plain*Node[C]so builder and literal construction mix. - FR-6.2
ValidateMUST 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; noany-typed clients, no reflection on the client. - NFR-2
Builder,App, andEmitMUST be optional — a caller MUST be able to use struct-literalNodes withShell/Dispatchdirectly. - NFR-3 MUST build for
linuxandopenbsd(SHOULD also for other BSDs and macOS). The OpenBSDreadlinetermios workaround MUST be transparent and a no-op elsewhere. - NFR-4 Core dependencies MUST be only
chzyer/readlineandgolang.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 breakingv2MUST use the/v2suffix. - 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.
Emitwrites toos.Stdout; an injectableio.Writerwould 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.