From d63ffd6a3a6fbf089effc8978a9584b350725bff Mon Sep 17 00:00:00 2001 From: Pim van Pelt Date: Fri, 5 Jun 2026 21:48:48 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20golang-cli=20v1.0.0=20=E2=80=94=20gener?= =?UTF-8?q?ic=20command-tree=20CLI=20library?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- LICENSE | 201 ++++++++++++++++++++++++++++++++++++ README.md | 144 ++++++++++++++++++++++++++ color.go | 41 ++++++++ complete.go | 130 ++++++++++++++++++++++++ docs/PROPOSAL.md | 257 +++++++++++++++++++++++++++++++++++++++++++++++ example/main.go | 222 ++++++++++++++++++++++++++++++++++++++++ go.mod | 8 ++ go.sum | 9 ++ output.go | 64 ++++++++++++ shell.go | 140 ++++++++++++++++++++++++++ term_default.go | 13 +++ term_openbsd.go | 68 +++++++++++++ tree.go | 168 +++++++++++++++++++++++++++++++ tree_test.go | 205 +++++++++++++++++++++++++++++++++++++ 14 files changed, 1670 insertions(+) create mode 100644 LICENSE create mode 100644 README.md create mode 100644 color.go create mode 100644 complete.go create mode 100644 docs/PROPOSAL.md create mode 100644 example/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 output.go create mode 100644 shell.go create mode 100644 term_default.go create mode 100644 term_openbsd.go create mode 100644 tree.go create mode 100644 tree_test.go diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..4fc01d6 --- /dev/null +++ b/LICENSE @@ -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 + + 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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e2eda60 --- /dev/null +++ b/README.md @@ -0,0 +1,144 @@ + + +# 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 ``) accepts +any single token as an argument. `Dynamic` receives the args captured by slot +nodes *earlier on the path*, so a `` slot can list only the services of +the `` 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: "", 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 web1 web2 db1 +inv> show server web1 service ? show one service + : 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). diff --git a/color.go b/color.go new file mode 100644 index 0000000..061327e --- /dev/null +++ b/color.go @@ -0,0 +1,41 @@ +// SPDX-FileCopyrightText: (C) Copyright 2026 Pim van Pelt +// 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) } diff --git a/complete.go b/complete.go new file mode 100644 index 0000000..d256c5e --- /dev/null +++ b/complete.go @@ -0,0 +1,130 @@ +// SPDX-FileCopyrightText: (C) Copyright 2026 Pim van Pelt +// 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(), " \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 +} diff --git a/docs/PROPOSAL.md b/docs/PROPOSAL.md new file mode 100644 index 0000000..4e512b1 --- /dev/null +++ b/docs/PROPOSAL.md @@ -0,0 +1,257 @@ + + +# 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 `` + can list the EVPNs *of the `` 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. diff --git a/example/main.go b/example/main.go new file mode 100644 index 0000000..412f818 --- /dev/null +++ b/example/main.go @@ -0,0 +1,222 @@ +// SPDX-FileCopyrightText: (C) Copyright 2026 Pim van Pelt +// 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 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 # 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 " 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 [ [service []]] + serviceName := &cli.Node[inventory]{Word: "", 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: "", Help: "show one server", Dynamic: dynServers, Run: runShowServer, Children: []*cli.Node[inventory]{service}} + server := &cli.Node[inventory]{Word: "server", Help: "list servers (add 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 + ping := &cli.Node[inventory]{Word: "ping", Help: "ping a server", Children: []*cli.Node[inventory]{ + {Word: "", 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) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..9a41734 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..5552f15 --- /dev/null +++ b/go.sum @@ -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= diff --git a/output.go b/output.go new file mode 100644 index 0000000..03b72e7 --- /dev/null +++ b/output.go @@ -0,0 +1,64 @@ +// SPDX-FileCopyrightText: (C) Copyright 2026 Pim van Pelt +// 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 } diff --git a/shell.go b/shell.go new file mode 100644 index 0000000..189720b --- /dev/null +++ b/shell.go @@ -0,0 +1,140 @@ +// SPDX-FileCopyrightText: (C) Copyright 2026 Pim van Pelt +// 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(" ") + 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) + } + } +} diff --git a/term_default.go b/term_default.go new file mode 100644 index 0000000..c030149 --- /dev/null +++ b/term_default.go @@ -0,0 +1,13 @@ +// SPDX-FileCopyrightText: (C) Copyright 2026 Pim van Pelt +// 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) {} diff --git a/term_openbsd.go b/term_openbsd.go new file mode 100644 index 0000000..2da2dd5 --- /dev/null +++ b/term_openbsd.go @@ -0,0 +1,68 @@ +// SPDX-FileCopyrightText: (C) Copyright 2026 Pim van Pelt +// 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) + } +} diff --git a/tree.go b/tree.go new file mode 100644 index 0000000..92d047e --- /dev/null +++ b/tree.go @@ -0,0 +1,168 @@ +// SPDX-FileCopyrightText: (C) Copyright 2026 Pim van Pelt +// 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 ""); 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. "" can list the EVPN instances of +// the "" 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 +} diff --git a/tree_test.go b/tree_test.go new file mode 100644 index 0000000..48ec2a5 --- /dev/null +++ b/tree_test.go @@ -0,0 +1,205 @@ +// SPDX-FileCopyrightText: (C) Copyright 2026 Pim van Pelt +// 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: "", 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: "", 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 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: "", 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 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 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 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: 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: "", + 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") + } +}