// SPDX-FileCopyrightText: (C) Copyright 2026 Pim van Pelt // 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 }