feat: golang-cli v1.0.0 — generic command-tree CLI library
Reusable, generics-based CLI extracted from vpp-evpn's cmd/evpnc: a declarative command tree (Node[C]) from which dispatch, '?'-help and TAB-completion are derived, an interactive Shell[C], dynamic slot resolvers (context-dependent via captured args), text-or-JSON output (Emit), and color helpers (Paint/Label/KV). Builds on Linux and OpenBSD (readline termios override). Includes a self-contained example and a design proposal under docs/. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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.
|
||||||
@@ -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).
|
||||||
@@ -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
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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=
|
||||||
@@ -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 }
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user