Files
golang-cli/example/main.go
T
pim 9e0a98ed07 feat: Validate + keypress subpackage; RFC-style design.md (v1.2.0)
Validate(root): optional startup/test check for tree authoring faults —
>1 slot child per node, empty word, duplicate sibling words, dead-end
node — traversing circular slots without looping (#3).

keypress subpackage: WaitForKey(ctx, cancel) cancels a context on any
keystroke for watch-style streaming commands, with per-GOOS cbreak
(linux TCGETS/TCSETS, BSD TIOCGETA/TIOCSETA) and a non-tty/unsupported
fallback that just waits on ctx. Lifts the last OpenBSD-specific bit out
of evpnc/maglevc's watch.go (#6).

docs: replace PROPOSAL.md with an RFC-2119 design.md (FR/NFR for the
library). Example now dogfoods Validate (a unit test) and keypress (a
bounded `watch` command).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 22:11:13 +02:00

216 lines
7.9 KiB
Go

// 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:
//
// - 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;
// - 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):
//
// 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 (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 show server web1
package main
import (
"context"
"fmt"
"sort"
"strings"
cli "git.ipng.ch/ipng/golang-cli"
"git.ipng.ch/ipng/golang-cli/keypress"
)
// 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
}
// runWatch streams a few simulated events, stopping early if a key is pressed.
// In a real CLI this would be a server-side stream; here it is a bounded loop so
// the demo never hangs when stdin is not a terminal. Press any key (in a TTY) to
// stop it before it finishes.
func runWatch(ctx context.Context, _ inventory, _ []string) error {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
go keypress.WaitForKey(ctx, cancel)
for i := 1; i <= 5; i++ {
select {
case <-ctx.Done():
return nil // key pressed
default:
}
if err := cli.Emit(cli.KV("event", fmt.Sprintf("%d", i)), map[string]any{"event": i}); err != nil {
return err
}
}
return nil
}
func runQuit(context.Context, inventory, []string) error { return cli.ErrQuit }
// 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] {
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("watch", "stream a few events (any key stops it)", runWatch),
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() {
(&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) },
}).Main()
}