Reusable, generics-based CLI extracted from vpp-evpn's cmd/evpnc: a declarative command tree (Node[C]) from which dispatch, '?'-help and TAB-completion are derived, an interactive Shell[C], dynamic slot resolvers (context-dependent via captured args), text-or-JSON output (Emit), and color helpers (Paint/Label/KV). Builds on Linux and OpenBSD (readline termios override). Includes a self-contained example and a design proposal under docs/. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
13 KiB
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 everyrun*/dyn*function (commands.go) — the actual command set.color.go— theformatErrorgRPC-status unwrap stays in-app (wired viaShell.FormatError). The genericlabel/paint/colorEnabledhalf moved into the library ascli.Label/cli.Paint/cli.SetColor(seecolor.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[]stringis 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:
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)
Candidatesarg order: evpnc(ctx, client, root, tokens, partial)vs maglev(root, tokens, partial, ctx, client). Library picks the evpnc order.- evpnc's
Candidatespasses capturedargstoDynamic; maglev discards them (node, _, remaining). Library passes them (superset; maglev's funcs ignore). RunShell: evpnc callsapplyTermFuncs(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.splitTokenslives incomplete.go(maglev) vscomplete.go(evpnc) — same body; library exports oneSplitTokens.- 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
tree.go Node[C], Walk, matchFixedChild, findSlotChild, expandPaths, Candidates
tree_test.go table tests over a fixture tree (no client needed; Walk ignores Dynamic)
complete.go completer[C], questionListener[C], SplitTokens, splitForCompletion
shell.go Shell[C] (config + Run), Dispatch, showHelpAt, unknownCommandError, ErrQuit
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
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:
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)
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).
git init ~/src/golang-cli;go mod init git.ipng.ch/ipng/golang-cli;go 1.25.- Copy
tree.go,complete.go,shell.gopieces,shell_term_*.goin. MakeNode,Walk,Candidates,Completer,questionListener,Dispatch,Shellgeneric overC. AddShell.FormatErrorandErrQuit. - Port
tree_test.goto a self-contained fixture tree (a tinyNode[*fakeClient]with a couple of slot nodes + a circular watch-opts node) so the package testsWalk/expandPaths/Candidateswith no gRPC dependency.go test ./...green. - Dependencies:
github.com/chzyer/readline,golang.org/x/sys/unix(both already in each app'sgo.sum). Tagv0.1.0.
Phase 1 — convert vpp-evpn (the args-bearing reference, less work).
go get git.ipng.ch/ipng/golang-cli@v0.1.0; during local dev add areplace git.ipng.ch/ipng/golang-cli => ../golang-clito iterate.- Delete
tree.go,complete.go,shell.gobody,shell_term_*.gofromcmd/evpnc. Keepcommands.go,color.go,watch*.go,main.go. - Replace
*Nodewith*cli.Node[pb.EvpnrClient];runQuitreturnscli.ErrQuit;mainbuilds acli.Shell(§4.2).Walk/expandPathsrefs inwatch.go? none — good. - Run the existing checkpoint:
make fixstyle test lint vet. Thecmd/evpnc/tree_test.goTestWalk/TestExpandPathsmove to exercisingbuildTree()through the library's exportedWalk/ExpandPaths(rename as needed) — they assert the app's tree, so they stay in the app.
Phase 2 — convert vpp-maglev.
- Same wiring. Plus the mechanical edits from §3.1: add
_ []stringto everydyn*;Candidates/Dynamiccall sites now come from the library. - maglev's
main.gocolor-default logic is equivalent to evpnc's; unchanged. makecheckpoint 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 thatCompleter[C]andquestionListener[C]must carry the type param through to theirreadlineinterface methods.readline.AutoCompleter/Listenerare non-generic interfaces, but a concrete*Completer[pb.EvpnrClient]satisfies them — verified shape, no reflection needed. - The
?echo trick (questionListener.OnChangewriting the prompt+line torl.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 openbsdsplit so maglev gets it without pulling OpenBSD code into its Linux builds. Verify aGOOS=openbsd go buildof the library. - No hidden client coupling.
Walk/expandPathsnever touch the client (the existingTestWalkcomment confirms it) — so they could even be non-generic withDynamic any. Keeping them generic is simpler and uniform.
7. Open decisions for you
- Generics vs
anyclient. Recommended: generics (§3.2) — type-safe, zero boilerplate in apps. The cost is[]*cli.Node[pb.EvpnrClient]verbosity incommands.go; a per-apptype node = cli.Node[pb.EvpnrClient]alias hides it entirely. - Module host/name.
git.ipng.ch/ipng/golang-cli(consistent) vs a GitHub mirror if you want it public. Package nameclivs something less likely to collide (cmdtree,cmdline). - Scope of v0.1. Minimum = tree + completion + shell. Stretch = the
keypresssubpackage (§4.3) and maybe a tinycolorhelper if a third client wants it. Suggest shipping the minimum first; both apps prove the API before it ossifies. - Whose
Dynamicshape 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:
- A new
go 1.25module 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. - One genuine API choice (args-bearing
Dynamic, genericC) and one new config type (Shell[C]with an injectableFormatError). Everything else is moved verbatim. - Mechanical edits in each app: swap
Nodeforcli.Node[ClientT], route the REPL throughcli.Shell, returncli.ErrQuitfrom quit, and (maglev only) add an ignored args param to eachdyn*. No behaviour changes; bothmakecheckpoints 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.