9e0a98ed07
Validate(root): optional startup/test check for tree authoring faults — >1 slot child per node, empty word, duplicate sibling words, dead-end node — traversing circular slots without looping (#3). keypress subpackage: WaitForKey(ctx, cancel) cancels a context on any keystroke for watch-style streaming commands, with per-GOOS cbreak (linux TCGETS/TCSETS, BSD TIOCGETA/TIOCSETA) and a non-tty/unsupported fallback that just waits on ctx. Lifts the last OpenBSD-specific bit out of evpnc/maglevc's watch.go (#6). docs: replace PROPOSAL.md with an RFC-2119 design.md (FR/NFR for the library). Example now dogfoods Validate (a unit test) and keypress (a bounded `watch` command). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
159 lines
7.3 KiB
Markdown
159 lines
7.3 KiB
Markdown
<!--
|
|
SPDX-FileCopyrightText: (C) Copyright 2026 Pim van Pelt <pim@ipng.ch>
|
|
SPDX-License-Identifier: Apache-2.0
|
|
-->
|
|
|
|
# golang-cli Design Document
|
|
|
|
| | |
|
|
| --- | --- |
|
|
| **Status** | Describes shipped behavior as of `v1.2.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](https://datatracker.ietf.org/doc/html/rfc2119),
|
|
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`, `-json`, `-version`, and `-server`
|
|
(only when a server is configured).
|
|
- **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 `Node`s 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.
|