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:
+222
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user