496557858d
App now registers -json only when App.JSON is true, so a CLI whose commands do not use cli.Emit never advertises a flag it cannot honor. Driven by the first real consumer (evpnc), whose commands print text directly and are not yet converted to Emit. The example opts in (JSON: true). Backward-additive: existing App users that want -json set JSON: true. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
217 lines
8.0 KiB
Go
217 lines
8.0 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.3.0",
|
|
Prompt: "inv> ",
|
|
Root: buildTree(),
|
|
JSON: true, // commands use cli.Emit, so advertise -json
|
|
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()
|
|
}
|