Files
golang-cli/app.go
T
pim e030cd28e9 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>
2026-06-05 21:59:33 +02:00

200 lines
6.1 KiB
Go

// 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
}