feat: builder + App runner, Makefile (v1.1.0)

Builder (cli.For[C]): Root/Dir/Cmd/SlotDir/Slot construct the tree
without repeating the [C] type parameter at every node; returns plain
*Node[C] so it interoperates with struct-literal construction.

App[C]: collapses the per-binary main.go — standard flags
(-color/-json/-version, -server when configured), mode-aware color
defaults, version banner, client connect, and the one-shot-vs-shell
split — into one Main(). Transport-agnostic via a Connect callback, so
it never assumes gRPC.

Makefile: `make check` = fixstyle vet lint test (the pre-commit gate),
plus build and a linux+openbsd cross target. The example now dogfoods
both Builder and App. Tests cover the builder tree and App's one-shot
dispatch / -version / nil-Connect paths.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-05 21:59:33 +02:00
parent d63ffd6a3a
commit e030cd28e9
8 changed files with 538 additions and 64 deletions
+32 -63
View File
@@ -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()
}