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>
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 (whatKVuses 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.