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:
@@ -0,0 +1,199 @@
|
||||
// SPDX-FileCopyrightText: (C) Copyright 2026 Pim van Pelt <pim@ipng.ch>
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user