Skip to content

Commit 33c6f35

Browse files
Allow associating env vars with flags (#200)
1 parent dd90a26 commit 33c6f35

24 files changed

Lines changed: 847 additions & 60 deletions

README.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,35 @@ func buildCmd() (*cli.Command, error) {
181181
> [!TIP]
182182
> Default values can be provided with the [cli.FlagDefault](https://pkg.go.dev/go.followtheprocess.codes/cli#FlagDefault) Option
183183
184+
> [!TIP]
185+
> Flags can also be set via environment variables using the [cli.Env](https://pkg.go.dev/go.followtheprocess.codes/cli#Env) option.
186+
> Environment variables are always overridden by explicit CLI flags.
187+
188+
```go
189+
var force bool
190+
cli.Flag(&force, "force", 'f', "Force deletion", cli.Env[bool]("MYTOOL_FORCE"))
191+
```
192+
193+
> [!NOTE]
194+
> `cli.Env` requires an explicit type parameter because Go cannot infer it from the string argument alone.
195+
> The compiler enforces that the type matches the flag — `cli.Env[string](...)` on a `bool` flag is a compile error.
196+
197+
When `MYTOOL_FORCE=true` is set in the environment, `--force` is implied. Passing `--force=false` on the command line always wins.
198+
199+
Slice flags accept comma-separated env var values:
200+
201+
```sh
202+
MYTOOL_ITEMS='one,two,three' mytool
203+
```
204+
205+
The env var name is also shown in `--help` output:
206+
207+
```
208+
Options:
209+
210+
-f --force bool Force deletion (env: $MYTOOL_FORCE)
211+
```
212+
184213
The types are all inferred automatically! No more `BoolSliceVarP`
185214

186215
The types you can use for flags currently are:

command.go

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -667,24 +667,25 @@ func writeFlags(cmd *Command, s *strings.Builder) error {
667667
shorthand = "N/A"
668668
}
669669

670-
line := fmt.Sprintf(
671-
" %s\t--%s\t%s\t%s\t", style.Bold.Text(shorthand),
670+
defaultStr := ""
671+
if fl.Default() != "" {
672+
defaultStr = "[default: " + fl.Default() + "]"
673+
}
674+
675+
envStr := ""
676+
if fl.EnvVar() != "" {
677+
envStr = "(env: $" + fl.EnvVar() + ")"
678+
}
679+
680+
line := fmt.Sprintf(" %s\t--%s\t%s\t%s\t%s\t%s",
681+
style.Bold.Text(shorthand),
672682
style.Bold.Text(name),
673683
fl.Type(),
674684
fl.Usage(),
685+
defaultStr,
686+
envStr,
675687
)
676688

677-
if fl.Default() != "" {
678-
line = fmt.Sprintf(
679-
" %s\t--%s\t%s\t%s\t[default: %s]",
680-
style.Bold.Text(shorthand),
681-
style.Bold.Text(name),
682-
fl.Type(),
683-
fl.Usage(),
684-
fl.Default(),
685-
)
686-
}
687-
688689
fmt.Fprintln(tw, line)
689690
}
690691

command_test.go

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -506,6 +506,39 @@ func TestHelp(t *testing.T) {
506506
},
507507
wantErr: false,
508508
},
509+
{
510+
name: "flag with env var",
511+
options: []cli.Option{
512+
cli.OverrideArgs([]string{"--help"}),
513+
cli.Short("A test command"),
514+
cli.Flag(new(bool), "force", 'f', "Force something", cli.Env[bool]("MYTOOL_FORCE")),
515+
cli.Run(func(_ context.Context, _ *cli.Command) error { return nil }),
516+
},
517+
wantErr: false,
518+
},
519+
{
520+
name: "flags with multiple env vars",
521+
options: []cli.Option{
522+
cli.OverrideArgs([]string{"--help"}),
523+
cli.Short("A test command"),
524+
cli.Flag(new(bool), "force", 'f', "Force something", cli.Env[bool]("MYTOOL_FORCE")),
525+
cli.Flag(new(int), "count", 'c', "A much longer usage description here", cli.Env[int]("MYTOOL_COUNT")),
526+
cli.Flag(new(string), "name", 'n', "Name", cli.Env[string]("MYTOOL_NAME")),
527+
cli.Run(func(_ context.Context, _ *cli.Command) error { return nil }),
528+
},
529+
wantErr: false,
530+
},
531+
{
532+
name: "flag with default and env var",
533+
options: []cli.Option{
534+
cli.OverrideArgs([]string{"--help"}),
535+
cli.Short("A test command"),
536+
cli.Flag(new(int), "count", 'c', "Count things", cli.FlagDefault(5), cli.Env[int]("MYTOOL_COUNT")),
537+
cli.Flag(new(string), "name", 'n', "A name", cli.Env[string]("MYTOOL_NAME")),
538+
cli.Run(func(_ context.Context, _ *cli.Command) error { return nil }),
539+
},
540+
wantErr: false,
541+
},
509542
}
510543

511544
for _, tt := range tests {
@@ -852,6 +885,85 @@ func TestExecuteNilCommand(t *testing.T) {
852885
}
853886
}
854887

888+
func TestEnvFlag(t *testing.T) {
889+
tests := []struct {
890+
name string
891+
setup func(t *testing.T)
892+
stdout string
893+
errMsg string
894+
args []string
895+
wantErr bool
896+
}{
897+
{
898+
name: "bool flag set via env var",
899+
setup: func(t *testing.T) {
900+
t.Setenv("MYTOOL_FORCE", "true")
901+
},
902+
stdout: "force: true\n",
903+
args: []string{},
904+
wantErr: false,
905+
},
906+
{
907+
name: "CLI bool flag overrides env var",
908+
setup: func(t *testing.T) {
909+
t.Setenv("MYTOOL_FORCE", "true")
910+
},
911+
stdout: "force: false\n",
912+
args: []string{"--force=false"},
913+
wantErr: false,
914+
},
915+
{
916+
name: "env var not set leaves flag at default",
917+
stdout: "force: false\n",
918+
args: []string{},
919+
wantErr: false,
920+
},
921+
{
922+
name: "invalid env var value propagates error through Execute",
923+
setup: func(t *testing.T) {
924+
t.Setenv("MYTOOL_FORCE", "notabool")
925+
},
926+
args: []string{},
927+
wantErr: true,
928+
errMsg: `failed to parse command flags: could not set flag from env: env var MYTOOL_FORCE: parse error: flag "force" received invalid value "notabool" (expected bool): strconv.ParseBool: parsing "notabool": invalid syntax`,
929+
},
930+
}
931+
932+
for _, tt := range tests {
933+
t.Run(tt.name, func(t *testing.T) {
934+
if tt.setup != nil {
935+
tt.setup(t)
936+
}
937+
938+
var force bool
939+
940+
stdout := &bytes.Buffer{}
941+
942+
cmd, err := cli.New("test",
943+
cli.Stdout(stdout),
944+
cli.Flag(&force, "force", flag.NoShortHand, "Force something", cli.Env[bool]("MYTOOL_FORCE")),
945+
cli.OverrideArgs(tt.args),
946+
cli.Run(func(ctx context.Context, cmd *cli.Command) error {
947+
fmt.Fprintf(cmd.Stdout(), "force: %v\n", force)
948+
return nil
949+
}),
950+
)
951+
test.Ok(t, err)
952+
953+
err = cmd.Execute(t.Context())
954+
test.WantErr(t, err, tt.wantErr)
955+
956+
if tt.wantErr && tt.errMsg != "" {
957+
test.Equal(t, err.Error(), tt.errMsg)
958+
}
959+
960+
if !tt.wantErr {
961+
test.Equal(t, stdout.String(), tt.stdout)
962+
}
963+
})
964+
}
965+
}
966+
855967
// The order in which we apply options shouldn't matter, this test
856968
// shuffles the order of the options and asserts the Command we get
857969
// out behaves the same as a baseline.

examples/subcommands/cli.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ func buildDoCommand() (*cli.Command, error) {
8888
cli.Example("Do it for a specific duration", "demo do something --duration 1m30s"),
8989
cli.Version("do version"),
9090
cli.Arg(&thing, "thing", "Thing to do"),
91-
cli.Flag(&options.count, "count", 'c', "Number of times to do the thing", cli.FlagDefault(1)),
91+
cli.Flag(&options.count, "count", 'c', "Number of times to do the thing", cli.FlagDefault(1), cli.Env[int]("DEMO_COUNT")),
9292
cli.Flag(&options.fast, "fast", 'f', "Do the thing quickly"),
9393
cli.Flag(&options.verbosity, "verbosity", 'v', "Increase the verbosity level"),
9494
cli.Flag(&options.duration, "duration", 'd', "Do the thing for a specific duration", cli.FlagDefault(1*time.Second)),

internal/flag/config.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,7 @@ import "go.followtheprocess.codes/cli/flag"
66
type Config[T flag.Flaggable] struct {
77
// DefaultValue holds the intended default value of the flag.
88
DefaultValue T
9+
// EnvVar is the name of an environment variable that may set this flag's value
10+
// if the flag is not explicitly provided on the command line.
11+
EnvVar string
912
}

internal/flag/flag.go

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,11 @@ var _ Value = Flag[string]{} // This will fail if we violate our Value interface
2323

2424
// Flag represents a single command line flag.
2525
type Flag[T flag.Flaggable] struct {
26-
value *T // The actual stored value
27-
name string // The name of the flag as appears on the command line, e.g. "force" for a --force flag
28-
usage string // One line description of the flag, e.g. "Force deletion without confirmation"
29-
short rune // Optional shorthand version of the flag, e.g. "f" for a -f flag
26+
value *T // The actual stored value
27+
name string // The name of the flag as appears on the command line, e.g. "force" for a --force flag
28+
usage string // one line description of the flag, e.g. "Force deletion without confirmation"
29+
envVar string // Name of an environment variable that may set this flag's value if the flag is not explicitly provided on the command line
30+
short rune // Optional shorthand version of the flag, e.g. "f" for a -f flag
3031
}
3132

3233
// New constructs and returns a new [Flag].
@@ -49,10 +50,11 @@ func New[T flag.Flaggable](p *T, name string, short rune, usage string, config C
4950
*p = config.DefaultValue
5051

5152
flag := Flag[T]{
52-
value: p,
53-
name: name,
54-
usage: usage,
55-
short: short,
53+
value: p,
54+
name: name,
55+
usage: usage,
56+
short: short,
57+
envVar: config.EnvVar,
5658
}
5759

5860
return flag, nil
@@ -89,6 +91,29 @@ func (f Flag[T]) Default() string {
8991
return f.String()
9092
}
9193

94+
// EnvVar returns the name of the environment variable associated with this flag,
95+
// or an empty string if none was configured.
96+
func (f Flag[T]) EnvVar() string {
97+
return f.envVar
98+
}
99+
100+
// IsSlice reports whether the flag holds a slice value that accumulates repeated
101+
// calls to Set. Returns false for []byte and net.IP, which are parsed atomically.
102+
func (f Flag[T]) IsSlice() bool {
103+
if f.value == nil {
104+
return false
105+
}
106+
107+
switch any(*f.value).(type) {
108+
case []int, []int8, []int16, []int32, []int64,
109+
[]uint, []uint16, []uint32, []uint64,
110+
[]float32, []float64, []string:
111+
return true
112+
default:
113+
return false
114+
}
115+
}
116+
92117
// NoArgValue returns a string representation of value the flag should hold
93118
// when it is given no arguments on the command line. For example a boolean flag
94119
// --delete, when passed without arguments implies --delete true.

internal/flag/set.go

Lines changed: 56 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"errors"
55
"fmt"
66
"iter"
7+
"os"
78
"slices"
89
"strings"
910

@@ -13,17 +14,19 @@ import (
1314

1415
// Set is a set of command line flags.
1516
type Set struct {
16-
flags map[string]Value // The actual stored flags, can lookup by name
17-
shorthands map[rune]Value // The flags by shorthand
18-
args []string // Arguments minus flags or flag values
19-
extra []string // Arguments after "--" was hit
17+
flags map[string]Value // The actual stored flags, can lookup by name
18+
shorthands map[rune]Value // The flags by shorthand
19+
envVars map[string]string // flag name → env var name
20+
args []string // Arguments minus flags or flag values
21+
extra []string // Arguments after "--" was hit
2022
}
2123

2224
// NewSet builds and returns a new set of flags.
2325
func NewSet() *Set {
2426
return &Set{
2527
flags: make(map[string]Value),
2628
shorthands: make(map[rune]Value),
29+
envVars: make(map[string]string),
2730
}
2831
}
2932

@@ -50,6 +53,10 @@ func AddToSet[T flag.Flaggable](set *Set, f Flag[T]) error {
5053

5154
set.flags[name] = f
5255

56+
if f.envVar != "" {
57+
set.envVars[name] = f.envVar
58+
}
59+
5360
// Only add the shorthand if it wasn't opted out of
5461
if short != flag.NoShortHand {
5562
set.shorthands[short] = f
@@ -143,11 +150,17 @@ func (s *Set) ExtraArgs() []string {
143150
}
144151

145152
// Parse parses flags and their values from the command line.
146-
func (s *Set) Parse(args []string) (err error) {
153+
func (s *Set) Parse(args []string) error {
154+
var err error
155+
147156
if s == nil {
148157
return errors.New("Parse called on a nil set")
149158
}
150159

160+
if err = s.applyEnvVars(); err != nil {
161+
return fmt.Errorf("could not set flag from env: %w", err)
162+
}
163+
151164
for len(args) > 0 {
152165
arg := args[0] // The argument we're currently inspecting
153166
args = args[1:] // Remainder
@@ -202,6 +215,44 @@ func (s *Set) All() iter.Seq2[string, Value] {
202215
}
203216
}
204217

218+
// applyEnvVars looks up each configured environment variable and applies its value
219+
// to the corresponding flag. It is called at the start of Parse so that CLI args
220+
// parsed afterward naturally override these values.
221+
//
222+
// Slice flags accept comma-separated values e.g. MYTOOL_ITEMS='one,two,three'.
223+
// Empty and unset variables are ignored.
224+
func (s *Set) applyEnvVars() error {
225+
for name, envName := range s.envVars {
226+
val, ok := os.LookupEnv(envName)
227+
if !ok || val == "" {
228+
continue
229+
}
230+
231+
f, ok := s.flags[name]
232+
if !ok {
233+
return fmt.Errorf("flag %q does not exist", name)
234+
}
235+
236+
if f.IsSlice() {
237+
for item := range strings.SplitSeq(val, ",") {
238+
if item = strings.TrimSpace(item); item != "" {
239+
if err := f.Set(item); err != nil {
240+
return fmt.Errorf("env var %s: %w", envName, err)
241+
}
242+
}
243+
}
244+
245+
continue
246+
}
247+
248+
if err := f.Set(val); err != nil {
249+
return fmt.Errorf("env var %s: %w", envName, err)
250+
}
251+
}
252+
253+
return nil
254+
}
255+
205256
// parseLongFlag parses a single long flag e.g. --delete. It is passed
206257
// the possible long flag and the rest of the argument list and returns
207258
// the remaining arguments after it's done parsing to the caller.

0 commit comments

Comments
 (0)