d63ffd6a3a
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>
223 lines
8.3 KiB
Go
223 lines
8.3 KiB
Go
// 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)
|
|
}
|
|
}
|