// SPDX-FileCopyrightText: (C) Copyright 2026 Pim van Pelt // 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 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 # 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 " 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 [ [service []]] serviceName := &cli.Node[inventory]{Word: "", 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: "", Help: "show one server", Dynamic: dynServers, Run: runShowServer, Children: []*cli.Node[inventory]{service}} server := &cli.Node[inventory]{Word: "server", Help: "list servers (add 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 ping := &cli.Node[inventory]{Word: "ping", Help: "ping a server", Children: []*cli.Node[inventory]{ {Word: "", 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) } }