Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e030cd28e9 |
@@ -0,0 +1,33 @@
|
||||
# SPDX-FileCopyrightText: (C) Copyright 2026 Pim van Pelt <pim@ipng.ch>
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
.PHONY: help all check test fixstyle vet lint build cross
|
||||
|
||||
help: ## Show this help
|
||||
@printf "Usage: make <target>\n\nTargets:\n"
|
||||
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \
|
||||
awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-12s\033[0m %s\n", $$1, $$2}'
|
||||
|
||||
all: check ## Alias for check
|
||||
|
||||
check: fixstyle vet lint test ## Run the full pre-commit gate (format, vet, lint, test)
|
||||
|
||||
test: ## Run all Go unit tests
|
||||
go test ./...
|
||||
|
||||
fixstyle: ## Format the Go tree (gofmt)
|
||||
gofmt -w .
|
||||
|
||||
vet: ## Run go vet across the tree
|
||||
go vet ./...
|
||||
|
||||
lint: ## Run golangci-lint across the Go tree
|
||||
golangci-lint run ./...
|
||||
|
||||
build: ## Build all packages for the host platform
|
||||
go build ./...
|
||||
|
||||
cross: ## Verify the tree builds on Linux and OpenBSD
|
||||
GOOS=linux GOARCH=amd64 go build ./...
|
||||
GOOS=linux GOARCH=arm64 go build ./...
|
||||
GOOS=openbsd GOARCH=amd64 go build ./...
|
||||
@@ -72,6 +72,47 @@ Return `cli.ErrQuit` from a command's `Run` to stop the REPL. `Shell.FormatError
|
||||
lets you render command errors however you like (e.g. unwrap a gRPC status to its
|
||||
message and color it); it defaults to `err.Error()`.
|
||||
|
||||
## Less boilerplate: `Builder` and `App`
|
||||
|
||||
`cli.For[C]()` returns a `Builder` so no node repeats the `[C]` type parameter.
|
||||
The names are symmetric — `Dir`/`Cmd` for fixed keywords (without/with an
|
||||
action), `SlotDir`/`Slot` for dynamic argument nodes:
|
||||
|
||||
```go
|
||||
b := cli.For[inventory]()
|
||||
root := b.Root(
|
||||
b.Dir("show", "show state",
|
||||
b.Cmd("server", "list servers", runShowServers,
|
||||
b.Slot("<name>", "show one", dynServers, runShowServer))),
|
||||
b.Cmd("quit", "exit", runQuit),
|
||||
)
|
||||
```
|
||||
|
||||
`cli.App[C]` wraps the whole process entry point — the standard flags
|
||||
(`-color`, `-json`, `-version`, and `-server` when configured), mode-aware color
|
||||
defaults, the version banner, connecting the client, and the one-shot-vs-shell
|
||||
split — into a single `Main()`. It is transport-agnostic: it never dials
|
||||
anything itself, so supply `Connect` to build the client (dial gRPC, open a
|
||||
socket, or return an in-memory value):
|
||||
|
||||
```go
|
||||
func main() {
|
||||
(&cli.App[inventory]{
|
||||
Name: "inv", Version: "1.1.0", Prompt: "inv> ", Root: buildTree(),
|
||||
// Local CLI: no -server flag. A networked CLI sets DefaultServer/ServerEnv
|
||||
// and dials inside Connect.
|
||||
Connect: func(context.Context, string) (inventory, func(), error) {
|
||||
return newInventory(), nil, nil
|
||||
},
|
||||
FormatError: func(err error) string { return cli.Paint(err.Error(), cli.Red) },
|
||||
}).Main()
|
||||
}
|
||||
```
|
||||
|
||||
Both are additive conveniences: every builder method returns a plain
|
||||
`*cli.Node[C]`, so builder and struct-literal construction interoperate, and you
|
||||
can still drive the lifecycle yourself with `Shell`/`Dispatch` instead of `App`.
|
||||
|
||||
## Output: color and JSON
|
||||
|
||||
A command describes its result **once** and the framework renders it:
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
// SPDX-FileCopyrightText: (C) Copyright 2026 Pim van Pelt <pim@ipng.ch>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// errUsage is returned by App.Run when flag parsing fails. The flag package has
|
||||
// already printed the error and usage to stderr, so Main exits without printing
|
||||
// it again.
|
||||
var errUsage = errors.New("usage error")
|
||||
|
||||
// App wires a command tree to a process entry point: it registers the standard
|
||||
// flags (-color, -json, -version, and -server when a server is configured),
|
||||
// applies mode-aware color defaults, connects the client, and then either runs
|
||||
// one command (when arguments are given) or starts the interactive shell.
|
||||
//
|
||||
// App is transport-agnostic: it never dials anything itself. Supply Connect to
|
||||
// build the client from the resolved -server address (dial gRPC, open a socket,
|
||||
// or just return an in-memory value). Leave DefaultServer and ServerEnv empty
|
||||
// for a local CLI with no -server flag.
|
||||
//
|
||||
// Typical use:
|
||||
//
|
||||
// func main() {
|
||||
// (&cli.App[pb.EvpnrClient]{
|
||||
// Name: "evpnc", Version: version, Commit: commit, Date: date,
|
||||
// Prompt: "evpn> ", Root: buildTree(),
|
||||
// DefaultServer: "localhost:8900", ServerEnv: "EVPNC_SERVER",
|
||||
// Connect: func(ctx context.Context, server string) (pb.EvpnrClient, func(), error) {
|
||||
// conn, err := grpc.NewClient(netutil.EnsurePort(server, "8900"), ...)
|
||||
// if err != nil { return nil, nil, err }
|
||||
// return pb.NewEvpnrClient(conn), func() { _ = conn.Close() }, nil
|
||||
// },
|
||||
// FormatError: formatError,
|
||||
// }).Main()
|
||||
// }
|
||||
type App[C any] struct {
|
||||
// Name is the program name, used in the -version line and as the banner.
|
||||
Name string
|
||||
// Version, Commit, Date populate the -version line. Commit/Date are optional.
|
||||
Version string
|
||||
Commit string
|
||||
Date string
|
||||
// Prompt is the interactive shell prompt, e.g. "evpn> ".
|
||||
Prompt string
|
||||
// Root is the command tree. Required.
|
||||
Root *Node[C]
|
||||
// Greeting, if set, is printed after the version banner in interactive mode.
|
||||
Greeting string
|
||||
|
||||
// DefaultServer is the -server default. If both it and ServerEnv are empty,
|
||||
// no -server flag is registered (local CLI).
|
||||
DefaultServer string
|
||||
// ServerEnv, if set, is an environment variable that overrides DefaultServer.
|
||||
ServerEnv string
|
||||
// Connect builds the client from the resolved server address and returns it
|
||||
// with a cleanup function (called on exit; may be nil). If Connect is nil,
|
||||
// the zero value of C is used.
|
||||
Connect func(ctx context.Context, server string) (client C, cleanup func(), err error)
|
||||
|
||||
// FormatError renders command and fatal errors. Defaults to err.Error().
|
||||
FormatError func(error) string
|
||||
// RegisterFlags, if set, registers app-specific flags before parsing.
|
||||
RegisterFlags func(*flag.FlagSet)
|
||||
}
|
||||
|
||||
// Main runs the app with os.Args and exits the process: status 0 on success,
|
||||
// 2 on a usage error, 1 on any other error (printed via FormatError).
|
||||
func (a *App[C]) Main() {
|
||||
err := a.Run(context.Background(), os.Args[1:])
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
if errors.Is(err, errUsage) {
|
||||
os.Exit(2)
|
||||
}
|
||||
fmt.Fprintln(os.Stderr, a.formatErr()(err))
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Run parses argv, connects, and dispatches one command or starts the shell. It
|
||||
// returns a fatal error (connect failure, one-shot command error, readline
|
||||
// error); per-command errors in the interactive shell are printed there and do
|
||||
// not stop the loop. Use Run directly when you manage the process lifecycle
|
||||
// yourself; otherwise call Main.
|
||||
func (a *App[C]) Run(ctx context.Context, argv []string) error {
|
||||
fs := flag.NewFlagSet(a.Name, flag.ContinueOnError)
|
||||
|
||||
var serverFlag *string
|
||||
defaultServer := a.DefaultServer
|
||||
if a.ServerEnv != "" {
|
||||
if v := os.Getenv(a.ServerEnv); v != "" {
|
||||
defaultServer = v
|
||||
}
|
||||
}
|
||||
if defaultServer != "" || a.ServerEnv != "" {
|
||||
usage := "server address"
|
||||
if a.ServerEnv != "" {
|
||||
usage += " (env: " + a.ServerEnv + ")"
|
||||
}
|
||||
serverFlag = fs.String("server", defaultServer, usage)
|
||||
}
|
||||
color := fs.Bool("color", true, "colorize output (default: on in the shell, off one-shot)")
|
||||
jsonOut := fs.Bool("json", false, "emit JSON instead of text")
|
||||
showVersion := fs.Bool("version", false, "print version and exit")
|
||||
if a.RegisterFlags != nil {
|
||||
a.RegisterFlags(fs)
|
||||
}
|
||||
if err := fs.Parse(argv); err != nil {
|
||||
if errors.Is(err, flag.ErrHelp) {
|
||||
return nil // flag already printed usage
|
||||
}
|
||||
return errUsage // flag already printed the error and usage
|
||||
}
|
||||
|
||||
if *showVersion {
|
||||
fmt.Println(a.versionLine())
|
||||
return nil
|
||||
}
|
||||
if *jsonOut {
|
||||
SetFormat(FormatJSON)
|
||||
}
|
||||
|
||||
positional := fs.Args()
|
||||
interactive := len(positional) == 0
|
||||
|
||||
// Mode-aware color default: on in the interactive shell, off one-shot (so
|
||||
// piped output is script-safe). An explicit -color overrides either way;
|
||||
// JSON forces it off.
|
||||
colorExplicit := false
|
||||
fs.Visit(func(f *flag.Flag) {
|
||||
if f.Name == "color" {
|
||||
colorExplicit = true
|
||||
}
|
||||
})
|
||||
colorOn := interactive
|
||||
if colorExplicit {
|
||||
colorOn = *color
|
||||
}
|
||||
if *jsonOut {
|
||||
colorOn = false
|
||||
}
|
||||
SetColor(colorOn)
|
||||
|
||||
client, cleanup, err := a.connect(ctx, serverFlag)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
if interactive {
|
||||
fmt.Println(a.versionLine())
|
||||
if a.Greeting != "" {
|
||||
fmt.Println(a.Greeting)
|
||||
}
|
||||
sh := &Shell[C]{Root: a.Root, Client: client, Prompt: a.Prompt, FormatError: a.FormatError}
|
||||
return sh.Run(ctx)
|
||||
}
|
||||
return Dispatch(ctx, a.Root, client, SplitTokens(strings.Join(positional, " ")))
|
||||
}
|
||||
|
||||
func (a *App[C]) connect(ctx context.Context, serverFlag *string) (C, func(), error) {
|
||||
if a.Connect == nil {
|
||||
var zero C
|
||||
return zero, func() {}, nil
|
||||
}
|
||||
server := ""
|
||||
if serverFlag != nil {
|
||||
server = *serverFlag
|
||||
}
|
||||
client, cleanup, err := a.Connect(ctx, server)
|
||||
if cleanup == nil {
|
||||
cleanup = func() {}
|
||||
}
|
||||
return client, cleanup, err
|
||||
}
|
||||
|
||||
func (a *App[C]) formatErr() func(error) string {
|
||||
if a.FormatError != nil {
|
||||
return a.FormatError
|
||||
}
|
||||
return func(err error) string { return err.Error() }
|
||||
}
|
||||
|
||||
func (a *App[C]) versionLine() string {
|
||||
s := a.Name + " " + a.Version
|
||||
if a.Commit != "" || a.Date != "" {
|
||||
s += fmt.Sprintf(" (commit %s, built %s)", a.Commit, a.Date)
|
||||
}
|
||||
return s
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
// SPDX-FileCopyrightText: (C) Copyright 2026 Pim van Pelt <pim@ipng.ch>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestAppOneShotDispatch runs a one-shot command through App.Run and checks the
|
||||
// positional args reach the command, the captured slot arg is correct, and the
|
||||
// client from Connect is threaded through.
|
||||
func TestAppOneShotDispatch(t *testing.T) {
|
||||
var gotArg string
|
||||
var gotClient fakeClient
|
||||
b := For[fakeClient]()
|
||||
root := b.Root(
|
||||
b.Dir("ping", "",
|
||||
b.Slot("<name>", "", func(context.Context, fakeClient, []string) []string { return nil },
|
||||
func(_ context.Context, c fakeClient, args []string) error {
|
||||
gotClient = c
|
||||
gotArg = args[0]
|
||||
return nil
|
||||
})),
|
||||
)
|
||||
app := &App[fakeClient]{
|
||||
Name: "t",
|
||||
Root: root,
|
||||
Connect: func(context.Context, string) (fakeClient, func(), error) {
|
||||
return fakeClient{instances: []string{"sentinel"}}, nil, nil
|
||||
},
|
||||
}
|
||||
if err := app.Run(context.Background(), []string{"ping", "host9"}); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if gotArg != "host9" {
|
||||
t.Errorf("arg = %q, want host9", gotArg)
|
||||
}
|
||||
if len(gotClient.instances) != 1 || gotClient.instances[0] != "sentinel" {
|
||||
t.Errorf("client not threaded from Connect: %+v", gotClient)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppVersion checks -version short-circuits before connecting.
|
||||
func TestAppVersion(t *testing.T) {
|
||||
connected := false
|
||||
app := &App[fakeClient]{
|
||||
Name: "t",
|
||||
Version: "9.9.9",
|
||||
Root: For[fakeClient]().Root(),
|
||||
Connect: func(context.Context, string) (fakeClient, func(), error) {
|
||||
connected = true
|
||||
return fakeClient{}, nil, nil
|
||||
},
|
||||
}
|
||||
if err := app.Run(context.Background(), []string{"-version"}); err != nil {
|
||||
t.Fatalf("Run -version: %v", err)
|
||||
}
|
||||
if connected {
|
||||
t.Error("-version should not connect")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppNilConnect checks a local CLI with no Connect uses the zero client.
|
||||
func TestAppNilConnect(t *testing.T) {
|
||||
ran := false
|
||||
b := For[fakeClient]()
|
||||
app := &App[fakeClient]{
|
||||
Name: "t",
|
||||
Root: b.Root(b.Cmd("go", "", func(context.Context, fakeClient, []string) error { ran = true; return nil })),
|
||||
}
|
||||
if err := app.Run(context.Background(), []string{"go"}); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if !ran {
|
||||
t.Error("command did not run with nil Connect")
|
||||
}
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
// SPDX-FileCopyrightText: (C) Copyright 2026 Pim van Pelt <pim@ipng.ch>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package cli
|
||||
|
||||
import "context"
|
||||
|
||||
// Builder constructs Node[C] values without repeating the [C] type parameter at
|
||||
// every node. Obtain one with For[C], then use Root/Dir/Cmd/SlotDir/Slot:
|
||||
//
|
||||
// b := cli.For[Client]()
|
||||
// root := b.Root(
|
||||
// b.Dir("show", "show state",
|
||||
// b.Cmd("version", "print version", runVersion),
|
||||
// b.Cmd("server", "list servers (add <name> for one)", runServers,
|
||||
// b.Slot("<name>", "show one", dynServers, runServer))),
|
||||
// b.Cmd("quit", "exit", runQuit),
|
||||
// )
|
||||
//
|
||||
// The naming is symmetric: Dir/Cmd are fixed keyword nodes (without/with an
|
||||
// action), SlotDir/Slot are dynamic argument nodes (without/with an action).
|
||||
// The builder is a zero-size value, so constructing many nodes from one is free.
|
||||
//
|
||||
// It is purely a convenience: every method returns a plain *Node[C], so builder
|
||||
// and struct-literal construction interoperate freely — you can mix the two,
|
||||
// and a node built either way can still be mutated afterward (e.g. to wire a
|
||||
// circular slot: opt := b.Slot(...); opt.Children = []*cli.Node[C]{opt}).
|
||||
type Builder[C any] struct{}
|
||||
|
||||
// For returns a Builder bound to client type C.
|
||||
func For[C any]() Builder[C] { return Builder[C]{} }
|
||||
|
||||
// Root returns the (wordless) root node holding the given top-level children.
|
||||
func (Builder[C]) Root(children ...*Node[C]) *Node[C] {
|
||||
return &Node[C]{Children: children}
|
||||
}
|
||||
|
||||
// Dir is a fixed keyword node with no action of its own — a grouping of
|
||||
// subcommands (e.g. "show", "create").
|
||||
func (Builder[C]) Dir(word, help string, children ...*Node[C]) *Node[C] {
|
||||
return &Node[C]{Word: word, Help: help, Children: children}
|
||||
}
|
||||
|
||||
// Cmd is a fixed keyword node that runs an action. It may also carry children:
|
||||
// a node that both does something and has subcommands (e.g. "show instance",
|
||||
// which lists members and also accepts "<id>").
|
||||
func (Builder[C]) Cmd(word, help string, run func(context.Context, C, []string) error, children ...*Node[C]) *Node[C] {
|
||||
return &Node[C]{Word: word, Help: help, Run: run, Children: children}
|
||||
}
|
||||
|
||||
// SlotDir is a dynamic slot that captures any token as an argument but has no
|
||||
// action of its own — a navigational placeholder (e.g. "<id>" in
|
||||
// "instance <id> evpn ..."). dyn yields the live completion candidates, given
|
||||
// the args captured by earlier slots on the path.
|
||||
func (Builder[C]) SlotDir(word, help string, dyn func(context.Context, C, []string) []string, children ...*Node[C]) *Node[C] {
|
||||
return &Node[C]{Word: word, Help: help, Dynamic: dyn, Children: children}
|
||||
}
|
||||
|
||||
// Slot is a dynamic slot that both captures a token argument and runs an action
|
||||
// (e.g. "delete group <id>"). It may also carry children. dyn yields the live
|
||||
// completion candidates for the slot, given the args captured by earlier slots.
|
||||
func (Builder[C]) Slot(word, help string, dyn func(context.Context, C, []string) []string, run func(context.Context, C, []string) error, children ...*Node[C]) *Node[C] {
|
||||
return &Node[C]{Word: word, Help: help, Dynamic: dyn, Run: run, Children: children}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// SPDX-FileCopyrightText: (C) Copyright 2026 Pim van Pelt <pim@ipng.ch>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// buildFixtureB constructs the same tree shape as buildFixture (tree_test.go)
|
||||
// but via the Builder, so the two are interchangeable. dynInstances, dynEvpns,
|
||||
// dynWatchOpts, runNoop and runQuit are defined in tree_test.go (same package).
|
||||
func buildFixtureB() *Node[fakeClient] {
|
||||
b := For[fakeClient]()
|
||||
|
||||
// Circular watch-options slot: built then wired to itself.
|
||||
watchOpt := b.Slot("<opts>", "", dynWatchOpts, runNoop)
|
||||
watchOpt.Children = []*Node[fakeClient]{watchOpt}
|
||||
|
||||
return b.Root(
|
||||
b.Dir("show", "show state",
|
||||
b.Cmd("instance", "list fleet members (add <id> for one)", runNoop,
|
||||
b.Slot("<id>", "show one member", dynInstances, runNoop,
|
||||
b.Cmd("evpn", "list the member's EVPNs", runNoop,
|
||||
b.Slot("<evpn>", "show one EVPN", dynEvpns, runNoop))))),
|
||||
b.Dir("watch", "stream events",
|
||||
b.Cmd("events", "watch events", runNoop, watchOpt)),
|
||||
b.Cmd("quit", "exit", runQuit),
|
||||
)
|
||||
}
|
||||
|
||||
// TestBuilderMatchesLiteral checks the Builder produces a tree that Walks
|
||||
// identically to the struct-literal fixture: same runnable nodes, captured
|
||||
// args, prefix abbreviation, and circular-slot handling.
|
||||
func TestBuilderMatchesLiteral(t *testing.T) {
|
||||
root := buildFixtureB()
|
||||
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", "blue"}, true, "show one EVPN", []string{"host1", "blue"}, 0},
|
||||
{[]string{"sh", "inst"}, true, "list fleet members (add <id> for one)", nil, 0},
|
||||
{[]string{"watch", "events", "num", "5", "crud"}, true, "", []string{"num", "5", "crud"}, 0},
|
||||
{[]string{"show", "bogus"}, false, "", nil, 1},
|
||||
}
|
||||
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 got := node.Run != nil; got != tc.runnable {
|
||||
t.Errorf("Walk(%v) runnable=%v, want %v", tc.toks, got, tc.runnable)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuilderDynamicCandidates checks a Builder-built slot still resolves
|
||||
// context-dependent candidates through its Dynamic function.
|
||||
func TestBuilderDynamicCandidates(t *testing.T) {
|
||||
root := buildFixtureB()
|
||||
client := fakeClient{
|
||||
instances: []string{"host1", "host2"},
|
||||
evpns: map[string][]string{"host1": {"blue", "red"}},
|
||||
}
|
||||
got := Candidates(context.Background(), client, root, []string{"show", "instance", "host1", "evpn"}, "")
|
||||
if len(got) != 2 || got[0].Word != "blue" || got[1].Word != "red" {
|
||||
t.Errorf("Candidates(... host1 evpn) = %v, want [blue red]", got)
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -105,15 +105,18 @@ Module: `git.ipng.ch/ipng/golang-cli` (matches your git host). Single package
|
||||
go.sum
|
||||
LICENSE Apache-2.0 (matches the SPDX headers already in the files)
|
||||
README.md
|
||||
Makefile check (= fixstyle vet lint test), build, cross (linux + openbsd)
|
||||
tree.go Node[C], Walk, matchFixedChild, findSlotChild, expandPaths, Candidates
|
||||
tree_test.go table tests over a fixture tree (no client needed; Walk ignores Dynamic)
|
||||
builder.go For[C] / Builder[C]: Root/Dir/Cmd/SlotDir/Slot (no repeated [C])
|
||||
complete.go completer[C], questionListener[C], SplitTokens, splitForCompletion
|
||||
shell.go Shell[C] (config + Run), Dispatch, showHelpAt, unknownCommandError, ErrQuit
|
||||
app.go App[C]: standard flags + banner + connect + one-shot/shell -> Main()
|
||||
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
|
||||
example/main.go self-contained, dependency-free demo CLI (uses Builder + App)
|
||||
keypress/ (optional) WaitForKeypress + cbreak shim, per-GOOS <- §4.3
|
||||
```
|
||||
|
||||
|
||||
+32
-63
@@ -6,13 +6,13 @@
|
||||
// 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);
|
||||
// - the tree built with cli.For/Builder (no repeated [inventory] type param);
|
||||
// - 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.
|
||||
// - cli.App, which wires flags (-color/-json/-version), the banner, and the
|
||||
// one-shot-vs-interactive split into a single Main().
|
||||
//
|
||||
// Run it interactively (TAB to complete, '?' for help):
|
||||
//
|
||||
@@ -21,18 +21,16 @@
|
||||
// inv> show server web1 service ? # lists web1's services
|
||||
// inv> colors # the ANSI palette
|
||||
//
|
||||
// Or one-shot — text or JSON:
|
||||
// Or one-shot — text or JSON (one-shot is script-safe: color off unless -color):
|
||||
//
|
||||
// go run ./example show server web1
|
||||
// go run ./example -json show server web1
|
||||
// go run ./example -color=false show server web1
|
||||
// go run ./example -color show server web1
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
@@ -158,65 +156,36 @@ func runColors(context.Context, inventory, []string) error {
|
||||
|
||||
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.
|
||||
// buildTree is the single source of truth for the command set, built with the
|
||||
// cli.Builder so no node repeats the [inventory] type parameter.
|
||||
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},
|
||||
}}
|
||||
b := cli.For[inventory]()
|
||||
return b.Root(
|
||||
b.Dir("show", "show inventory state",
|
||||
b.Cmd("server", "list servers (add <name> for one)", runShowServers,
|
||||
b.Slot("<name>", "show one server", dynServers, runShowServer,
|
||||
b.Cmd("service", "list a server's services", runShowServerServices,
|
||||
b.Slot("<svc>", "show one service", dynServices, runShowServerService))))),
|
||||
b.Dir("ping", "ping a server",
|
||||
b.Slot("<name>", "ping this server", dynServers, runPing)),
|
||||
b.Cmd("colors", "show the ANSI color palette", runColors),
|
||||
b.Cmd("quit", "exit the shell", runQuit),
|
||||
b.Cmd("exit", "exit the shell", 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> ",
|
||||
(&cli.App[inventory]{
|
||||
Name: "example",
|
||||
Version: "1.1.0",
|
||||
Prompt: "inv> ",
|
||||
Root: buildTree(),
|
||||
Greeting: "golang-cli example — try: show server, ping db1, colors, '?' for help, TAB to complete",
|
||||
// Local CLI: no -server flag. Connect just hands over the in-memory data,
|
||||
// proving App is transport-agnostic (it never dials anything itself).
|
||||
Connect: func(context.Context, string) (inventory, func(), error) {
|
||||
return newInventory(), nil, nil
|
||||
},
|
||||
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)
|
||||
}
|
||||
}).Main()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user