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
+222
View File
@@ -0,0 +1,222 @@
// SPDX-FileCopyrightText: (C) Copyright 2026 Pim van Pelt <pim@ipng.ch>
// SPDX-License-Identifier: Apache-2.0
// Command example is a self-contained demo of git.ipng.ch/ipng/golang-cli.
//
// It builds a tiny "server inventory" CLI over an in-memory client — no gRPC,
// no external services. It shows the building blocks of the package:
//
// - a declarative command tree (buildTree);
// - dynamic slot resolvers, including a context-dependent one (dynServices
// lists the services of the <server> captured earlier on the path);
// - command functions that hand one result to cli.Emit, which renders it as
// colorized text or, under -json, as JSON — the command supplies both;
// - the color helpers: cli.KV paints the "key=" of a key=value pair blue and
// cli.Paint colors status words, both honoring the -color flag.
//
// Run it interactively (TAB to complete, '?' for help):
//
// go run ./example
// inv> show server <TAB> # completes to web1 / web2 / db1
// inv> show server web1 service ? # lists web1's services
// inv> colors # the ANSI palette
//
// Or one-shot — text or JSON:
//
// go run ./example show server web1
// go run ./example -json show server web1
// go run ./example -color=false show server web1
package main
import (
"context"
"flag"
"fmt"
"os"
"sort"
"strings"
cli "git.ipng.ch/ipng/golang-cli"
)
// inventory is the "client" C that the tree is generic over. In a real app this
// would be a gRPC client; here it is just in-memory data. It is passed unchanged
// to every Dynamic and Run function.
type inventory struct {
// servers maps a server name to the services running on it.
servers map[string][]string
}
func newInventory() inventory {
return inventory{servers: map[string][]string{
"web1": {"http", "https", "ssh"},
"web2": {"http", "https"},
"db1": {"postgres", "ssh"},
}}
}
func (inv inventory) names() []string {
out := make([]string, 0, len(inv.servers))
for n := range inv.servers {
out = append(out, n)
}
sort.Strings(out)
return out
}
// --- dynamic resolvers -------------------------------------------------------
// dynServers lists every server name. args is unused here.
func dynServers(_ context.Context, inv inventory, _ []string) []string {
return inv.names()
}
// dynServices is context-dependent: it lists the services of the server that
// was captured as args[0] earlier on the path. This is what lets
// "show server web1 service <TAB>" offer only web1's services.
func dynServices(_ context.Context, inv inventory, args []string) []string {
if len(args) == 0 {
return nil
}
svcs := append([]string(nil), inv.servers[args[0]]...)
sort.Strings(svcs)
return svcs
}
// --- command functions -------------------------------------------------------
//
// Each hands cli.Emit a text form and a machine value; the framework prints one
// or the other based on -json. Text uses cli.KV (blue "key=") and cli.Paint.
func runShowServers(_ context.Context, inv inventory, _ []string) error {
names := inv.names()
return cli.Emit(cli.KV("servers", strings.Join(names, ", ")),
map[string]any{"servers": names})
}
type serverView struct {
Name string `json:"name"`
Count int `json:"count"`
Services []string `json:"services"`
}
func runShowServer(_ context.Context, inv inventory, args []string) error {
name := args[0]
svcs, ok := inv.servers[name]
if !ok {
return fmt.Errorf("no such server: %s", name)
}
text := fmt.Sprintf("%s %s %s",
cli.KV("name", name),
cli.KV("count", fmt.Sprintf("%d", len(svcs))),
cli.KV("services", strings.Join(svcs, ", ")))
return cli.Emit(text, serverView{Name: name, Count: len(svcs), Services: svcs})
}
func runShowServerServices(_ context.Context, inv inventory, args []string) error {
name := args[0]
text := fmt.Sprintf("%s %s", cli.KV("server", name), cli.KV("services", strings.Join(inv.servers[name], ", ")))
return cli.Emit(text, map[string]any{"server": name, "services": inv.servers[name]})
}
func runShowServerService(_ context.Context, inv inventory, args []string) error {
server, svc := args[0], args[1]
for _, s := range inv.servers[server] {
if s == svc {
text := cli.KV(server+"/"+svc, cli.Paint("running", cli.Green))
return cli.Emit(text, map[string]any{"server": server, "service": svc, "running": true})
}
}
return fmt.Errorf("%s is not running %s", server, svc)
}
func runPing(_ context.Context, inv inventory, args []string) error {
name := args[0]
if _, ok := inv.servers[name]; !ok {
return fmt.Errorf("no such server: %s", name)
}
return cli.Emit(fmt.Sprintf("pong from %s", cli.Paint(name, cli.Green)),
map[string]any{"server": name, "reply": "pong"})
}
// runColors demonstrates the palette. Each line shows a value painted with one
// of the standard colors; with -color=false the same text prints unadorned.
func runColors(context.Context, inventory, []string) error {
fmt.Println("status words via Paint (try -color=false to disable):")
fmt.Println(" " + cli.KV("status", cli.Paint("OK", cli.Green)))
fmt.Println(" " + cli.KV("status", cli.Paint("DEGRADED", cli.Yellow)))
fmt.Println(" " + cli.KV("status", cli.Paint("DOWN", cli.Red)))
fmt.Println(" " + cli.KV("note", cli.Paint("highlight", cli.Cyan)))
fmt.Println("the raw helpers:")
fmt.Printf(" Paint(%q, cli.Red) => %s\n", "text", cli.Paint("text", cli.Red))
fmt.Printf(" Paint(%q, cli.Green) => %s\n", "text", cli.Paint("text", cli.Green))
fmt.Printf(" Paint(%q, cli.Blue) => %s\n", "text", cli.Paint("text", cli.Blue))
fmt.Printf(" Paint(%q, cli.Yellow) => %s\n", "text", cli.Paint("text", cli.Yellow))
fmt.Printf(" KV(%q, %q) => %s\n", "key", "value", cli.KV("key", "value"))
return nil
}
func runQuit(context.Context, inventory, []string) error { return cli.ErrQuit }
// buildTree is the single source of truth for the command set: dispatch, help,
// and tab-completion are all derived from it.
func buildTree() *cli.Node[inventory] {
// show server [<name> [service [<svc>]]]
serviceName := &cli.Node[inventory]{Word: "<svc>", Help: "show one service", Dynamic: dynServices, Run: runShowServerService}
service := &cli.Node[inventory]{Word: "service", Help: "list a server's services", Run: runShowServerServices, Children: []*cli.Node[inventory]{serviceName}}
serverName := &cli.Node[inventory]{Word: "<name>", Help: "show one server", Dynamic: dynServers, Run: runShowServer, Children: []*cli.Node[inventory]{service}}
server := &cli.Node[inventory]{Word: "server", Help: "list servers (add <name> for one)", Run: runShowServers, Children: []*cli.Node[inventory]{serverName}}
show := &cli.Node[inventory]{Word: "show", Help: "show inventory state", Children: []*cli.Node[inventory]{server}}
// ping <server>
ping := &cli.Node[inventory]{Word: "ping", Help: "ping a server", Children: []*cli.Node[inventory]{
{Word: "<name>", Help: "ping this server", Dynamic: dynServers, Run: runPing},
}}
return &cli.Node[inventory]{Children: []*cli.Node[inventory]{
show,
ping,
{Word: "colors", Help: "show the ANSI color palette", Run: runColors},
{Word: "quit", Help: "exit the shell", Run: runQuit},
{Word: "exit", Help: "exit the shell", Run: runQuit},
}}
}
func main() {
color := flag.Bool("color", true, "colorize output: blue key= labels and painted status words")
jsonOut := flag.Bool("json", false, "emit JSON instead of text (one-shot mode)")
flag.Parse()
if *jsonOut {
cli.SetFormat(cli.FormatJSON)
}
// JSON output is machine-facing, so suppress ANSI escapes in that mode.
cli.SetColor(*color && !*jsonOut)
inv := newInventory()
root := buildTree()
ctx := context.Background()
// With arguments: run one command and exit (script-friendly).
if args := flag.Args(); len(args) > 0 {
if err := cli.Dispatch(ctx, root, inv, cli.SplitTokens(strings.Join(args, " "))); err != nil {
fmt.Fprintln(os.Stderr, cli.Paint(err.Error(), cli.Red))
os.Exit(1)
}
return
}
// No arguments: interactive REPL with completion and '?'-help. Errors are
// painted red via FormatError.
fmt.Println("golang-cli example — try: show server, ping db1, colors, '?' for help, TAB to complete")
shell := &cli.Shell[inventory]{
Root: root,
Client: inv,
Prompt: "inv> ",
FormatError: func(err error) string { return cli.Paint(err.Error(), cli.Red) },
}
if err := shell.Run(ctx); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}