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>
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
<!--
|
||||
SPDX-FileCopyrightText: (C) Copyright 2026 Pim van Pelt <pim@ipng.ch>
|
||||
SPDX-License-Identifier: Apache-2.0
|
||||
-->
|
||||
|
||||
# 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`](https://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.
|
||||
|
||||
```go
|
||||
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
|
||||
|
||||
```go
|
||||
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:
|
||||
|
||||
```go
|
||||
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:
|
||||
|
||||
```go
|
||||
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`](example/main.go) is a complete, dependency-free demo — an
|
||||
in-memory "server inventory" CLI:
|
||||
|
||||
```sh
|
||||
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:
|
||||
|
||||
```sh
|
||||
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](LICENSE).
|
||||
Reference in New Issue
Block a user