Render(v) treats JSON as the model: in -json mode it prints v as JSON; otherwise it paints it as text — object scalars on one line as key=value (keys blue, values bright white: a structural key/value distinction, not semantic color), nested objects indented, arrays one block per element, field order preserved via an order-keeping JSON decode. EmitJSON(v) is the JSON-only arm for commands that paint their own text. Operates on JSON only, so core stays protobuf-free (NFR-4). Adds White to the palette. The example gains an `inspect` command demoing Render (text vs -json). design.md FR-4.5/4.6 document the renderer and the "JSON is always the full record; synopsis-vs-detail is text-only" principle. 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().
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.
cli.Validate(root) reports common authoring faults (more than one slot child
under a node, an empty word, duplicate sibling words, a dead-end node). It is
optional; the idiomatic use is a one-line unit test so a malformed tree fails the
build:
func TestTreeValid(t *testing.T) {
if err := cli.Validate(buildTree()); err != nil { t.Fatal(err) }
}
Streaming commands
For a watch-style command that streams until interrupted, the
keypress subpackage stops it on any keystroke:
func runWatch(ctx context.Context, c Client, _ []string) error {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
go keypress.WaitForKey(ctx, cancel) // any key cancels ctx
stream, _ := c.Watch(ctx, req)
for { ev, err := stream.Recv(); /* returns when ctx is cancelled */ }
}
When stdin is not a terminal it simply waits on the context, so piped/one-shot use never blocks on a keypress.
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.