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>
208 lines
6.3 KiB
Go
208 lines
6.3 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
|
|
// JSON, when true, registers a -json flag that switches Emit to JSON output.
|
|
// Leave it false for CLIs whose commands do not (yet) use cli.Emit, so they
|
|
// never advertise a flag they cannot honor.
|
|
JSON bool
|
|
|
|
// 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)")
|
|
var jsonOut *bool
|
|
if a.JSON {
|
|
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
|
|
}
|
|
jsonMode := jsonOut != nil && *jsonOut
|
|
if jsonMode {
|
|
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 jsonMode {
|
|
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
|
|
}
|