-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcli.go
More file actions
277 lines (240 loc) · 8.89 KB
/
cli.go
File metadata and controls
277 lines (240 loc) · 8.89 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
package cli
import (
"context"
"fmt"
"io"
"os"
"os/signal"
"syscall"
"github.com/bjaus/bind"
)
// Commander is the core interface every command must implement.
type Commander interface {
Run(ctx context.Context) error
}
// RunFunc adapts a plain function into a [Commander].
type RunFunc func(ctx context.Context) error
// Run implements [Commander].
func (f RunFunc) Run(ctx context.Context) error {
return f(ctx)
}
// Option configures the execution environment.
type Option func(*options)
type options struct {
stdout io.Writer
stderr io.Writer
stdin io.Reader
flagParser FlagParser
helpRenderer HelpRenderer
configResolver ConfigResolver
flagNormalizer func(string) string
envVarPrefix string
bindOpts []bind.Option
suggest bool
shortOptionHandling bool
prefixMatching bool
caseInsensitive bool
ignoreUnknown bool
sortedHelp bool
signalHandling bool
interactive bool
silenceErrors bool
silenceUsage bool
isTerminal func() bool
}
func defaults() *options {
return &options{
stdout: os.Stdout,
stderr: os.Stderr,
stdin: os.Stdin,
suggest: true,
isTerminal: defaultIsTerminal,
}
}
// WithStdout sets the writer used for standard output (e.g., help text).
func WithStdout(w io.Writer) Option {
return func(o *options) { o.stdout = w }
}
// WithStderr sets the writer used for standard error output.
func WithStderr(w io.Writer) Option {
return func(o *options) { o.stderr = w }
}
// WithFlagParser sets a global flag parser, overriding the default struct-tag parser.
func WithFlagParser(p FlagParser) Option {
return func(o *options) { o.flagParser = p }
}
// WithHelpRenderer sets a global help renderer, overriding the default renderer.
func WithHelpRenderer(r HelpRenderer) Option {
return func(o *options) { o.helpRenderer = r }
}
// WithSuggest enables or disables "did you mean?" suggestions for unknown
// commands and flags. Enabled by default.
func WithSuggest(enabled bool) Option {
return func(o *options) { o.suggest = enabled }
}
// WithShortOptionHandling enables POSIX-style short option combining.
// When enabled, -abc is expanded to -a -b -c (all but last must be bool/counter).
func WithShortOptionHandling(enabled bool) Option {
return func(o *options) { o.shortOptionHandling = enabled }
}
// WithPrefixMatching enables unique prefix matching for subcommand names.
// When enabled, "ser" matches "serve" if no other subcommand starts with "ser".
func WithPrefixMatching(enabled bool) Option {
return func(o *options) { o.prefixMatching = enabled }
}
// WithEnvVarPrefix sets a prefix prepended to all env var names declared via
// the env struct tag. For example, WithEnvVarPrefix("APP_") causes a flag
// tagged with `env:"PORT"` to look up the APP_PORT environment variable.
func WithEnvVarPrefix(prefix string) Option {
return func(o *options) { o.envVarPrefix = prefix }
}
// WithCaseInsensitive enables case-insensitive subcommand matching.
// When enabled, "Serve" matches "serve".
func WithCaseInsensitive(enabled bool) Option {
return func(o *options) { o.caseInsensitive = enabled }
}
// WithIgnoreUnknown causes unknown flags to be treated as positional args
// instead of returning an error. Useful for wrapper tools that forward
// flags to child processes.
func WithIgnoreUnknown(enabled bool) Option {
return func(o *options) { o.ignoreUnknown = enabled }
}
// WithSortedHelp sorts subcommands and flags alphabetically in help output.
func WithSortedHelp(enabled bool) Option {
return func(o *options) { o.sortedHelp = enabled }
}
// WithFlagNormalization sets a function that normalizes flag names before
// lookup. For example, to treat underscores as dashes:
//
// cli.WithFlagNormalization(func(s string) string {
// return strings.ReplaceAll(s, "_", "-")
// })
func WithFlagNormalization(fn func(string) string) Option {
return func(o *options) { o.flagNormalizer = fn }
}
// WithConfigResolver sets a global config resolver for flag values.
// Config values have lower priority than env vars and explicit CLI flags,
// but higher priority than defaults: explicit flag > env > config > default > zero.
func WithConfigResolver(r ConfigResolver) Option {
return func(o *options) { o.configResolver = r }
}
// WithSignalHandling enables automatic signal handling. When enabled,
// [Execute] wraps the context with [signal.NotifyContext] for SIGINT and
// SIGTERM, causing the context to be canceled when either signal is received.
func WithSignalHandling(enabled bool) Option {
return func(o *options) { o.signalHandling = enabled }
}
// WithInteractive enables interactive prompting for missing required flags
// when stdin is a terminal. Commands can implement [Prompter] to customize
// the prompting behavior.
//
// Terminal detection uses [os.ModeCharDevice] by default, which checks if stdin
// is a character device (TTY). This detection can be overridden using
// [WithTerminalCheck] for cases like expect/PTY testing where stdin appears
// as a terminal but you want to disable prompts, or vice versa.
func WithInteractive(enabled bool) Option {
return func(o *options) { o.interactive = enabled }
}
// WithTerminalCheck overrides the default terminal detection function used
// by interactive prompting. The default checks if stdin is a character device
// using [os.ModeCharDevice].
//
// Use this when you need to:
// - Force prompts in non-terminal environments (return true always)
// - Disable prompts even when stdin is a terminal (return false always)
// - Use custom detection logic for PTY/expect scenarios
//
// Example forcing prompts:
//
// cli.Execute(ctx, cmd, args,
// cli.WithInteractive(true),
// cli.WithTerminalCheck(func() bool { return true }),
// )
func WithTerminalCheck(check func() bool) Option {
return func(o *options) { o.isTerminal = check }
}
// WithStdin sets the reader used for standard input (e.g., interactive prompts).
func WithStdin(r io.Reader) Option {
return func(o *options) { o.stdin = r }
}
// WithSilenceErrors suppresses the "Error: ..." message in [ExecuteAndExit].
// The process still exits with the appropriate code. Use this when you want
// to handle error output yourself but still use framework exit code handling.
func WithSilenceErrors(enabled bool) Option {
return func(o *options) { o.silenceErrors = enabled }
}
// WithSilenceUsage suppresses the "Run 'app --help' for usage" hint that
// normally appears after flag parsing errors in [ExecuteAndExit].
func WithSilenceUsage(enabled bool) Option {
return func(o *options) { o.silenceUsage = enabled }
}
// Execute runs the command tree rooted at root with the given args and options.
// It resolves subcommands, parses flags, runs lifecycle hooks, and executes
// the target command.
func Execute(ctx context.Context, root Commander, args []string, opts ...Option) error {
o := defaults()
for _, opt := range opts {
opt(o)
}
if o.signalHandling {
var stop context.CancelFunc
ctx, stop = signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
defer stop()
}
return execute(ctx, root, args, o)
}
// ExecuteAndExit calls [Execute] and exits the process. If root implements
// [Exiter], its Exit method is called with the error and controls the exit
// entirely. Otherwise, the error is printed to stderr. For flag and command
// errors (unknown flag, missing required flag, etc.) a usage hint is appended.
// If the error implements [ExitCoder], its exit code is used; non-nil errors
// default to exit code 1.
//
// Help signals ([ShowHelp], [ErrShowHelp], [ShowUsage], [ErrShowUsage]) are
// handled specially: the framework renders the appropriate output and exits
// with the signal's exit code without printing "Error: ...".
func ExecuteAndExit(ctx context.Context, root Commander, args []string, opts ...Option) {
o := defaults()
for _, opt := range opts {
opt(o)
}
var stop context.CancelFunc
if o.signalHandling {
ctx, stop = signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
}
err := execute(ctx, root, args, o)
if stop != nil {
stop()
}
if err == nil {
os.Exit(0)
}
if e, ok := root.(Exiter); ok {
e.Exit(err)
return
}
// Help signals already rendered output in execute(); just exit with their code.
if isHelpSignal(err) {
os.Exit(exitCode(err))
}
formatError(o, root, err)
os.Exit(exitCode(err))
}
// formatError writes the error message and optional usage hint to stderr.
func formatError(o *options, root Commander, err error) {
if o.silenceErrors {
return
}
fmt.Fprintf(o.stderr, "Error: %s\n", err) //nolint:errcheck // best-effort error output
if !o.silenceUsage && isUsageError(err) {
name := resolveInfo(root).name
fmt.Fprintf(o.stderr, "Run '%s --help' for usage.\n", name) //nolint:errcheck // best-effort error output
}
}
// exitCode returns the process exit code for an error.
func exitCode(err error) int {
if ec, ok := err.(ExitCoder); ok {
return ec.ExitCode()
}
return 1
}