feat: golang-cli v1.0.0 — generic command-tree CLI library

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>
This commit is contained in:
2026-06-05 21:48:48 +02:00
commit d63ffd6a3a
14 changed files with 1670 additions and 0 deletions
+205
View File
@@ -0,0 +1,205 @@
// SPDX-FileCopyrightText: (C) Copyright 2026 Pim van Pelt <pim@ipng.ch>
// SPDX-License-Identifier: Apache-2.0
package cli
import (
"context"
"sort"
"strings"
"testing"
)
// fakeClient stands in for an app's gRPC client. The fixture tree's Dynamic
// funcs read from it, proving the client is threaded through unchanged and that
// Dynamic sees the args captured by earlier slot nodes.
type fakeClient struct {
instances []string
evpns map[string][]string // per-instance EVPN names
}
func dynInstances(_ context.Context, c fakeClient, _ []string) []string {
return c.instances
}
// dynEvpns demonstrates context-dependent lookup: it lists the EVPNs of the
// instance captured as args[0] earlier on the path.
func dynEvpns(_ context.Context, c fakeClient, args []string) []string {
if len(args) == 0 {
return nil
}
return c.evpns[args[0]]
}
func dynWatchOpts(_ context.Context, _ fakeClient, _ []string) []string {
return []string{"num", "log", "crud"}
}
func runNoop(context.Context, fakeClient, []string) error { return nil }
func runQuit(context.Context, fakeClient, []string) error { return ErrQuit }
// buildFixture mirrors the real apps' tree shape: singular keyword nodes that
// double as "list" and "show one", a context-dependent slot, a circular
// watch-options slot, and a quit leaf.
func buildFixture() *Node[fakeClient] {
showInstanceEvpnName := &Node[fakeClient]{Word: "<evpn>", Help: "show one EVPN", Dynamic: dynEvpns, Run: runNoop}
showInstanceEvpn := &Node[fakeClient]{Word: "evpn", Help: "list the member's EVPNs", Run: runNoop, Children: []*Node[fakeClient]{showInstanceEvpnName}}
showInstanceName := &Node[fakeClient]{Word: "<id>", Help: "show one member", Dynamic: dynInstances, Run: runNoop, Children: []*Node[fakeClient]{showInstanceEvpn}}
show := &Node[fakeClient]{Word: "show", Help: "show state", Children: []*Node[fakeClient]{
{Word: "instance", Help: "list fleet members (add <id> for one)", Run: runNoop, Children: []*Node[fakeClient]{showInstanceName}},
}}
// Circular watch-options slot: every option captured as an arg and may be
// followed by another option.
watchOpt := &Node[fakeClient]{Word: "<opts>", Dynamic: dynWatchOpts, Run: runNoop}
watchOpt.Children = []*Node[fakeClient]{watchOpt}
watch := &Node[fakeClient]{Word: "watch", Help: "stream events", Children: []*Node[fakeClient]{
{Word: "events", Help: "watch events", Run: runNoop, Children: []*Node[fakeClient]{watchOpt}},
}}
return &Node[fakeClient]{Children: []*Node[fakeClient]{
show,
watch,
{Word: "quit", Help: "exit", Run: runQuit},
}}
}
func TestWalk(t *testing.T) {
root := buildFixture()
cases := []struct {
toks []string
runnable bool
help string
args []string
remaining int
}{
{[]string{"show", "instance"}, true, "list fleet members (add <id> for one)", nil, 0},
{[]string{"show", "instance", "host1"}, true, "show one member", []string{"host1"}, 0},
{[]string{"show", "instance", "host1", "evpn"}, true, "list the member's EVPNs", []string{"host1"}, 0},
{[]string{"show", "instance", "host1", "evpn", "blue"}, true, "show one EVPN", []string{"host1", "blue"}, 0},
// prefix abbreviation: singular, unique keywords resolve from a prefix.
{[]string{"sh", "inst"}, true, "list fleet members (add <id> for one)", nil, 0},
// circular watch options are captured as args.
{[]string{"watch", "events", "num", "5", "crud"}, true, "", []string{"num", "5", "crud"}, 0},
// unknown trailing token is reported as remaining.
{[]string{"show", "bogus"}, false, "", nil, 1},
// "show" itself is not runnable and consumes everything.
{[]string{"show"}, false, "", nil, 0},
}
for _, tc := range cases {
node, args, remaining := Walk(root, tc.toks)
if len(remaining) != tc.remaining {
t.Errorf("Walk(%v) remaining=%v, want %d", tc.toks, remaining, tc.remaining)
continue
}
if tc.remaining > 0 {
continue
}
if gotRunnable := node.Run != nil; gotRunnable != tc.runnable {
t.Errorf("Walk(%v) runnable=%v, want %v", tc.toks, gotRunnable, tc.runnable)
continue
}
if tc.runnable && node.Help != tc.help {
t.Errorf("Walk(%v) help=%q, want %q", tc.toks, node.Help, tc.help)
}
if strings.Join(args, ",") != strings.Join(tc.args, ",") {
t.Errorf("Walk(%v) args=%v, want %v", tc.toks, args, tc.args)
}
}
}
func TestExpandPathsCoversCommands(t *testing.T) {
root := buildFixture()
var joined string
for _, l := range ExpandPaths(root, "") {
joined += l.Path + "\n"
}
for _, want := range []string{
"show instance",
"show instance <id> evpn <evpn>",
"watch events",
"quit",
} {
if !strings.Contains(joined, want) {
t.Errorf("ExpandPaths missing %q\n%s", want, joined)
}
}
}
func TestCandidates(t *testing.T) {
root := buildFixture()
client := fakeClient{
instances: []string{"host1", "host2"},
evpns: map[string][]string{"host1": {"blue", "red"}},
}
ctx := context.Background()
words := func(ns []*Node[fakeClient]) []string {
out := make([]string, 0, len(ns))
for _, n := range ns {
out = append(out, n.Word)
}
sort.Strings(out)
return out
}
eq := func(got, want []string) bool {
if len(got) != len(want) {
return false
}
for i := range got {
if got[i] != want[i] {
return false
}
}
return true
}
// Fixed-child completion filtered by partial.
if got := words(Candidates(ctx, client, root, []string{"show"}, "inst")); !eq(got, []string{"instance"}) {
t.Errorf(`Candidates(show, "inst") = %v, want [instance]`, got)
}
// Dynamic slot values offered for an empty partial.
if got := words(Candidates(ctx, client, root, []string{"show", "instance"}, "")); !eq(got, []string{"host1", "host2"}) {
t.Errorf(`Candidates(show instance, "") = %v, want [host1 host2]`, got)
}
// Dynamic slot values filtered by partial.
if got := words(Candidates(ctx, client, root, []string{"show", "instance"}, "host1")); !eq(got, []string{"host1"}) {
t.Errorf(`Candidates(show instance, "host1") = %v, want [host1]`, got)
}
// Context-dependent slot: <evpn> lists the EVPNs of host1 captured earlier.
if got := words(Candidates(ctx, client, root, []string{"show", "instance", "host1", "evpn"}, "")); !eq(got, []string{"blue", "red"}) {
t.Errorf(`Candidates(... host1 evpn, "") = %v, want [blue red]`, got)
}
// A broken left context offers nothing.
if got := Candidates(ctx, client, root, []string{"show", "bogus"}, ""); got != nil {
t.Errorf("Candidates(show bogus) = %v, want nil", got)
}
}
func TestDispatchRunsCommand(t *testing.T) {
root := buildFixture()
var gotArgs []string
root.Children = append(root.Children, &Node[fakeClient]{
Word: "echo",
Children: []*Node[fakeClient]{{
Word: "<x>",
Dynamic: func(context.Context, fakeClient, []string) []string { return nil },
Run: func(_ context.Context, _ fakeClient, a []string) error {
gotArgs = a
return nil
},
}},
})
if err := Dispatch(context.Background(), root, fakeClient{}, []string{"echo", "hi"}); err != nil {
t.Fatalf("Dispatch echo: %v", err)
}
if len(gotArgs) != 1 || gotArgs[0] != "hi" {
t.Errorf("Run got args %v, want [hi]", gotArgs)
}
// Unknown command surfaces as an error.
if err := Dispatch(context.Background(), root, fakeClient{}, []string{"nope"}); err == nil {
t.Error("Dispatch(nope) = nil, want unknown-command error")
}
}