pim d63ffd6a3a 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>
2026-06-05 21:48:48 +02:00

golang-cli

A small command-line interface library: you declare a command tree once, and dispatch, ?-help, and TAB-completion are all derived from it. Output can be colorized text or JSON from the same command code. Built on github.com/chzyer/readline.

It is generic over a client type C (typically a gRPC client) that is threaded unchanged into every command and completion function — so your command code receives the concrete client with no type assertions.

import cli "git.ipng.ch/ipng/golang-cli"

The building blocks

Concept API
The parse tree cli.Node[C] + cli.Walk / cli.ExpandPaths
Dynamic nodes (live completion candidates) Node.Dynamic func(ctx, C, args) []string
Command functions Node.Run func(ctx, C, args) error, run by cli.Dispatch / cli.Shell
Interactive shell cli.Shell[C] (TAB-completion, ?-help, prefix abbreviation)
Output cli.Emit(text, value) → text or JSON; cli.KV / cli.Paint / cli.Label

A slot node (Dynamic != nil, with a placeholder Word like <name>) accepts any single token as an argument. Dynamic receives the args captured by slot nodes earlier on the path, so a <service> slot can list only the services of the <server> already typed.

Usage

type inventory struct { /* your client */ }

func dynServers(_ context.Context, inv inventory, _ []string) []string { /* ... */ }

func runShow(_ context.Context, inv inventory, args []string) error {
    // Hand Emit a human string and a machine value; the framework prints one or
    // the other based on the output format (see -json below).
    return cli.Emit(cli.KV("name", args[0]), map[string]any{"name": args[0]})
}

func buildTree() *cli.Node[inventory] {
    return &cli.Node[inventory]{Children: []*cli.Node[inventory]{
        {Word: "show", Help: "show state", Children: []*cli.Node[inventory]{
            {Word: "server", Help: "list servers", Run: runShowServers, Children: []*cli.Node[inventory]{
                {Word: "<name>", Help: "show one", Dynamic: dynServers, Run: runShow},
            }},
        }},
        {Word: "quit", Help: "exit", Run: func(context.Context, inventory, []string) error { return cli.ErrQuit }},
    }}
}

func main() {
    root, inv := buildTree(), newInventory()
    if args := os.Args[1:]; len(args) > 0 {           // one-shot
        _ = cli.Dispatch(context.Background(), root, inv, cli.SplitTokens(strings.Join(args, " ")))
        return
    }
    // interactive REPL: TAB completion, '?' help, prefix abbreviation
    _ = (&cli.Shell[inventory]{Root: root, Client: inv, Prompt: "inv> "}).Run(context.Background())
}

Return cli.ErrQuit from a command's Run to stop the REPL. Shell.FormatError lets you render command errors however you like (e.g. unwrap a gRPC status to its message and color it); it defaults to err.Error().

Output: color and JSON

A command describes its result once and the framework renders it:

cli.Emit(
    cli.KV("name", name),                       // text form: blue "name=" + value
    map[string]any{"name": name},               // machine form, used under -json
)

Toggle the format and color once at startup:

if *jsonFlag { cli.SetFormat(cli.FormatJSON) }
cli.SetColor(*colorFlag && !*jsonFlag)
  • cli.KV(key, value)"key=value" with the key painted blue.
  • cli.Paint(s, cli.Red|Green|Blue|Yellow|Cyan) — color a status word.
  • cli.Label(s) — blue (what KV uses for the key).

With color off (-color=false) or in JSON mode, no ANSI escapes are emitted, so output stays script-safe.

Runnable example

example/main.go is a complete, dependency-free demo — an in-memory "server inventory" CLI:

go run ./example                                 # interactive shell
go run ./example show server web1                # name=web1  count=3  services=http, https, ssh
go run ./example -json show server web1          # {"name":"web1","count":3,"services":[...]}
go run ./example -color=false show server web1   # no ANSI escapes
go run ./example colors                          # the ANSI palette
go run ./example ping db1                         # pong from db1

In the interactive shell, TAB completes and ? lists what can follow:

inv> show server <TAB>            web1  web2  db1
inv> show server web1 service ?   show one service
                                  <svc>:  http  https  ssh

Versioning

Released as semver Go module tags. Pin a version with:

go get git.ipng.ch/ipng/golang-cli@v1.0.0

The import path stays git.ipng.ch/ipng/golang-cli for all v0/v1 releases; a future v2 would import as git.ipng.ch/ipng/golang-cli/v2.

Notes

  • OpenBSD: readline's native termios path is broken there; the library installs an x/sys/unix-based override automatically (term_openbsd.go), a no-op on every other platform. Verified building on Linux (amd64/arm64) and OpenBSD.
  • Requires Go 1.25+ (generics). Dependencies: chzyer/readline, golang.org/x/sys.

License

Apache-2.0. See LICENSE.

S
Description
A CLI parser w/ dynamic node resolution, tab completion, colorization and JSON features.
Readme Apache-2.0 116 KiB
Languages
Go 98.6%
Makefile 1.4%