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
+41
View File
@@ -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: