Files
golang-cli/README.md
T
pim e030cd28e9 feat: builder + App runner, Makefile (v1.1.0)
Builder (cli.For[C]): Root/Dir/Cmd/SlotDir/Slot construct the tree
without repeating the [C] type parameter at every node; returns plain
*Node[C] so it interoperates with struct-literal construction.

App[C]: collapses the per-binary main.go — standard flags
(-color/-json/-version, -server when configured), mode-aware color
defaults, version banner, client connect, and the one-shot-vs-shell
split — into one Main(). Transport-agnostic via a Connect callback, so
it never assumes gRPC.

Makefile: `make check` = fixstyle vet lint test (the pre-commit gate),
plus build and a linux+openbsd cross target. The example now dogfoods
both Builder and App. Tests cover the builder tree and App's one-shot
dispatch / -version / nil-Connect paths.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 21:59:33 +02:00

6.8 KiB

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

Less boilerplate: Builder and App

cli.For[C]() returns a Builder so no node repeats the [C] type parameter. The names are symmetric — Dir/Cmd for fixed keywords (without/with an action), SlotDir/Slot for dynamic argument nodes:

b := cli.For[inventory]()
root := b.Root(
    b.Dir("show", "show state",
        b.Cmd("server", "list servers", runShowServers,
            b.Slot("<name>", "show one", dynServers, runShowServer))),
    b.Cmd("quit", "exit", runQuit),
)

cli.App[C] wraps the whole process entry point — the standard flags (-color, -json, -version, and -server when configured), mode-aware color defaults, the version banner, connecting the client, and the one-shot-vs-shell split — into a single Main(). It is transport-agnostic: it never dials anything itself, so supply Connect to build the client (dial gRPC, open a socket, or return an in-memory value):

func main() {
    (&cli.App[inventory]{
        Name: "inv", Version: "1.1.0", Prompt: "inv> ", Root: buildTree(),
        // Local CLI: no -server flag. A networked CLI sets DefaultServer/ServerEnv
        // and dials inside Connect.
        Connect: func(context.Context, string) (inventory, func(), error) {
            return newInventory(), nil, nil
        },
        FormatError: func(err error) string { return cli.Paint(err.Error(), cli.Red) },
    }).Main()
}

Both are additive conveniences: every builder method returns a plain *cli.Node[C], so builder and struct-literal construction interoperate, and you can still drive the lifecycle yourself with Shell/Dispatch instead of App.

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.