2 Commits

Author SHA1 Message Date
Pim van Pelt 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
Pim van Pelt 9e0a98ed07 feat: Validate + keypress subpackage; RFC-style design.md (v1.2.0)
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>
2026-06-05 22:11:13 +02:00
13 changed files with 544 additions and 264 deletions
+29
View File
@@ -113,6 +113,35 @@ Both are additive conveniences: every builder method returns a plain
`*cli.Node[C]`, so builder and struct-literal construction interoperate, and you
can still drive the lifecycle yourself with `Shell`/`Dispatch` instead of `App`.
`cli.Validate(root)` reports common authoring faults (more than one slot child
under a node, an empty word, duplicate sibling words, a dead-end node). It is
optional; the idiomatic use is a one-line unit test so a malformed tree fails the
build:
```go
func TestTreeValid(t *testing.T) {
if err := cli.Validate(buildTree()); err != nil { t.Fatal(err) }
}
```
## Streaming commands
For a `watch`-style command that streams until interrupted, the
[`keypress`](keypress) subpackage stops it on any keystroke:
```go
func runWatch(ctx context.Context, c Client, _ []string) error {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
go keypress.WaitForKey(ctx, cancel) // any key cancels ctx
stream, _ := c.Watch(ctx, req)
for { ev, err := stream.Recv(); /* returns when ctx is cancelled */ }
}
```
When stdin is not a terminal it simply waits on the context, so piped/one-shot
use never blocks on a keypress.
## Output: color and JSON
A command describes its result **once** and the framework renders it:
+11 -3
View File
@@ -55,6 +55,10 @@ type App[C any] struct {
Root *Node[C]
// Greeting, if set, is printed after the version banner in interactive mode.
Greeting string
// JSON, when true, registers a -json flag that switches Emit to JSON output.
// Leave it false for CLIs whose commands do not (yet) use cli.Emit, so they
// never advertise a flag they cannot honor.
JSON bool
// DefaultServer is the -server default. If both it and ServerEnv are empty,
// no -server flag is registered (local CLI).
@@ -109,7 +113,10 @@ func (a *App[C]) Run(ctx context.Context, argv []string) error {
serverFlag = fs.String("server", defaultServer, usage)
}
color := fs.Bool("color", true, "colorize output (default: on in the shell, off one-shot)")
jsonOut := fs.Bool("json", false, "emit JSON instead of text")
var jsonOut *bool
if a.JSON {
jsonOut = fs.Bool("json", false, "emit JSON instead of text")
}
showVersion := fs.Bool("version", false, "print version and exit")
if a.RegisterFlags != nil {
a.RegisterFlags(fs)
@@ -125,7 +132,8 @@ func (a *App[C]) Run(ctx context.Context, argv []string) error {
fmt.Println(a.versionLine())
return nil
}
if *jsonOut {
jsonMode := jsonOut != nil && *jsonOut
if jsonMode {
SetFormat(FormatJSON)
}
@@ -145,7 +153,7 @@ func (a *App[C]) Run(ctx context.Context, argv []string) error {
if colorExplicit {
colorOn = *color
}
if *jsonOut {
if jsonMode {
colorOn = false
}
SetColor(colorOn)
-260
View File
@@ -1,260 +0,0 @@
<!--
SPDX-FileCopyrightText: (C) Copyright 2026 Pim van Pelt <pim@ipng.ch>
SPDX-License-Identifier: Apache-2.0
-->
# Proposal: extract the command-tree CLI into a reusable `golang-cli` package
Status: draft / for discussion. Nothing in `vpp-evpn` or `vpp-maglev` has been
changed. This document describes how to lift the command-tree CLI out of
`cmd/evpnc` (vpp-evpn) and `cmd/client` (vpp-maglev) into a standalone module at
`~/src/golang-cli`, and re-import it in both with **no functional difference**.
## 1. Why
Both projects carry the same hand-rolled CLI: a declarative command tree from
which dispatch, `?`-help, and tab-completion are all derived, driven by
`chzyer/readline`. The two copies have already drifted (see §3), and a third
copy would just cargo-cult the drift forward. The parser, the dynamic-node
mechanism, and the command registration are genuinely generic — only the gRPC
*client type* and the *tree contents* are app-specific.
## 2. The three components (as you framed them)
| Your name | Concrete artifact today | Reusable? |
|---|---|---|
| "the parsing tree" | `Node` struct + `Walk` / `matchFixedChild` / `findSlotChild` / `expandPaths` (`tree.go`) | **Yes, verbatim** (modulo client type) |
| "dynamic node function registration" | `Node.Dynamic func(ctx, client, args) []string`, resolved in `Candidates` and the `?` listener | **Yes** — this is the only real API-shape decision (see §3.1) |
| "command function registration" | `Node.Run func(ctx, client, args) error`, dispatched by `dispatch` | **Yes, verbatim** |
Everything that *consumes* the tree is also generic and moves with it:
`Completer` (readline `AutoCompleter`), `questionListener` (the `?` key handler),
`dispatch`, `showHelpAt`, `unknownCommandError`, the REPL loop (`runShell`),
`splitTokens` / `splitForCompletion`, and the OpenBSD termios shim
(`shell_term_openbsd.go` / `_default.go`).
What does **not** move — it stays in each app:
- `buildTree()` and every `run*` / `dyn*` function (`commands.go`) — the actual
command set.
- `color.go` — the `formatError` gRPC-status unwrap stays in-app (wired via
`Shell.FormatError`). The generic `label`/`paint`/`colorEnabled` half **moved
into the library** as `cli.Label`/`cli.Paint`/`cli.SetColor` (see `color.go`,
`output.go`).
- `watch.go` (gRPC server-stream consumer) — app/proto-specific. *But* its
generic half — "cancel a context on any keypress" — can optionally move (§4.3).
- `main.go` — flag parsing, gRPC dial, color-mode defaults.
## 3. What differs between the two copies today
The copies are ~95% identical. The differences, and how the library reconciles
them so neither app changes behaviour:
### 3.1 `Dynamic` signature — the one real decision
- `vpp-evpn`: `func(context.Context, pb.EvpnrClient, []string) []string` — the
trailing `[]string` is the args captured by earlier slot nodes, so `<evpn>`
can list the EVPNs *of the `<id>` already typed*.
- `vpp-maglev`: `func(context.Context, grpcapi.MaglevClient) []string` — no args.
The evpn signature is a strict superset. **The library adopts the args-bearing
form.** maglev's `dyn*` functions gain an ignored `_ []string` parameter — a
purely mechanical edit, zero behaviour change.
### 3.2 Client type — solved with generics
Both projects are `go 1.25`, so generics are available. The package is generic
over the client type `C`:
```go
type Node[C any] struct {
Word string
Help string
Dynamic func(context.Context, C, []string) []string // non-nil => slot node
Children []*Node[C]
Run func(context.Context, C, []string) error
}
```
`C` is `pb.EvpnrClient` in evpnc and `grpcapi.MaglevClient` in maglevc. Run/Dynamic
funcs receive the concrete client — **no `any`, no type assertions** in app code.
(Alternative considered: a non-generic `any` client with assertions in every
`run*`. Rejected — it pushes boilerplate and runtime panics into the apps. See §7.)
### 3.3 Cosmetic drift the library erases (all no-ops behaviourally)
- `Candidates` arg order: evpnc `(ctx, client, root, tokens, partial)` vs maglev
`(root, tokens, partial, ctx, client)`. Library picks the evpnc order.
- evpnc's `Candidates` passes captured `args` to `Dynamic`; maglev discards them
(`node, _, remaining`). Library passes them (superset; maglev's funcs ignore).
- `RunShell`: evpnc calls `applyTermFuncs(cfg)` (OpenBSD fix), maglev does not.
Library always calls it — it's a no-op off OpenBSD, so maglev gains the fix
with no change on Linux/macOS.
- `splitTokens` lives in `complete.go` (maglev) vs `complete.go` (evpnc) — same
body; library exports one `SplitTokens`.
- The `?` listener's partial-token branch carries different comments but identical
logic; unified.
## 4. Proposed package shape
Module: `git.ipng.ch/ipng/golang-cli` (matches your git host). Single package
`cli` to start; the optional keypress helper can be a subpackage.
```
~/src/golang-cli/
go.mod module git.ipng.ch/ipng/golang-cli, go 1.25
go.sum
LICENSE Apache-2.0 (matches the SPDX headers already in the files)
README.md
Makefile check (= fixstyle vet lint test), build, cross (linux + openbsd)
tree.go Node[C], Walk, matchFixedChild, findSlotChild, expandPaths, Candidates
tree_test.go table tests over a fixture tree (no client needed; Walk ignores Dynamic)
builder.go For[C] / Builder[C]: Root/Dir/Cmd/SlotDir/Slot (no repeated [C])
complete.go completer[C], questionListener[C], SplitTokens, splitForCompletion
shell.go Shell[C] (config + Run), Dispatch, showHelpAt, unknownCommandError, ErrQuit
app.go App[C]: standard flags + banner + connect + one-shot/shell -> Main()
color.go SetColor, Paint, Label + the ANSI palette (Red/Green/Blue/Yellow/Cyan)
output.go SetFormat, Emit (text-or-JSON), KV, IsJSON
term_openbsd.go applyTermFuncs (//go:build openbsd) <- from shell_term_openbsd.go
term_default.go applyTermFuncs no-op (//go:build !openbsd) <- from shell_term_default.go
example/main.go self-contained, dependency-free demo CLI (uses Builder + App)
keypress/ (optional) WaitForKeypress + cbreak shim, per-GOOS <- §4.3
```
### 4.1 The one new type: `Shell[C]`
The REPL loop today hard-codes the prompt, the banner, the `errQuit` sentinel,
and `formatError`. Those become a small config struct so each app keeps its
exact strings and error rendering:
```go
type Shell[C any] struct {
Root *Node[C]
Client C
Prompt string // "evpn> " / "maglev> "
FormatError func(error) string // default: err.Error(); evpnc/maglev pass their gRPC-desc unwrap
}
func (s *Shell[C]) Run(ctx context.Context) error // the readline REPL loop
func Dispatch[C any](ctx context.Context, root *Node[C], client C, tokens []string) error
var ErrQuit = errors.New("quit") // a quit/exit Run func returns this; Run() stops on it
```
Banner printing stays in `main.go` (it needs `buildinfo.Version()` etc., which
is app-specific) — `main` prints the banner, then calls `shell.Run(ctx)`.
`FormatError` is how each app keeps its gRPC `desc =` unwrap + red coloring
without the library depending on gRPC at all.
### 4.2 What the app's `main.go` looks like after (evpnc)
```go
root := buildTree() // returns *cli.Node[pb.EvpnrClient]
if len(args) == 0 {
fmt.Printf("evpnc %s ...\n", buildinfo.Version(), ...) // banner
return (&cli.Shell[pb.EvpnrClient]{
Root: root, Client: client, Prompt: "evpn> ", FormatError: formatError,
}).Run(ctx)
}
return cli.Dispatch(ctx, root, client, cli.SplitTokens(strings.Join(args, " ")))
```
`buildTree`, `runQuit` (`return cli.ErrQuit`), all `run*`/`dyn*`, `color.go`,
`watch.go` are unchanged except `Node``cli.Node[pb.EvpnrClient]` and the
`dyn*` signature tweak in maglev.
### 4.3 Optional: the keypress helper
`watch.go` in both apps shares "put stdin in cbreak, cancel ctx on any key", with
per-GOOS termios ioctls (`watch_linux.go` / `watch_bsd.go` in evpnc; inline in
maglev). The proto-streaming half is app-specific and stays, but
`WaitForKeypress(ctx, cancel)` + the cbreak shim are reusable. Suggest a
`cli/keypress` subpackage so apps don't recopy the ioctl tables. Low priority —
it's the least-drifted, most-self-contained piece. Can land in a follow-up.
## 5. Migration plan
**Phase 0 — stand up the module (no app changes).**
1. `git init ~/src/golang-cli`; `go mod init git.ipng.ch/ipng/golang-cli`; `go 1.25`.
2. Copy `tree.go`, `complete.go`, `shell.go` pieces, `shell_term_*.go` in.
Make `Node`, `Walk`, `Candidates`, `Completer`, `questionListener`, `Dispatch`,
`Shell` generic over `C`. Add `Shell.FormatError` and `ErrQuit`.
3. Port `tree_test.go` to a self-contained fixture tree (a tiny `Node[*fakeClient]`
with a couple of slot nodes + a circular watch-opts node) so the package tests
`Walk`/`expandPaths`/`Candidates` with no gRPC dependency. `go test ./...` green.
4. Dependencies: `github.com/chzyer/readline`, `golang.org/x/sys/unix` (both
already in each app's `go.sum`). Tag `v0.1.0`.
**Phase 1 — convert vpp-evpn (the args-bearing reference, less work).**
1. `go get git.ipng.ch/ipng/golang-cli@v0.1.0`; during local dev add a
`replace git.ipng.ch/ipng/golang-cli => ../golang-cli` to iterate.
2. Delete `tree.go`, `complete.go`, `shell.go` body, `shell_term_*.go` from
`cmd/evpnc`. Keep `commands.go`, `color.go`, `watch*.go`, `main.go`.
3. Replace `*Node` with `*cli.Node[pb.EvpnrClient]`; `runQuit` returns
`cli.ErrQuit`; `main` builds a `cli.Shell` (§4.2). `Walk`/`expandPaths` refs in
`watch.go`? none — good.
4. Run the existing checkpoint: `make fixstyle test lint vet`. The
`cmd/evpnc/tree_test.go` `TestWalk`/`TestExpandPaths` move to exercising
`buildTree()` through the library's exported `Walk`/`ExpandPaths` (rename as
needed) — they assert the *app's* tree, so they stay in the app.
**Phase 2 — convert vpp-maglev.**
1. Same wiring. Plus the mechanical edits from §3.1: add `_ []string` to every
`dyn*`; `Candidates`/`Dynamic` call sites now come from the library.
2. maglev's `main.go` color-default logic is equivalent to evpnc's; unchanged.
3. `make` checkpoint green.
**Phase 3 — drop the replace directives, tag a real version, pin both apps.**
## 6. Risks / things to verify during the port
- **Generics + method values.** `Node[C]` is fine; the only subtlety is that
`Completer[C]` and `questionListener[C]` must carry the type param through to
their `readline` interface methods. `readline.AutoCompleter` / `Listener` are
non-generic interfaces, but a concrete `*Completer[pb.EvpnrClient]` satisfies
them — verified shape, no reflection needed.
- **The `?` echo trick** (`questionListener.OnChange` writing the prompt+line to
`rl.Stderr()` before help) depends on readline's wrapWriter behaviour. It's
copied verbatim; no change.
- **OpenBSD.** evpnc has the termios shim and a `make pkg-openbsd`; the library
must keep the `//go:build openbsd` split so maglev gets it without pulling
OpenBSD code into its Linux builds. Verify a `GOOS=openbsd go build` of the
library.
- **No hidden client coupling.** `Walk`/`expandPaths` never touch the client
(the existing `TestWalk` comment confirms it) — so they could even be
non-generic with `Dynamic any`. Keeping them generic is simpler and uniform.
## 7. Open decisions for you
1. **Generics vs `any` client.** Recommended: generics (§3.2) — type-safe,
zero boilerplate in apps. The cost is `[]*cli.Node[pb.EvpnrClient]` verbosity
in `commands.go`; a per-app `type node = cli.Node[pb.EvpnrClient]` alias hides
it entirely.
2. **Module host/name.** `git.ipng.ch/ipng/golang-cli` (consistent) vs a GitHub
mirror if you want it public. Package name `cli` vs something less likely to
collide (`cmdtree`, `cmdline`).
3. **Scope of v0.1.** Minimum = tree + completion + shell. Stretch = the
`keypress` subpackage (§4.3) and maybe a tiny `color` helper if a third client
wants it. Suggest shipping the minimum first; both apps prove the API before
it ossifies.
4. **Whose `Dynamic` shape wins** — already answered (args-bearing, §3.1) unless
you'd rather keep maglev argless and have evpnc pass args by closure capture.
The superset is cleaner.
## 8. Bottom line
"What would it take to make this its own standalone package?" — concretely:
1. A new `go 1.25` module with three files made generic over the client type
(`tree.go`, `complete.go`, `shell.go`) plus the two OpenBSD term files and a
self-contained test — ~1 day including tests.
2. One genuine API choice (args-bearing `Dynamic`, generic `C`) and one new
config type (`Shell[C]` with an injectable `FormatError`). Everything else is
moved verbatim.
3. Mechanical edits in each app: swap `Node` for `cli.Node[ClientT]`, route the
REPL through `cli.Shell`, return `cli.ErrQuit` from quit, and (maglev only)
add an ignored args param to each `dyn*`. No behaviour changes; both `make`
checkpoints stay green.
The tree, the dynamic-node mechanism, and the command/run registration are all
already cleanly separated in these files — the refactor is mostly *relocation +
generics*, not redesign.
+159
View File
@@ -0,0 +1,159 @@
<!--
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.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](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` 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 `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.
+26 -1
View File
@@ -35,6 +35,7 @@ import (
"strings"
cli "git.ipng.ch/ipng/golang-cli"
"git.ipng.ch/ipng/golang-cli/keypress"
)
// inventory is the "client" C that the tree is generic over. In a real app this
@@ -154,6 +155,28 @@ func runColors(context.Context, inventory, []string) error {
return nil
}
// runWatch streams a few simulated events, stopping early if a key is pressed.
// In a real CLI this would be a server-side stream; here it is a bounded loop so
// the demo never hangs when stdin is not a terminal. Press any key (in a TTY) to
// stop it before it finishes.
func runWatch(ctx context.Context, _ inventory, _ []string) error {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
go keypress.WaitForKey(ctx, cancel)
for i := 1; i <= 5; i++ {
select {
case <-ctx.Done():
return nil // key pressed
default:
}
if err := cli.Emit(cli.KV("event", fmt.Sprintf("%d", i)), map[string]any{"event": i}); err != nil {
return err
}
}
return nil
}
func runQuit(context.Context, inventory, []string) error { return cli.ErrQuit }
// buildTree is the single source of truth for the command set, built with the
@@ -168,6 +191,7 @@ func buildTree() *cli.Node[inventory] {
b.Slot("<svc>", "show one service", dynServices, runShowServerService))))),
b.Dir("ping", "ping a server",
b.Slot("<name>", "ping this server", dynServers, runPing)),
b.Cmd("watch", "stream a few events (any key stops it)", runWatch),
b.Cmd("colors", "show the ANSI color palette", runColors),
b.Cmd("quit", "exit the shell", runQuit),
b.Cmd("exit", "exit the shell", runQuit),
@@ -177,9 +201,10 @@ func buildTree() *cli.Node[inventory] {
func main() {
(&cli.App[inventory]{
Name: "example",
Version: "1.1.0",
Version: "1.3.0",
Prompt: "inv> ",
Root: buildTree(),
JSON: true, // commands use cli.Emit, so advertise -json
Greeting: "golang-cli example — try: show server, ping db1, colors, '?' for help, TAB to complete",
// Local CLI: no -server flag. Connect just hands over the in-memory data,
// proving App is transport-agnostic (it never dials anything itself).
+20
View File
@@ -0,0 +1,20 @@
// SPDX-FileCopyrightText: (C) Copyright 2026 Pim van Pelt <pim@ipng.ch>
// SPDX-License-Identifier: Apache-2.0
package main
import (
"testing"
cli "git.ipng.ch/ipng/golang-cli"
)
// TestTreeValid keeps the example's command tree free of the authoring faults
// Validate checks for. It doubles as the recommended way to use Validate: from
// a unit test, so a malformed tree fails the build rather than misdispatching
// at runtime.
func TestTreeValid(t *testing.T) {
if err := cli.Validate(buildTree()); err != nil {
t.Fatalf("buildTree() has authoring faults:\n%v", err)
}
}
+32
View File
@@ -0,0 +1,32 @@
// SPDX-FileCopyrightText: (C) Copyright 2026 Pim van Pelt <pim@ipng.ch>
// SPDX-License-Identifier: Apache-2.0
//go:build openbsd || freebsd || netbsd || dragonfly || darwin
package keypress
import "golang.org/x/sys/unix"
// cbreak puts the terminal into cbreak mode (no canonical input/echo) so a
// single keystroke is available, leaving output post-processing intact. The
// BSDs use the TIOCGETA/TIOCSETA termios ioctls (Linux uses TCGETS/TCSETS). It
// returns the previous termios for restore, or an error if fd is not a tty.
func cbreak(fd int) (*unix.Termios, error) {
old, err := unix.IoctlGetTermios(fd, unix.TIOCGETA)
if err != nil {
return nil, err
}
t := *old
t.Lflag &^= unix.ICANON | unix.ECHO | unix.ECHOE | unix.ECHOK | unix.ECHONL
t.Cc[unix.VMIN] = 1
t.Cc[unix.VTIME] = 0
if err := unix.IoctlSetTermios(fd, unix.TIOCSETA, &t); err != nil {
return nil, err
}
return old, nil
}
// restore reverts the terminal to the settings captured by cbreak.
func restore(fd int, old *unix.Termios) error {
return unix.IoctlSetTermios(fd, unix.TIOCSETAF, old)
}
+32
View File
@@ -0,0 +1,32 @@
// SPDX-FileCopyrightText: (C) Copyright 2026 Pim van Pelt <pim@ipng.ch>
// SPDX-License-Identifier: Apache-2.0
//go:build linux
package keypress
import "golang.org/x/sys/unix"
// cbreak puts the terminal into cbreak mode (no canonical input/echo) so a
// single keystroke is available, leaving output post-processing intact. Linux
// uses the TCGETS/TCSETS termios ioctls (the BSDs use TIOCGETA/TIOCSETA). It
// returns the previous termios for restore, or an error if fd is not a tty.
func cbreak(fd int) (*unix.Termios, error) {
old, err := unix.IoctlGetTermios(fd, unix.TCGETS)
if err != nil {
return nil, err
}
t := *old
t.Lflag &^= unix.ICANON | unix.ECHO | unix.ECHOE | unix.ECHOK | unix.ECHONL
t.Cc[unix.VMIN] = 1
t.Cc[unix.VTIME] = 0
if err := unix.IoctlSetTermios(fd, unix.TCSETS, &t); err != nil {
return nil, err
}
return old, nil
}
// restore reverts the terminal to the settings captured by cbreak.
func restore(fd int, old *unix.Termios) error {
return unix.IoctlSetTermios(fd, unix.TCSETSF, old)
}
+19
View File
@@ -0,0 +1,19 @@
// SPDX-FileCopyrightText: (C) Copyright 2026 Pim van Pelt <pim@ipng.ch>
// SPDX-License-Identifier: Apache-2.0
//go:build !linux && !openbsd && !freebsd && !netbsd && !dragonfly && !darwin
package keypress
import "errors"
// termiosState is a placeholder so cbreak/restore have a consistent signature on
// platforms without a supported termios path. On those platforms cbreak always
// fails, so WaitForKey degrades to waiting on the context (it never reads stdin).
type termiosState struct{}
func cbreak(int) (*termiosState, error) {
return nil, errors.New("keypress: cbreak is unsupported on this platform")
}
func restore(int, *termiosState) error { return nil }
+52
View File
@@ -0,0 +1,52 @@
// SPDX-FileCopyrightText: (C) Copyright 2026 Pim van Pelt <pim@ipng.ch>
// SPDX-License-Identifier: Apache-2.0
// Package keypress lets a streaming command stop on any keystroke. A
// watch-style command runs WaitForKey in a goroutine with a cancellable
// context; the first key pressed cancels the context, tearing down the stream.
//
// ctx, cancel := context.WithCancel(ctx)
// defer cancel()
// go keypress.WaitForKey(ctx, cancel)
// for { ev, err := stream.Recv(); ... } // returns when ctx is cancelled
//
// When standard input is not a terminal (piped, redirected, backgrounded) there
// is no keystroke to wait for, so WaitForKey just blocks until ctx ends and
// never cancels on its own.
package keypress
import (
"context"
"os"
)
// WaitForKey blocks until a key is pressed on standard input or ctx is done,
// whichever comes first; on a keypress it calls cancel. If stdin is not a
// terminal it waits on ctx only (it neither reads input nor calls cancel). The
// terminal is placed in cbreak mode for the duration and restored on return.
//
// Run it in its own goroutine. If ctx ends first, WaitForKey returns and
// restores the terminal; a read already blocked on stdin is left to be reaped
// when the process exits (there is no portable way to interrupt it).
func WaitForKey(ctx context.Context, cancel context.CancelFunc) {
fd := int(os.Stdin.Fd())
old, err := cbreak(fd)
if err != nil {
<-ctx.Done() // stdin is not a tty: nothing to read, just honor ctx
return
}
defer func() { _ = restore(fd, old) }()
readDone := make(chan struct{})
go func() {
defer close(readDone)
buf := make([]byte, 1)
_, _ = os.Stdin.Read(buf)
}()
select {
case <-readDone:
cancel()
case <-ctx.Done():
}
}
+31
View File
@@ -0,0 +1,31 @@
// SPDX-FileCopyrightText: (C) Copyright 2026 Pim van Pelt <pim@ipng.ch>
// SPDX-License-Identifier: Apache-2.0
package keypress
import (
"context"
"testing"
"time"
)
// TestWaitForKeyReturnsOnContextCancel checks WaitForKey unblocks when the
// context ends, both on the non-tty path (cbreak fails → wait on ctx) and the
// tty path (select returns on ctx.Done). Under `go test` stdin is normally not
// a terminal, exercising the former.
func TestWaitForKeyReturnsOnContextCancel(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel() // already done
done := make(chan struct{})
go func() {
WaitForKey(ctx, func() {})
close(done)
}()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("WaitForKey did not return after the context was cancelled")
}
}
+77
View File
@@ -0,0 +1,77 @@
// SPDX-FileCopyrightText: (C) Copyright 2026 Pim van Pelt <pim@ipng.ch>
// SPDX-License-Identifier: Apache-2.0
package cli
import (
"errors"
"fmt"
"strings"
)
// Validate reports common authoring faults in a command tree. Walk tolerates
// all of them, so Validate is optional — but calling it once at startup, or
// from a unit test, catches mistakes that otherwise cause silent misdispatch.
// It returns all problems found, joined, or nil if the tree is clean.
//
// It reports:
// - a node with more than one slot child (Dynamic != nil): only the first is
// ever reachable (FR-1.7);
// - a non-root node with an empty Word: it can never be matched or displayed;
// - duplicate fixed (non-slot) words among a node's children: the second is
// shadowed by the first;
// - a node that neither runs nor has children: a dead end that can only print
// "<no completions>".
//
// Circular slots (a slot that is its own descendant, e.g. a watch-options node)
// are traversed once, not looped.
func Validate[C any](root *Node[C]) error {
var errs []error
validateNode(root, "", true, make(map[*Node[C]]bool), &errs)
return errors.Join(errs...)
}
func validateNode[C any](n *Node[C], path string, isRoot bool, seen map[*Node[C]]bool, errs *[]error) {
if seen[n] {
return
}
seen[n] = true
where := path
if where == "" {
where = "<root>"
}
if !isRoot && n.Word == "" {
*errs = append(*errs, fmt.Errorf("%s: node has an empty Word", where))
}
if !isRoot && n.Run == nil && len(n.Children) == 0 {
*errs = append(*errs, fmt.Errorf("%s: node neither runs nor has children (dead end)", where))
}
slots := 0
fixedWords := make(map[string]bool)
for _, c := range n.Children {
if c.Dynamic != nil {
slots++
continue
}
if c.Word != "" {
if fixedWords[c.Word] {
*errs = append(*errs, fmt.Errorf("%s: duplicate child word %q", where, c.Word))
}
fixedWords[c.Word] = true
}
}
if slots > 1 {
*errs = append(*errs, fmt.Errorf("%s: %d slot children, but only the first is reachable", where, slots))
}
for _, c := range n.Children {
cp := c.Word
if path != "" {
cp = path + " " + c.Word
}
validateNode(c, strings.TrimSpace(cp), false, seen, errs)
}
}
+56
View File
@@ -0,0 +1,56 @@
// SPDX-FileCopyrightText: (C) Copyright 2026 Pim van Pelt <pim@ipng.ch>
// SPDX-License-Identifier: Apache-2.0
package cli
import (
"context"
"strings"
"testing"
)
// TestValidateClean checks that a well-formed tree (the builder fixture, which
// includes a circular slot) validates without error.
func TestValidateClean(t *testing.T) {
if err := Validate(buildFixtureB()); err != nil {
t.Errorf("Validate(clean tree) = %v, want nil", err)
}
}
// TestValidateFaults checks each fault class is reported.
func TestValidateFaults(t *testing.T) {
dyn := func(context.Context, fakeClient, []string) []string { return nil }
root := &Node[fakeClient]{Children: []*Node[fakeClient]{
// two slot children under one node
{Word: "twoslots", Help: "", Children: []*Node[fakeClient]{
{Word: "<a>", Dynamic: dyn, Run: runNoop},
{Word: "<b>", Dynamic: dyn, Run: runNoop},
}},
// duplicate fixed words
{Word: "dup", Children: []*Node[fakeClient]{
{Word: "x", Run: runNoop},
{Word: "x", Run: runNoop},
}},
// dead end: neither runs nor has children
{Word: "deadend"},
// empty word
{Word: "", Run: runNoop},
}}
err := Validate(root)
if err == nil {
t.Fatal("Validate(broken tree) = nil, want errors")
}
msg := err.Error()
for _, want := range []string{
"only the first is reachable",
`duplicate child word "x"`,
"dead end",
"empty Word",
} {
if !strings.Contains(msg, want) {
t.Errorf("Validate missing %q in:\n%s", want, msg)
}
}
}