diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..0cbcebf --- /dev/null +++ b/Makefile @@ -0,0 +1,33 @@ +# SPDX-FileCopyrightText: (C) Copyright 2026 Pim van Pelt +# SPDX-License-Identifier: Apache-2.0 + +.PHONY: help all check test fixstyle vet lint build cross + +help: ## Show this help + @printf "Usage: make \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 ./... diff --git a/README.md b/README.md index e2eda60..12eb3d3 100644 --- a/README.md +++ b/README.md @@ -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("", "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: diff --git a/app.go b/app.go new file mode 100644 index 0000000..edc7746 --- /dev/null +++ b/app.go @@ -0,0 +1,199 @@ +// SPDX-FileCopyrightText: (C) Copyright 2026 Pim van Pelt +// 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 +} diff --git a/app_test.go b/app_test.go new file mode 100644 index 0000000..11654e8 --- /dev/null +++ b/app_test.go @@ -0,0 +1,79 @@ +// SPDX-FileCopyrightText: (C) Copyright 2026 Pim van Pelt +// 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("", "", 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") + } +} diff --git a/builder.go b/builder.go new file mode 100644 index 0000000..2e274f9 --- /dev/null +++ b/builder.go @@ -0,0 +1,64 @@ +// SPDX-FileCopyrightText: (C) Copyright 2026 Pim van Pelt +// 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 for one)", runServers, +// b.Slot("", "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 ""). +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. "" in +// "instance 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 "). 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} +} diff --git a/builder_test.go b/builder_test.go new file mode 100644 index 0000000..04eb5c4 --- /dev/null +++ b/builder_test.go @@ -0,0 +1,86 @@ +// SPDX-FileCopyrightText: (C) Copyright 2026 Pim van Pelt +// 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("", "", dynWatchOpts, runNoop) + watchOpt.Children = []*Node[fakeClient]{watchOpt} + + return b.Root( + b.Dir("show", "show state", + b.Cmd("instance", "list fleet members (add for one)", runNoop, + b.Slot("", "show one member", dynInstances, runNoop, + b.Cmd("evpn", "list the member's EVPNs", runNoop, + b.Slot("", "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 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 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) + } +} diff --git a/docs/PROPOSAL.md b/docs/PROPOSAL.md index 4e512b1..e60f751 100644 --- a/docs/PROPOSAL.md +++ b/docs/PROPOSAL.md @@ -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 ``` diff --git a/example/main.go b/example/main.go index 412f818..6ce9cd0 100644 --- a/example/main.go +++ b/example/main.go @@ -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 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 [ [service []]] - serviceName := &cli.Node[inventory]{Word: "", Help: "show one service", Dynamic: dynServices, Run: runShowServerService} - service := &cli.Node[inventory]{Word: "service", Help: "list a server's services", Run: runShowServerServices, Children: []*cli.Node[inventory]{serviceName}} - serverName := &cli.Node[inventory]{Word: "", Help: "show one server", Dynamic: dynServers, Run: runShowServer, Children: []*cli.Node[inventory]{service}} - server := &cli.Node[inventory]{Word: "server", Help: "list servers (add for one)", Run: runShowServers, Children: []*cli.Node[inventory]{serverName}} - show := &cli.Node[inventory]{Word: "show", Help: "show inventory state", Children: []*cli.Node[inventory]{server}} - - // ping - ping := &cli.Node[inventory]{Word: "ping", Help: "ping a server", Children: []*cli.Node[inventory]{ - {Word: "", Help: "ping this server", Dynamic: dynServers, Run: runPing}, - }} - - return &cli.Node[inventory]{Children: []*cli.Node[inventory]{ - show, - ping, - {Word: "colors", Help: "show the ANSI color palette", Run: runColors}, - {Word: "quit", Help: "exit the shell", Run: runQuit}, - {Word: "exit", Help: "exit the shell", Run: runQuit}, - }} + b := cli.For[inventory]() + return b.Root( + b.Dir("show", "show inventory state", + b.Cmd("server", "list servers (add for one)", runShowServers, + b.Slot("", "show one server", dynServers, runShowServer, + b.Cmd("service", "list a server's services", runShowServerServices, + b.Slot("", "show one service", dynServices, runShowServerService))))), + b.Dir("ping", "ping a server", + b.Slot("", "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() }