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:
2026-06-05 21:48:48 +02:00
commit d63ffd6a3a
14 changed files with 1670 additions and 0 deletions
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for describing the origin of the Work and
reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Support. While redistributing the Work or
Derivative Works thereof, You may choose to offer, and charge a
fee for, acceptance of support, warranty, indemnity, or other
liability obligations and/or rights consistent with this License.
However, in accepting such obligations, You may act only on Your
own behalf and on Your sole responsibility, not on behalf of any
other Contributor, and only if You agree to indemnify, defend,
and hold each Contributor harmless for any liability incurred by,
or claims asserted against, such Contributor by reason of your
accepting any such warranty or support.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2026 Pim van Pelt <pim@ipng.ch>
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+144
View File
@@ -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).
+41
View File
@@ -0,0 +1,41 @@
// SPDX-FileCopyrightText: (C) Copyright 2026 Pim van Pelt <pim@ipng.ch>
// SPDX-License-Identifier: Apache-2.0
package cli
// Bright (high-intensity) ANSI color codes, plus the reset sequence. These are
// the palette consumed by Paint and Label; status words pop while unremarkable
// "normal" states can stay uncolored.
const (
Reset = "\x1b[0m"
Red = "\x1b[91m" // bright red
Green = "\x1b[92m" // bright green
Blue = "\x1b[94m" // bright blue
Yellow = "\x1b[93m" // bright yellow
Cyan = "\x1b[96m" // bright cyan
)
// colorEnabled is process-global, toggled once at startup via SetColor. It
// defaults to false so output is script-safe unless a program opts in.
var colorEnabled bool
// SetColor turns colorized output on or off for Paint and Label. Call it once
// at startup (e.g. from a -color flag): color is useful in an interactive shell
// but noise when piping one-shot output into scripts.
func SetColor(on bool) { colorEnabled = on }
// ColorEnabled reports whether colorization is currently on.
func ColorEnabled() bool { return colorEnabled }
// Paint wraps s in an ANSI color when color is enabled; otherwise it returns s
// unchanged, so the word still reads in scripts and on no-color terminals.
func Paint(s, code string) string {
if !colorEnabled {
return s
}
return code + s + Reset
}
// Label wraps s in blue when color is enabled. Use it for static field labels —
// the "key=" half of a "key=value" pair — so the value stands out in normal font.
func Label(s string) string { return Paint(s, Blue) }
+130
View File
@@ -0,0 +1,130 @@
// SPDX-FileCopyrightText: (C) Copyright 2026 Pim van Pelt <pim@ipng.ch>
// SPDX-License-Identifier: Apache-2.0
package cli
import (
"context"
"fmt"
"strings"
"time"
"github.com/chzyer/readline"
)
// defaultCompleteTimeout bounds a single Dynamic lookup triggered by TAB or '?'.
const defaultCompleteTimeout = 1 * time.Second
// SplitTokens splits a command line into whitespace-separated tokens.
func SplitTokens(s string) []string { return strings.Fields(s) }
// splitForCompletion separates the confirmed prefix tokens from the partial
// token being completed, based on whether the cursor sits after a space.
func splitForCompletion(before string) (prefix []string, partial string) {
tokens := SplitTokens(before)
if len(tokens) == 0 || (len(before) > 0 && before[len(before)-1] == ' ') {
return tokens, ""
}
return tokens[:len(tokens)-1], tokens[len(tokens)-1]
}
// completer implements readline.AutoCompleter over the command tree.
type completer[C any] struct {
root *Node[C]
client C
timeout time.Duration
}
// Do returns the completion suffixes for the token at the cursor.
func (co *completer[C]) Do(line []rune, pos int) (newLine [][]rune, length int) {
prefix, partial := splitForCompletion(string(line[:pos]))
ctx, cancel := context.WithTimeout(context.Background(), co.timeout)
defer cancel()
candidates := Candidates(ctx, co.client, co.root, prefix, partial)
var suffixes [][]rune
for _, c := range candidates {
suffixes = append(suffixes, []rune(c.Word[len(partial):]+" "))
}
return suffixes, len([]rune(partial))
}
// questionListener intercepts '?' and prints inline help at the cursor.
type questionListener[C any] struct {
root *Node[C]
client C
rl *readline.Instance
timeout time.Duration
}
func (ql *questionListener[C]) OnChange(line []rune, pos int, key rune) ([]rune, int, bool) {
if key != '?' {
return line, pos, false
}
before := strings.TrimSuffix(string(line[:pos]), "?")
tokens := SplitTokens(before)
prefix, partial := splitForCompletion(before)
// Walk the confirmed prefix, then try to advance one more step using the
// partial token (via prefix-match or slot fallback), so "sh?" expands "sh"
// to "show" and shows show's subtree.
node, args, remaining := Walk(ql.root, prefix)
displayPrefix := strings.Join(prefix, " ")
var unknownMsg string
if len(remaining) > 0 {
consumed := prefix[:len(prefix)-len(remaining)]
unknownMsg = unknownCommandError(consumed, remaining[0]).Error()
displayPrefix = strings.Join(consumed, " ")
} else if partial != "" {
if next := matchFixedChild(node.Children, partial); next != nil {
node = next
displayPrefix = strings.Join(tokens, " ")
} else if slot := findSlotChild(node.Children); slot != nil {
node = slot
displayPrefix = strings.Join(tokens, " ")
}
}
lines := expandPaths(node, displayPrefix, make(map[*Node[C]]bool))
ctx, cancel := context.WithTimeout(context.Background(), ql.timeout)
defer cancel()
var dynValues []string
var dynWord string
if slot := findSlotChild(node.Children); slot != nil {
dynValues = slot.Dynamic(ctx, ql.client, args)
dynWord = slot.Word
}
maxLen := 0
for _, l := range lines {
if len(l.Path) > maxLen {
maxLen = len(l.Path)
}
}
// readline's wrapWriter erases the current input row before each Write and
// redraws the prompt after, so echo the full "prompt + line" ourselves as
// the first write: it lands on the just-cleaned row, and the help below it
// each redraws a fresh prompt.
_, _ = fmt.Fprintf(ql.rl.Stderr(), "%s%s\r\n", ql.rl.Config.Prompt, string(line))
if unknownMsg != "" {
_, _ = fmt.Fprintf(ql.rl.Stderr(), " %s\r\n", unknownMsg)
}
if len(lines) == 0 {
_, _ = fmt.Fprintf(ql.rl.Stderr(), " <no completions>\r\n")
} else {
for _, l := range lines {
if l.Help != "" {
_, _ = fmt.Fprintf(ql.rl.Stderr(), "%-*s %s\r\n", maxLen+2, l.Path, l.Help)
} else {
_, _ = fmt.Fprintf(ql.rl.Stderr(), "%s\r\n", l.Path)
}
}
if len(dynValues) > 0 {
_, _ = fmt.Fprintf(ql.rl.Stderr(), " %s: %s\r\n", dynWord, strings.Join(dynValues, " "))
}
}
// Remove the '?' from the line and step the cursor back one position.
newLine := append(append([]rune{}, line[:pos-1]...), line[pos:]...)
return newLine, pos - 1, true
}
+257
View File
@@ -0,0 +1,257 @@
<!--
SPDX-FileCopyrightText: (C) Copyright 2026 Pim van Pelt <pim@ipng.ch>
SPDX-License-Identifier: Apache-2.0
-->
# Proposal: extract the command-tree CLI into a reusable `golang-cli` package
Status: draft / for discussion. Nothing in `vpp-evpn` or `vpp-maglev` has been
changed. This document describes how to lift the command-tree CLI out of
`cmd/evpnc` (vpp-evpn) and `cmd/client` (vpp-maglev) into a standalone module at
`~/src/golang-cli`, and re-import it in both with **no functional difference**.
## 1. Why
Both projects carry the same hand-rolled CLI: a declarative command tree from
which dispatch, `?`-help, and tab-completion are all derived, driven by
`chzyer/readline`. The two copies have already drifted (see §3), and a third
copy would just cargo-cult the drift forward. The parser, the dynamic-node
mechanism, and the command registration are genuinely generic — only the gRPC
*client type* and the *tree contents* are app-specific.
## 2. The three components (as you framed them)
| Your name | Concrete artifact today | Reusable? |
|---|---|---|
| "the parsing tree" | `Node` struct + `Walk` / `matchFixedChild` / `findSlotChild` / `expandPaths` (`tree.go`) | **Yes, verbatim** (modulo client type) |
| "dynamic node function registration" | `Node.Dynamic func(ctx, client, args) []string`, resolved in `Candidates` and the `?` listener | **Yes** — this is the only real API-shape decision (see §3.1) |
| "command function registration" | `Node.Run func(ctx, client, args) error`, dispatched by `dispatch` | **Yes, verbatim** |
Everything that *consumes* the tree is also generic and moves with it:
`Completer` (readline `AutoCompleter`), `questionListener` (the `?` key handler),
`dispatch`, `showHelpAt`, `unknownCommandError`, the REPL loop (`runShell`),
`splitTokens` / `splitForCompletion`, and the OpenBSD termios shim
(`shell_term_openbsd.go` / `_default.go`).
What does **not** move — it stays in each app:
- `buildTree()` and every `run*` / `dyn*` function (`commands.go`) — the actual
command set.
- `color.go` — the `formatError` gRPC-status unwrap stays in-app (wired via
`Shell.FormatError`). The generic `label`/`paint`/`colorEnabled` half **moved
into the library** as `cli.Label`/`cli.Paint`/`cli.SetColor` (see `color.go`,
`output.go`).
- `watch.go` (gRPC server-stream consumer) — app/proto-specific. *But* its
generic half — "cancel a context on any keypress" — can optionally move (§4.3).
- `main.go` — flag parsing, gRPC dial, color-mode defaults.
## 3. What differs between the two copies today
The copies are ~95% identical. The differences, and how the library reconciles
them so neither app changes behaviour:
### 3.1 `Dynamic` signature — the one real decision
- `vpp-evpn`: `func(context.Context, pb.EvpnrClient, []string) []string` — the
trailing `[]string` is the args captured by earlier slot nodes, so `<evpn>`
can list the EVPNs *of the `<id>` already typed*.
- `vpp-maglev`: `func(context.Context, grpcapi.MaglevClient) []string` — no args.
The evpn signature is a strict superset. **The library adopts the args-bearing
form.** maglev's `dyn*` functions gain an ignored `_ []string` parameter — a
purely mechanical edit, zero behaviour change.
### 3.2 Client type — solved with generics
Both projects are `go 1.25`, so generics are available. The package is generic
over the client type `C`:
```go
type Node[C any] struct {
Word string
Help string
Dynamic func(context.Context, C, []string) []string // non-nil => slot node
Children []*Node[C]
Run func(context.Context, C, []string) error
}
```
`C` is `pb.EvpnrClient` in evpnc and `grpcapi.MaglevClient` in maglevc. Run/Dynamic
funcs receive the concrete client — **no `any`, no type assertions** in app code.
(Alternative considered: a non-generic `any` client with assertions in every
`run*`. Rejected — it pushes boilerplate and runtime panics into the apps. See §7.)
### 3.3 Cosmetic drift the library erases (all no-ops behaviourally)
- `Candidates` arg order: evpnc `(ctx, client, root, tokens, partial)` vs maglev
`(root, tokens, partial, ctx, client)`. Library picks the evpnc order.
- evpnc's `Candidates` passes captured `args` to `Dynamic`; maglev discards them
(`node, _, remaining`). Library passes them (superset; maglev's funcs ignore).
- `RunShell`: evpnc calls `applyTermFuncs(cfg)` (OpenBSD fix), maglev does not.
Library always calls it — it's a no-op off OpenBSD, so maglev gains the fix
with no change on Linux/macOS.
- `splitTokens` lives in `complete.go` (maglev) vs `complete.go` (evpnc) — same
body; library exports one `SplitTokens`.
- The `?` listener's partial-token branch carries different comments but identical
logic; unified.
## 4. Proposed package shape
Module: `git.ipng.ch/ipng/golang-cli` (matches your git host). Single package
`cli` to start; the optional keypress helper can be a subpackage.
```
~/src/golang-cli/
go.mod module git.ipng.ch/ipng/golang-cli, go 1.25
go.sum
LICENSE Apache-2.0 (matches the SPDX headers already in the files)
README.md
tree.go Node[C], Walk, matchFixedChild, findSlotChild, expandPaths, Candidates
tree_test.go table tests over a fixture tree (no client needed; Walk ignores Dynamic)
complete.go completer[C], questionListener[C], SplitTokens, splitForCompletion
shell.go Shell[C] (config + Run), Dispatch, showHelpAt, unknownCommandError, ErrQuit
color.go SetColor, Paint, Label + the ANSI palette (Red/Green/Blue/Yellow/Cyan)
output.go SetFormat, Emit (text-or-JSON), KV, IsJSON
term_openbsd.go applyTermFuncs (//go:build openbsd) <- from shell_term_openbsd.go
term_default.go applyTermFuncs no-op (//go:build !openbsd) <- from shell_term_default.go
example/main.go self-contained, dependency-free demo CLI
keypress/ (optional) WaitForKeypress + cbreak shim, per-GOOS <- §4.3
```
### 4.1 The one new type: `Shell[C]`
The REPL loop today hard-codes the prompt, the banner, the `errQuit` sentinel,
and `formatError`. Those become a small config struct so each app keeps its
exact strings and error rendering:
```go
type Shell[C any] struct {
Root *Node[C]
Client C
Prompt string // "evpn> " / "maglev> "
FormatError func(error) string // default: err.Error(); evpnc/maglev pass their gRPC-desc unwrap
}
func (s *Shell[C]) Run(ctx context.Context) error // the readline REPL loop
func Dispatch[C any](ctx context.Context, root *Node[C], client C, tokens []string) error
var ErrQuit = errors.New("quit") // a quit/exit Run func returns this; Run() stops on it
```
Banner printing stays in `main.go` (it needs `buildinfo.Version()` etc., which
is app-specific) — `main` prints the banner, then calls `shell.Run(ctx)`.
`FormatError` is how each app keeps its gRPC `desc =` unwrap + red coloring
without the library depending on gRPC at all.
### 4.2 What the app's `main.go` looks like after (evpnc)
```go
root := buildTree() // returns *cli.Node[pb.EvpnrClient]
if len(args) == 0 {
fmt.Printf("evpnc %s ...\n", buildinfo.Version(), ...) // banner
return (&cli.Shell[pb.EvpnrClient]{
Root: root, Client: client, Prompt: "evpn> ", FormatError: formatError,
}).Run(ctx)
}
return cli.Dispatch(ctx, root, client, cli.SplitTokens(strings.Join(args, " ")))
```
`buildTree`, `runQuit` (`return cli.ErrQuit`), all `run*`/`dyn*`, `color.go`,
`watch.go` are unchanged except `Node``cli.Node[pb.EvpnrClient]` and the
`dyn*` signature tweak in maglev.
### 4.3 Optional: the keypress helper
`watch.go` in both apps shares "put stdin in cbreak, cancel ctx on any key", with
per-GOOS termios ioctls (`watch_linux.go` / `watch_bsd.go` in evpnc; inline in
maglev). The proto-streaming half is app-specific and stays, but
`WaitForKeypress(ctx, cancel)` + the cbreak shim are reusable. Suggest a
`cli/keypress` subpackage so apps don't recopy the ioctl tables. Low priority —
it's the least-drifted, most-self-contained piece. Can land in a follow-up.
## 5. Migration plan
**Phase 0 — stand up the module (no app changes).**
1. `git init ~/src/golang-cli`; `go mod init git.ipng.ch/ipng/golang-cli`; `go 1.25`.
2. Copy `tree.go`, `complete.go`, `shell.go` pieces, `shell_term_*.go` in.
Make `Node`, `Walk`, `Candidates`, `Completer`, `questionListener`, `Dispatch`,
`Shell` generic over `C`. Add `Shell.FormatError` and `ErrQuit`.
3. Port `tree_test.go` to a self-contained fixture tree (a tiny `Node[*fakeClient]`
with a couple of slot nodes + a circular watch-opts node) so the package tests
`Walk`/`expandPaths`/`Candidates` with no gRPC dependency. `go test ./...` green.
4. Dependencies: `github.com/chzyer/readline`, `golang.org/x/sys/unix` (both
already in each app's `go.sum`). Tag `v0.1.0`.
**Phase 1 — convert vpp-evpn (the args-bearing reference, less work).**
1. `go get git.ipng.ch/ipng/golang-cli@v0.1.0`; during local dev add a
`replace git.ipng.ch/ipng/golang-cli => ../golang-cli` to iterate.
2. Delete `tree.go`, `complete.go`, `shell.go` body, `shell_term_*.go` from
`cmd/evpnc`. Keep `commands.go`, `color.go`, `watch*.go`, `main.go`.
3. Replace `*Node` with `*cli.Node[pb.EvpnrClient]`; `runQuit` returns
`cli.ErrQuit`; `main` builds a `cli.Shell` (§4.2). `Walk`/`expandPaths` refs in
`watch.go`? none — good.
4. Run the existing checkpoint: `make fixstyle test lint vet`. The
`cmd/evpnc/tree_test.go` `TestWalk`/`TestExpandPaths` move to exercising
`buildTree()` through the library's exported `Walk`/`ExpandPaths` (rename as
needed) — they assert the *app's* tree, so they stay in the app.
**Phase 2 — convert vpp-maglev.**
1. Same wiring. Plus the mechanical edits from §3.1: add `_ []string` to every
`dyn*`; `Candidates`/`Dynamic` call sites now come from the library.
2. maglev's `main.go` color-default logic is equivalent to evpnc's; unchanged.
3. `make` checkpoint green.
**Phase 3 — drop the replace directives, tag a real version, pin both apps.**
## 6. Risks / things to verify during the port
- **Generics + method values.** `Node[C]` is fine; the only subtlety is that
`Completer[C]` and `questionListener[C]` must carry the type param through to
their `readline` interface methods. `readline.AutoCompleter` / `Listener` are
non-generic interfaces, but a concrete `*Completer[pb.EvpnrClient]` satisfies
them — verified shape, no reflection needed.
- **The `?` echo trick** (`questionListener.OnChange` writing the prompt+line to
`rl.Stderr()` before help) depends on readline's wrapWriter behaviour. It's
copied verbatim; no change.
- **OpenBSD.** evpnc has the termios shim and a `make pkg-openbsd`; the library
must keep the `//go:build openbsd` split so maglev gets it without pulling
OpenBSD code into its Linux builds. Verify a `GOOS=openbsd go build` of the
library.
- **No hidden client coupling.** `Walk`/`expandPaths` never touch the client
(the existing `TestWalk` comment confirms it) — so they could even be
non-generic with `Dynamic any`. Keeping them generic is simpler and uniform.
## 7. Open decisions for you
1. **Generics vs `any` client.** Recommended: generics (§3.2) — type-safe,
zero boilerplate in apps. The cost is `[]*cli.Node[pb.EvpnrClient]` verbosity
in `commands.go`; a per-app `type node = cli.Node[pb.EvpnrClient]` alias hides
it entirely.
2. **Module host/name.** `git.ipng.ch/ipng/golang-cli` (consistent) vs a GitHub
mirror if you want it public. Package name `cli` vs something less likely to
collide (`cmdtree`, `cmdline`).
3. **Scope of v0.1.** Minimum = tree + completion + shell. Stretch = the
`keypress` subpackage (§4.3) and maybe a tiny `color` helper if a third client
wants it. Suggest shipping the minimum first; both apps prove the API before
it ossifies.
4. **Whose `Dynamic` shape wins** — already answered (args-bearing, §3.1) unless
you'd rather keep maglev argless and have evpnc pass args by closure capture.
The superset is cleaner.
## 8. Bottom line
"What would it take to make this its own standalone package?" — concretely:
1. A new `go 1.25` module with three files made generic over the client type
(`tree.go`, `complete.go`, `shell.go`) plus the two OpenBSD term files and a
self-contained test — ~1 day including tests.
2. One genuine API choice (args-bearing `Dynamic`, generic `C`) and one new
config type (`Shell[C]` with an injectable `FormatError`). Everything else is
moved verbatim.
3. Mechanical edits in each app: swap `Node` for `cli.Node[ClientT]`, route the
REPL through `cli.Shell`, return `cli.ErrQuit` from quit, and (maglev only)
add an ignored args param to each `dyn*`. No behaviour changes; both `make`
checkpoints stay green.
The tree, the dynamic-node mechanism, and the command/run registration are all
already cleanly separated in these files — the refactor is mostly *relocation +
generics*, not redesign.
+222
View File
@@ -0,0 +1,222 @@
// SPDX-FileCopyrightText: (C) Copyright 2026 Pim van Pelt <pim@ipng.ch>
// SPDX-License-Identifier: Apache-2.0
// Command example is a self-contained demo of git.ipng.ch/ipng/golang-cli.
//
// It builds a tiny "server inventory" CLI over an in-memory client — no gRPC,
// no external services. It shows the building blocks of the package:
//
// - a declarative command tree (buildTree);
// - dynamic slot resolvers, including a context-dependent one (dynServices
// lists the services of the <server> captured earlier on the path);
// - command functions that hand one result to cli.Emit, which renders it as
// colorized text or, under -json, as JSON — the command supplies both;
// - the color helpers: cli.KV paints the "key=" of a key=value pair blue and
// cli.Paint colors status words, both honoring the -color flag.
//
// Run it interactively (TAB to complete, '?' for help):
//
// go run ./example
// inv> show server <TAB> # completes to web1 / web2 / db1
// inv> show server web1 service ? # lists web1's services
// inv> colors # the ANSI palette
//
// Or one-shot — text or JSON:
//
// go run ./example show server web1
// go run ./example -json show server web1
// go run ./example -color=false show server web1
package main
import (
"context"
"flag"
"fmt"
"os"
"sort"
"strings"
cli "git.ipng.ch/ipng/golang-cli"
)
// inventory is the "client" C that the tree is generic over. In a real app this
// would be a gRPC client; here it is just in-memory data. It is passed unchanged
// to every Dynamic and Run function.
type inventory struct {
// servers maps a server name to the services running on it.
servers map[string][]string
}
func newInventory() inventory {
return inventory{servers: map[string][]string{
"web1": {"http", "https", "ssh"},
"web2": {"http", "https"},
"db1": {"postgres", "ssh"},
}}
}
func (inv inventory) names() []string {
out := make([]string, 0, len(inv.servers))
for n := range inv.servers {
out = append(out, n)
}
sort.Strings(out)
return out
}
// --- dynamic resolvers -------------------------------------------------------
// dynServers lists every server name. args is unused here.
func dynServers(_ context.Context, inv inventory, _ []string) []string {
return inv.names()
}
// dynServices is context-dependent: it lists the services of the server that
// was captured as args[0] earlier on the path. This is what lets
// "show server web1 service <TAB>" offer only web1's services.
func dynServices(_ context.Context, inv inventory, args []string) []string {
if len(args) == 0 {
return nil
}
svcs := append([]string(nil), inv.servers[args[0]]...)
sort.Strings(svcs)
return svcs
}
// --- command functions -------------------------------------------------------
//
// Each hands cli.Emit a text form and a machine value; the framework prints one
// or the other based on -json. Text uses cli.KV (blue "key=") and cli.Paint.
func runShowServers(_ context.Context, inv inventory, _ []string) error {
names := inv.names()
return cli.Emit(cli.KV("servers", strings.Join(names, ", ")),
map[string]any{"servers": names})
}
type serverView struct {
Name string `json:"name"`
Count int `json:"count"`
Services []string `json:"services"`
}
func runShowServer(_ context.Context, inv inventory, args []string) error {
name := args[0]
svcs, ok := inv.servers[name]
if !ok {
return fmt.Errorf("no such server: %s", name)
}
text := fmt.Sprintf("%s %s %s",
cli.KV("name", name),
cli.KV("count", fmt.Sprintf("%d", len(svcs))),
cli.KV("services", strings.Join(svcs, ", ")))
return cli.Emit(text, serverView{Name: name, Count: len(svcs), Services: svcs})
}
func runShowServerServices(_ context.Context, inv inventory, args []string) error {
name := args[0]
text := fmt.Sprintf("%s %s", cli.KV("server", name), cli.KV("services", strings.Join(inv.servers[name], ", ")))
return cli.Emit(text, map[string]any{"server": name, "services": inv.servers[name]})
}
func runShowServerService(_ context.Context, inv inventory, args []string) error {
server, svc := args[0], args[1]
for _, s := range inv.servers[server] {
if s == svc {
text := cli.KV(server+"/"+svc, cli.Paint("running", cli.Green))
return cli.Emit(text, map[string]any{"server": server, "service": svc, "running": true})
}
}
return fmt.Errorf("%s is not running %s", server, svc)
}
func runPing(_ context.Context, inv inventory, args []string) error {
name := args[0]
if _, ok := inv.servers[name]; !ok {
return fmt.Errorf("no such server: %s", name)
}
return cli.Emit(fmt.Sprintf("pong from %s", cli.Paint(name, cli.Green)),
map[string]any{"server": name, "reply": "pong"})
}
// runColors demonstrates the palette. Each line shows a value painted with one
// of the standard colors; with -color=false the same text prints unadorned.
func runColors(context.Context, inventory, []string) error {
fmt.Println("status words via Paint (try -color=false to disable):")
fmt.Println(" " + cli.KV("status", cli.Paint("OK", cli.Green)))
fmt.Println(" " + cli.KV("status", cli.Paint("DEGRADED", cli.Yellow)))
fmt.Println(" " + cli.KV("status", cli.Paint("DOWN", cli.Red)))
fmt.Println(" " + cli.KV("note", cli.Paint("highlight", cli.Cyan)))
fmt.Println("the raw helpers:")
fmt.Printf(" Paint(%q, cli.Red) => %s\n", "text", cli.Paint("text", cli.Red))
fmt.Printf(" Paint(%q, cli.Green) => %s\n", "text", cli.Paint("text", cli.Green))
fmt.Printf(" Paint(%q, cli.Blue) => %s\n", "text", cli.Paint("text", cli.Blue))
fmt.Printf(" Paint(%q, cli.Yellow) => %s\n", "text", cli.Paint("text", cli.Yellow))
fmt.Printf(" KV(%q, %q) => %s\n", "key", "value", cli.KV("key", "value"))
return nil
}
func runQuit(context.Context, inventory, []string) error { return cli.ErrQuit }
// buildTree is the single source of truth for the command set: dispatch, help,
// and tab-completion are all derived from it.
func buildTree() *cli.Node[inventory] {
// show server [<name> [service [<svc>]]]
serviceName := &cli.Node[inventory]{Word: "<svc>", Help: "show one service", Dynamic: dynServices, Run: runShowServerService}
service := &cli.Node[inventory]{Word: "service", Help: "list a server's services", Run: runShowServerServices, Children: []*cli.Node[inventory]{serviceName}}
serverName := &cli.Node[inventory]{Word: "<name>", Help: "show one server", Dynamic: dynServers, Run: runShowServer, Children: []*cli.Node[inventory]{service}}
server := &cli.Node[inventory]{Word: "server", Help: "list servers (add <name> for one)", Run: runShowServers, Children: []*cli.Node[inventory]{serverName}}
show := &cli.Node[inventory]{Word: "show", Help: "show inventory state", Children: []*cli.Node[inventory]{server}}
// ping <server>
ping := &cli.Node[inventory]{Word: "ping", Help: "ping a server", Children: []*cli.Node[inventory]{
{Word: "<name>", Help: "ping this server", Dynamic: dynServers, Run: runPing},
}}
return &cli.Node[inventory]{Children: []*cli.Node[inventory]{
show,
ping,
{Word: "colors", Help: "show the ANSI color palette", Run: runColors},
{Word: "quit", Help: "exit the shell", Run: runQuit},
{Word: "exit", Help: "exit the shell", Run: runQuit},
}}
}
func main() {
color := flag.Bool("color", true, "colorize output: blue key= labels and painted status words")
jsonOut := flag.Bool("json", false, "emit JSON instead of text (one-shot mode)")
flag.Parse()
if *jsonOut {
cli.SetFormat(cli.FormatJSON)
}
// JSON output is machine-facing, so suppress ANSI escapes in that mode.
cli.SetColor(*color && !*jsonOut)
inv := newInventory()
root := buildTree()
ctx := context.Background()
// With arguments: run one command and exit (script-friendly).
if args := flag.Args(); len(args) > 0 {
if err := cli.Dispatch(ctx, root, inv, cli.SplitTokens(strings.Join(args, " "))); err != nil {
fmt.Fprintln(os.Stderr, cli.Paint(err.Error(), cli.Red))
os.Exit(1)
}
return
}
// No arguments: interactive REPL with completion and '?'-help. Errors are
// painted red via FormatError.
fmt.Println("golang-cli example — try: show server, ping db1, colors, '?' for help, TAB to complete")
shell := &cli.Shell[inventory]{
Root: root,
Client: inv,
Prompt: "inv> ",
FormatError: func(err error) string { return cli.Paint(err.Error(), cli.Red) },
}
if err := shell.Run(ctx); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
+8
View File
@@ -0,0 +1,8 @@
module git.ipng.ch/ipng/golang-cli
go 1.25.0
require (
github.com/chzyer/readline v1.5.1
golang.org/x/sys v0.45.0
)
+9
View File
@@ -0,0 +1,9 @@
github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM=
github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ=
github.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI=
github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk=
github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04=
github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8=
golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+64
View File
@@ -0,0 +1,64 @@
// SPDX-FileCopyrightText: (C) Copyright 2026 Pim van Pelt <pim@ipng.ch>
// SPDX-License-Identifier: Apache-2.0
package cli
import (
"encoding/json"
"fmt"
"os"
)
// Format selects how command results are rendered. It is process-global,
// toggled once at startup via SetFormat (parallel to SetColor), so command
// functions can stay simple: they describe a result once and the framework
// renders it the chosen way.
type Format int
const (
// FormatText renders the human-readable string a command passes to Emit.
FormatText Format = iota
// FormatJSON marshals the machine value a command passes to Emit and
// ignores the text. Color is irrelevant in this mode.
FormatJSON
)
var outputFormat Format
// SetFormat selects the output format for Emit. Call it once at startup, e.g.
// from a -json flag.
func SetFormat(f Format) { outputFormat = f }
// OutputFormat reports the current output format.
func OutputFormat() Format { return outputFormat }
// IsJSON reports whether output is in JSON mode. Commands can use it to skip
// building the (then-unused) text representation, or to suppress banners.
func IsJSON() bool { return outputFormat == FormatJSON }
// Emit renders one command result to stdout. In FormatText mode it prints text
// — which the caller may have colorized with Paint/Label/KV — followed by a
// newline. In FormatJSON mode it marshals v (indented) and ignores text. The
// caller supplies both: text is the human form, v is the machine form. This is
// the single seam that lets the same command serve "show foo" and "-json show
// foo". Returns an error only if JSON marshaling fails.
//
// For multi-record output, marshal a slice as v and join the text yourself;
// Emit is one call per logical result so the JSON stays a single document.
func Emit(text string, v any) error {
if outputFormat == FormatJSON {
b, err := json.MarshalIndent(v, "", " ")
if err != nil {
return fmt.Errorf("marshal json: %w", err)
}
_, _ = fmt.Fprintln(os.Stdout, string(b))
return nil
}
_, _ = fmt.Fprintln(os.Stdout, text)
return nil
}
// KV renders "key=value" with the key (and the '=') painted blue when color is
// enabled, leaving the value in normal font. It is the building block for
// one-line text output and pairs with Emit's text argument.
func KV(key, value string) string { return Label(key+"=") + value }
+140
View File
@@ -0,0 +1,140 @@
// SPDX-FileCopyrightText: (C) Copyright 2026 Pim van Pelt <pim@ipng.ch>
// SPDX-License-Identifier: Apache-2.0
package cli
import (
"context"
"errors"
"fmt"
"io"
"strings"
"time"
"github.com/chzyer/readline"
)
// ErrQuit is the sentinel a quit/exit command returns from its Run function to
// stop the REPL. Shell.Run treats it (via errors.Is) as a clean exit, not an
// error to print.
var ErrQuit = errors.New("quit")
// Shell runs an interactive REPL over a command tree: readline-based input with
// tab-completion, '?'-help, prefix abbreviation, and prompt. Build it with a
// Root tree and a Client, then call Run.
type Shell[C any] struct {
// Root is the command tree. Required.
Root *Node[C]
// Client is passed unchanged to every Dynamic and Run function. Required.
Client C
// Prompt is the readline prompt, e.g. "evpn> ".
Prompt string
// FormatError renders a command error for display. Defaults to err.Error().
// Apps typically use this to unwrap a gRPC status to its message and color it.
FormatError func(error) string
// CompleteTimeout bounds a single Dynamic lookup for TAB/'?'. Defaults to 1s.
CompleteTimeout time.Duration
}
// Run starts the REPL and blocks until the user quits (a Run returns ErrQuit),
// hits EOF (Ctrl-D), or readline errors. ctx is passed to every command.
func (s *Shell[C]) Run(ctx context.Context) error {
formatErr := s.FormatError
if formatErr == nil {
formatErr = func(err error) string { return err.Error() }
}
timeout := s.CompleteTimeout
if timeout == 0 {
timeout = defaultCompleteTimeout
}
comp := &completer[C]{root: s.Root, client: s.Client, timeout: timeout}
ql := &questionListener[C]{root: s.Root, client: s.Client, timeout: timeout}
cfg := &readline.Config{
Prompt: s.Prompt,
AutoComplete: comp,
InterruptPrompt: "^C",
EOFPrompt: "exit",
Listener: ql,
}
// On OpenBSD, route terminal control through golang.org/x/sys/unix;
// readline's own termios path is broken there. No-op elsewhere.
applyTermFuncs(cfg)
rl, err := readline.NewEx(cfg)
if err != nil {
return fmt.Errorf("readline init: %w", err)
}
ql.rl = rl
defer func() { _ = rl.Close() }()
for {
line, err := rl.Readline()
if err == readline.ErrInterrupt {
continue
}
if err == io.EOF {
return nil
}
if err != nil {
return err
}
tokens := SplitTokens(line)
if len(tokens) == 0 {
continue
}
if err := Dispatch(ctx, s.Root, s.Client, tokens); err != nil {
if errors.Is(err, ErrQuit) {
return nil
}
_, _ = fmt.Fprintf(rl.Stderr(), "%s\n", formatErr(err))
}
}
}
// Dispatch walks the tree and executes the matched command, or prints the
// reachable leaves (help) if the matched node is not runnable. Use it directly
// for one-shot, non-interactive invocation.
func Dispatch[C any](ctx context.Context, root *Node[C], client C, tokens []string) error {
node, args, remaining := Walk(root, tokens)
if len(remaining) > 0 {
consumed := tokens[:len(tokens)-len(remaining)]
return unknownCommandError(consumed, remaining[0])
}
if node.Run == nil {
showHelpAt(node, strings.Join(tokens, " "))
return nil
}
return node.Run(ctx, client, args)
}
func unknownCommandError(consumed []string, bad string) error {
if len(consumed) == 0 {
return fmt.Errorf("unknown command: %s", bad)
}
return fmt.Errorf("unknown subcommand %q after %q", bad, strings.Join(consumed, " "))
}
// showHelpAt prints the reachable leaves below node, each with the given prefix,
// to stdout.
func showHelpAt[C any](node *Node[C], prefix string) {
lines := ExpandPaths(node, prefix)
if len(lines) == 0 {
fmt.Println(" <no completions>")
return
}
maxLen := 0
for _, l := range lines {
if len(l.Path) > maxLen {
maxLen = len(l.Path)
}
}
for _, l := range lines {
if l.Help != "" {
fmt.Printf("%-*s %s\n", maxLen+2, l.Path, l.Help)
} else {
fmt.Printf("%s\n", l.Path)
}
}
}
+13
View File
@@ -0,0 +1,13 @@
// SPDX-FileCopyrightText: (C) Copyright 2026 Pim van Pelt <pim@ipng.ch>
// SPDX-License-Identifier: Apache-2.0
//go:build !openbsd
package cli
import "github.com/chzyer/readline"
// applyTermFuncs is a no-op on every platform except OpenBSD, where readline's
// native termios handling is broken and needs an override (see term_openbsd.go).
// Elsewhere readline's defaults work as-is.
func applyTermFuncs(_ *readline.Config) {}
+68
View File
@@ -0,0 +1,68 @@
// SPDX-FileCopyrightText: (C) Copyright 2026 Pim van Pelt <pim@ipng.ch>
// SPDX-License-Identifier: Apache-2.0
//go:build openbsd
package cli
import (
"os"
"github.com/chzyer/readline"
"golang.org/x/sys/unix"
)
// applyTermFuncs makes readline drive the terminal through golang.org/x/sys/unix.
//
// chzyer/readline's own termios handling (term_bsd.go) issues raw
// syscall.Syscall6(SYS_IOCTL, ...) calls. OpenBSD forbids direct syscalls from
// outside libc, so those return ENOSYS: readline's IsTerminal() then reports
// false and the REPL silently degrades to a dumb line reader -- no prompt, no
// tab completion. x/sys/unix performs the same ioctls through libc.
//
// The raw-mode flag set below is copied verbatim from readline's own MakeRaw
// (term.go), and that match matters: it deliberately leaves OPOST enabled, so a
// command's "\n" output is still translated to "\r\n". A full cfmakeraw (e.g.
// golang.org/x/term.MakeRaw) clears OPOST and staircases multi-line output.
// Only OpenBSD needs this; every other platform keeps readline's native path.
func applyTermFuncs(cfg *readline.Config) {
stdin := int(os.Stdin.Fd())
stdout := int(os.Stdout.Fd())
var saved *unix.Termios
cfg.FuncIsTerminal = func() bool {
_, err := unix.IoctlGetTermios(stdin, unix.TIOCGETA)
return err == nil
}
cfg.FuncMakeRaw = func() error {
old, err := unix.IoctlGetTermios(stdin, unix.TIOCGETA)
if err != nil {
return err
}
saved = old
raw := *old
raw.Iflag &^= unix.IGNBRK | unix.BRKINT | unix.PARMRK | unix.ISTRIP | unix.INLCR | unix.IGNCR | unix.ICRNL | unix.IXON
// OPOST is intentionally left set (see the doc comment above).
raw.Lflag &^= unix.ECHO | unix.ECHONL | unix.ICANON | unix.ISIG | unix.IEXTEN
raw.Cflag &^= unix.CSIZE | unix.PARENB
raw.Cflag |= unix.CS8
raw.Cc[unix.VMIN] = 1
raw.Cc[unix.VTIME] = 0
return unix.IoctlSetTermios(stdin, unix.TIOCSETA, &raw)
}
cfg.FuncExitRaw = func() error {
if saved == nil {
return nil
}
err := unix.IoctlSetTermios(stdin, unix.TIOCSETA, saved)
saved = nil
return err
}
cfg.FuncGetWidth = func() int {
ws, err := unix.IoctlGetWinsize(stdout, unix.TIOCGWINSZ)
if err != nil || ws.Col == 0 {
return 80 // sane default when the width can't be read
}
return int(ws.Col)
}
}
+168
View File
@@ -0,0 +1,168 @@
// SPDX-FileCopyrightText: (C) Copyright 2026 Pim van Pelt <pim@ipng.ch>
// SPDX-License-Identifier: Apache-2.0
// Package cli is a command-line interface library built around a declarative
// command tree from which dispatch, "?"-help, and tab-completion are all
// derived, with optional colorized or JSON output. It is
// generic over a client type C (typically a gRPC client) that is threaded,
// unchanged, into every Dynamic and Run function so command code receives the
// concrete client with no type assertions.
//
// The three building blocks:
//
// - the parse tree — Node, plus Walk to resolve a token list to a node;
// - dynamic nodes — Node.Dynamic, a slot that yields live completion
// candidates given the args captured by earlier slots on the path;
// - command functions — Node.Run, dispatched by Dispatch / Shell.Run.
//
// Build the tree once and hand it to Shell (interactive REPL) or Dispatch
// (one-shot). See the package README for a worked example.
package cli
import (
"context"
"strings"
)
// Node is one word in the command tree. Leaf nodes have a Run function. Slot
// nodes have Dynamic set (and a placeholder Word like "<id>"); they accept any
// single token as an argument and may have further Children. Dynamic returns
// the live completion candidates for the slot, given the args captured by slot
// nodes earlier on the path (so e.g. "<evpn>" can list the EVPN instances of
// the "<id>" already typed).
type Node[C any] struct {
Word string
Help string
Dynamic func(context.Context, C, []string) []string // non-nil => slot node
Children []*Node[C]
Run func(context.Context, C, []string) error
}
// Walk descends the tree following tokens. At each step it tries fixed
// children first (exact then unique prefix), then falls back to a slot child.
// Tokens consumed by slot children are collected as args. Returns the deepest
// node reached, the args collected, and any tokens that could not be matched.
// A non-empty remaining slice means the input held a token that neither matched
// a fixed child nor fed a slot — callers should treat that as "unknown command"
// rather than silently anchoring help at the root. Walk never invokes Dynamic,
// so it needs no client.
func Walk[C any](root *Node[C], tokens []string) (node *Node[C], args, remaining []string) {
node = root
for len(tokens) > 0 {
tok := tokens[0]
if next := matchFixedChild(node.Children, tok); next != nil {
node = next
tokens = tokens[1:]
continue
}
if slot := findSlotChild(node.Children); slot != nil {
args = append(args, tok)
tokens = tokens[1:]
node = slot
continue
}
break // dead end; unconsumed tail returned to caller
}
return node, args, tokens
}
// matchFixedChild returns the non-slot child matching tok by exact, then unique
// prefix.
func matchFixedChild[C any](children []*Node[C], tok string) *Node[C] {
var fixed []*Node[C]
for _, c := range children {
if c.Dynamic == nil {
fixed = append(fixed, c)
}
}
for _, c := range fixed {
if c.Word == tok {
return c
}
}
var matches []*Node[C]
for _, c := range fixed {
if strings.HasPrefix(c.Word, tok) {
matches = append(matches, c)
}
}
if len(matches) == 1 {
return matches[0]
}
return nil
}
// findSlotChild returns the first slot child (Dynamic != nil).
func findSlotChild[C any](children []*Node[C]) *Node[C] {
for _, c := range children {
if c.Dynamic != nil {
return c
}
}
return nil
}
// HelpLine is a (path, help) pair produced by ExpandPaths for "?"-help and the
// not-runnable-node help listing.
type HelpLine struct {
Path string
Help string
}
// ExpandPaths returns a HelpLine for every runnable node reachable from node,
// each path prefixed with prefix (e.g. "show instance"). It guards against
// self-referencing slot nodes (such as a circular watch-options slot).
func ExpandPaths[C any](node *Node[C], prefix string) []HelpLine {
return expandPaths(node, prefix, make(map[*Node[C]]bool))
}
func expandPaths[C any](node *Node[C], prefix string, visited map[*Node[C]]bool) []HelpLine {
if visited[node] {
return nil
}
visited[node] = true
var lines []HelpLine
if node.Run != nil {
lines = append(lines, HelpLine{Path: prefix, Help: node.Help})
}
for _, child := range node.Children {
cp := child.Word
if prefix != "" {
cp = prefix + " " + child.Word
}
lines = append(lines, expandPaths(child, cp, visited)...)
}
return lines
}
// Candidates returns the completable children at the current position given the
// confirmed tokens and the partial token being completed. Fixed children are
// filtered by partial; if a slot child is present its Dynamic values (resolved
// with the args captured while walking the confirmed tokens) are appended.
// Returns nil if a confirmed token was unknown, so completion offers nothing
// rather than misleading the user past a broken left context.
func Candidates[C any](ctx context.Context, client C, root *Node[C], tokens []string, partial string) []*Node[C] {
node, args, remaining := Walk(root, tokens)
if len(remaining) > 0 {
return nil
}
matches := filterFixedChildren(node.Children, partial)
if slot := findSlotChild(node.Children); slot != nil {
for _, v := range slot.Dynamic(ctx, client, args) {
if strings.HasPrefix(v, partial) {
matches = append(matches, &Node[C]{Word: v, Help: slot.Help})
}
}
}
return matches
}
func filterFixedChildren[C any](children []*Node[C], prefix string) []*Node[C] {
var out []*Node[C]
for _, c := range children {
if c.Dynamic == nil && strings.HasPrefix(c.Word, prefix) {
out = append(out, c)
}
}
return out
}
+205
View File
@@ -0,0 +1,205 @@
// SPDX-FileCopyrightText: (C) Copyright 2026 Pim van Pelt <pim@ipng.ch>
// SPDX-License-Identifier: Apache-2.0
package cli
import (
"context"
"sort"
"strings"
"testing"
)
// fakeClient stands in for an app's gRPC client. The fixture tree's Dynamic
// funcs read from it, proving the client is threaded through unchanged and that
// Dynamic sees the args captured by earlier slot nodes.
type fakeClient struct {
instances []string
evpns map[string][]string // per-instance EVPN names
}
func dynInstances(_ context.Context, c fakeClient, _ []string) []string {
return c.instances
}
// dynEvpns demonstrates context-dependent lookup: it lists the EVPNs of the
// instance captured as args[0] earlier on the path.
func dynEvpns(_ context.Context, c fakeClient, args []string) []string {
if len(args) == 0 {
return nil
}
return c.evpns[args[0]]
}
func dynWatchOpts(_ context.Context, _ fakeClient, _ []string) []string {
return []string{"num", "log", "crud"}
}
func runNoop(context.Context, fakeClient, []string) error { return nil }
func runQuit(context.Context, fakeClient, []string) error { return ErrQuit }
// buildFixture mirrors the real apps' tree shape: singular keyword nodes that
// double as "list" and "show one", a context-dependent slot, a circular
// watch-options slot, and a quit leaf.
func buildFixture() *Node[fakeClient] {
showInstanceEvpnName := &Node[fakeClient]{Word: "<evpn>", Help: "show one EVPN", Dynamic: dynEvpns, Run: runNoop}
showInstanceEvpn := &Node[fakeClient]{Word: "evpn", Help: "list the member's EVPNs", Run: runNoop, Children: []*Node[fakeClient]{showInstanceEvpnName}}
showInstanceName := &Node[fakeClient]{Word: "<id>", Help: "show one member", Dynamic: dynInstances, Run: runNoop, Children: []*Node[fakeClient]{showInstanceEvpn}}
show := &Node[fakeClient]{Word: "show", Help: "show state", Children: []*Node[fakeClient]{
{Word: "instance", Help: "list fleet members (add <id> for one)", Run: runNoop, Children: []*Node[fakeClient]{showInstanceName}},
}}
// Circular watch-options slot: every option captured as an arg and may be
// followed by another option.
watchOpt := &Node[fakeClient]{Word: "<opts>", Dynamic: dynWatchOpts, Run: runNoop}
watchOpt.Children = []*Node[fakeClient]{watchOpt}
watch := &Node[fakeClient]{Word: "watch", Help: "stream events", Children: []*Node[fakeClient]{
{Word: "events", Help: "watch events", Run: runNoop, Children: []*Node[fakeClient]{watchOpt}},
}}
return &Node[fakeClient]{Children: []*Node[fakeClient]{
show,
watch,
{Word: "quit", Help: "exit", Run: runQuit},
}}
}
func TestWalk(t *testing.T) {
root := buildFixture()
cases := []struct {
toks []string
runnable bool
help string
args []string
remaining int
}{
{[]string{"show", "instance"}, true, "list fleet members (add <id> for one)", nil, 0},
{[]string{"show", "instance", "host1"}, true, "show one member", []string{"host1"}, 0},
{[]string{"show", "instance", "host1", "evpn"}, true, "list the member's EVPNs", []string{"host1"}, 0},
{[]string{"show", "instance", "host1", "evpn", "blue"}, true, "show one EVPN", []string{"host1", "blue"}, 0},
// prefix abbreviation: singular, unique keywords resolve from a prefix.
{[]string{"sh", "inst"}, true, "list fleet members (add <id> for one)", nil, 0},
// circular watch options are captured as args.
{[]string{"watch", "events", "num", "5", "crud"}, true, "", []string{"num", "5", "crud"}, 0},
// unknown trailing token is reported as remaining.
{[]string{"show", "bogus"}, false, "", nil, 1},
// "show" itself is not runnable and consumes everything.
{[]string{"show"}, false, "", nil, 0},
}
for _, tc := range cases {
node, args, remaining := Walk(root, tc.toks)
if len(remaining) != tc.remaining {
t.Errorf("Walk(%v) remaining=%v, want %d", tc.toks, remaining, tc.remaining)
continue
}
if tc.remaining > 0 {
continue
}
if gotRunnable := node.Run != nil; gotRunnable != tc.runnable {
t.Errorf("Walk(%v) runnable=%v, want %v", tc.toks, gotRunnable, tc.runnable)
continue
}
if tc.runnable && node.Help != tc.help {
t.Errorf("Walk(%v) help=%q, want %q", tc.toks, node.Help, tc.help)
}
if strings.Join(args, ",") != strings.Join(tc.args, ",") {
t.Errorf("Walk(%v) args=%v, want %v", tc.toks, args, tc.args)
}
}
}
func TestExpandPathsCoversCommands(t *testing.T) {
root := buildFixture()
var joined string
for _, l := range ExpandPaths(root, "") {
joined += l.Path + "\n"
}
for _, want := range []string{
"show instance",
"show instance <id> evpn <evpn>",
"watch events",
"quit",
} {
if !strings.Contains(joined, want) {
t.Errorf("ExpandPaths missing %q\n%s", want, joined)
}
}
}
func TestCandidates(t *testing.T) {
root := buildFixture()
client := fakeClient{
instances: []string{"host1", "host2"},
evpns: map[string][]string{"host1": {"blue", "red"}},
}
ctx := context.Background()
words := func(ns []*Node[fakeClient]) []string {
out := make([]string, 0, len(ns))
for _, n := range ns {
out = append(out, n.Word)
}
sort.Strings(out)
return out
}
eq := func(got, want []string) bool {
if len(got) != len(want) {
return false
}
for i := range got {
if got[i] != want[i] {
return false
}
}
return true
}
// Fixed-child completion filtered by partial.
if got := words(Candidates(ctx, client, root, []string{"show"}, "inst")); !eq(got, []string{"instance"}) {
t.Errorf(`Candidates(show, "inst") = %v, want [instance]`, got)
}
// Dynamic slot values offered for an empty partial.
if got := words(Candidates(ctx, client, root, []string{"show", "instance"}, "")); !eq(got, []string{"host1", "host2"}) {
t.Errorf(`Candidates(show instance, "") = %v, want [host1 host2]`, got)
}
// Dynamic slot values filtered by partial.
if got := words(Candidates(ctx, client, root, []string{"show", "instance"}, "host1")); !eq(got, []string{"host1"}) {
t.Errorf(`Candidates(show instance, "host1") = %v, want [host1]`, got)
}
// Context-dependent slot: <evpn> lists the EVPNs of host1 captured earlier.
if got := words(Candidates(ctx, client, root, []string{"show", "instance", "host1", "evpn"}, "")); !eq(got, []string{"blue", "red"}) {
t.Errorf(`Candidates(... host1 evpn, "") = %v, want [blue red]`, got)
}
// A broken left context offers nothing.
if got := Candidates(ctx, client, root, []string{"show", "bogus"}, ""); got != nil {
t.Errorf("Candidates(show bogus) = %v, want nil", got)
}
}
func TestDispatchRunsCommand(t *testing.T) {
root := buildFixture()
var gotArgs []string
root.Children = append(root.Children, &Node[fakeClient]{
Word: "echo",
Children: []*Node[fakeClient]{{
Word: "<x>",
Dynamic: func(context.Context, fakeClient, []string) []string { return nil },
Run: func(_ context.Context, _ fakeClient, a []string) error {
gotArgs = a
return nil
},
}},
})
if err := Dispatch(context.Background(), root, fakeClient{}, []string{"echo", "hi"}); err != nil {
t.Fatalf("Dispatch echo: %v", err)
}
if len(gotArgs) != 1 || gotArgs[0] != "hi" {
t.Errorf("Run got args %v, want [hi]", gotArgs)
}
// Unknown command surfaces as an error.
if err := Dispatch(context.Background(), root, fakeClient{}, []string{"nope"}); err == nil {
t.Error("Dispatch(nope) = nil, want unknown-command error")
}
}