Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,35 @@ func buildCmd() (*cli.Command, error) {
> [!TIP]
> Default values can be provided with the [cli.FlagDefault](https://pkg.go.dev/go.followtheprocess.codes/cli#FlagDefault) Option

> [!TIP]
> Flags can also be set via environment variables using the [cli.Env](https://pkg.go.dev/go.followtheprocess.codes/cli#Env) option.
> Environment variables are always overridden by explicit CLI flags.

```go
var force bool
cli.Flag(&force, "force", 'f', "Force deletion", cli.Env[bool]("MYTOOL_FORCE"))
```

> [!NOTE]
> `cli.Env` requires an explicit type parameter because Go cannot infer it from the string argument alone.
> The compiler enforces that the type matches the flag — `cli.Env[string](...)` on a `bool` flag is a compile error.

When `MYTOOL_FORCE=true` is set in the environment, `--force` is implied. Passing `--force=false` on the command line always wins.

Slice flags accept comma-separated env var values:

```sh
MYTOOL_ITEMS='one,two,three' mytool
```

The env var name is also shown in `--help` output:

```
Options:

-f --force bool Force deletion (env: $MYTOOL_FORCE)
```

The types are all inferred automatically! No more `BoolSliceVarP` ✨

The types you can use for flags currently are:
Expand Down
27 changes: 14 additions & 13 deletions command.go
Original file line number Diff line number Diff line change
Expand Up @@ -667,24 +667,25 @@ func writeFlags(cmd *Command, s *strings.Builder) error {
shorthand = "N/A"
}

line := fmt.Sprintf(
" %s\t--%s\t%s\t%s\t", style.Bold.Text(shorthand),
defaultStr := ""
if fl.Default() != "" {
defaultStr = "[default: " + fl.Default() + "]"
}

envStr := ""
if fl.EnvVar() != "" {
envStr = "(env: $" + fl.EnvVar() + ")"
}

line := fmt.Sprintf(" %s\t--%s\t%s\t%s\t%s\t%s",
style.Bold.Text(shorthand),
style.Bold.Text(name),
fl.Type(),
fl.Usage(),
defaultStr,
envStr,
)

if fl.Default() != "" {
line = fmt.Sprintf(
" %s\t--%s\t%s\t%s\t[default: %s]",
style.Bold.Text(shorthand),
style.Bold.Text(name),
fl.Type(),
fl.Usage(),
fl.Default(),
)
}

fmt.Fprintln(tw, line)
}

Expand Down
112 changes: 112 additions & 0 deletions command_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -506,6 +506,39 @@ func TestHelp(t *testing.T) {
},
wantErr: false,
},
{
name: "flag with env var",
options: []cli.Option{
cli.OverrideArgs([]string{"--help"}),
cli.Short("A test command"),
cli.Flag(new(bool), "force", 'f', "Force something", cli.Env[bool]("MYTOOL_FORCE")),
cli.Run(func(_ context.Context, _ *cli.Command) error { return nil }),
},
wantErr: false,
},
{
name: "flags with multiple env vars",
options: []cli.Option{
cli.OverrideArgs([]string{"--help"}),
cli.Short("A test command"),
cli.Flag(new(bool), "force", 'f', "Force something", cli.Env[bool]("MYTOOL_FORCE")),
cli.Flag(new(int), "count", 'c', "A much longer usage description here", cli.Env[int]("MYTOOL_COUNT")),
cli.Flag(new(string), "name", 'n', "Name", cli.Env[string]("MYTOOL_NAME")),
cli.Run(func(_ context.Context, _ *cli.Command) error { return nil }),
},
wantErr: false,
},
{
name: "flag with default and env var",
options: []cli.Option{
cli.OverrideArgs([]string{"--help"}),
cli.Short("A test command"),
cli.Flag(new(int), "count", 'c', "Count things", cli.FlagDefault(5), cli.Env[int]("MYTOOL_COUNT")),
cli.Flag(new(string), "name", 'n', "A name", cli.Env[string]("MYTOOL_NAME")),
cli.Run(func(_ context.Context, _ *cli.Command) error { return nil }),
},
wantErr: false,
},
}

for _, tt := range tests {
Expand Down Expand Up @@ -852,6 +885,85 @@ func TestExecuteNilCommand(t *testing.T) {
}
}

func TestEnvFlag(t *testing.T) {
tests := []struct {
name string
setup func(t *testing.T)
stdout string
errMsg string
args []string
wantErr bool
}{
{
name: "bool flag set via env var",
setup: func(t *testing.T) {
t.Setenv("MYTOOL_FORCE", "true")
},
stdout: "force: true\n",
args: []string{},
wantErr: false,
},
{
name: "CLI bool flag overrides env var",
setup: func(t *testing.T) {
t.Setenv("MYTOOL_FORCE", "true")
},
stdout: "force: false\n",
args: []string{"--force=false"},
wantErr: false,
},
{
name: "env var not set leaves flag at default",
stdout: "force: false\n",
args: []string{},
wantErr: false,
},
{
name: "invalid env var value propagates error through Execute",
setup: func(t *testing.T) {
t.Setenv("MYTOOL_FORCE", "notabool")
},
args: []string{},
wantErr: true,
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`,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.setup != nil {
tt.setup(t)
}

var force bool

stdout := &bytes.Buffer{}

cmd, err := cli.New("test",
cli.Stdout(stdout),
cli.Flag(&force, "force", flag.NoShortHand, "Force something", cli.Env[bool]("MYTOOL_FORCE")),
cli.OverrideArgs(tt.args),
cli.Run(func(ctx context.Context, cmd *cli.Command) error {
fmt.Fprintf(cmd.Stdout(), "force: %v\n", force)
return nil
}),
)
test.Ok(t, err)

err = cmd.Execute(t.Context())
test.WantErr(t, err, tt.wantErr)

if tt.wantErr && tt.errMsg != "" {
test.Equal(t, err.Error(), tt.errMsg)
}

if !tt.wantErr {
test.Equal(t, stdout.String(), tt.stdout)
}
})
}
}

// The order in which we apply options shouldn't matter, this test
// shuffles the order of the options and asserts the Command we get
// out behaves the same as a baseline.
Expand Down
2 changes: 1 addition & 1 deletion examples/subcommands/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ func buildDoCommand() (*cli.Command, error) {
cli.Example("Do it for a specific duration", "demo do something --duration 1m30s"),
cli.Version("do version"),
cli.Arg(&thing, "thing", "Thing to do"),
cli.Flag(&options.count, "count", 'c', "Number of times to do the thing", cli.FlagDefault(1)),
cli.Flag(&options.count, "count", 'c', "Number of times to do the thing", cli.FlagDefault(1), cli.Env[int]("DEMO_COUNT")),
cli.Flag(&options.fast, "fast", 'f', "Do the thing quickly"),
cli.Flag(&options.verbosity, "verbosity", 'v', "Increase the verbosity level"),
cli.Flag(&options.duration, "duration", 'd', "Do the thing for a specific duration", cli.FlagDefault(1*time.Second)),
Expand Down
3 changes: 3 additions & 0 deletions internal/flag/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,7 @@ import "go.followtheprocess.codes/cli/flag"
type Config[T flag.Flaggable] struct {
// DefaultValue holds the intended default value of the flag.
DefaultValue T
// EnvVar is the name of an environment variable that may set this flag's value
// if the flag is not explicitly provided on the command line.
EnvVar string
}
41 changes: 33 additions & 8 deletions internal/flag/flag.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,11 @@ var _ Value = Flag[string]{} // This will fail if we violate our Value interface

// Flag represents a single command line flag.
type Flag[T flag.Flaggable] struct {
value *T // The actual stored value
name string // The name of the flag as appears on the command line, e.g. "force" for a --force flag
usage string // One line description of the flag, e.g. "Force deletion without confirmation"
short rune // Optional shorthand version of the flag, e.g. "f" for a -f flag
value *T // The actual stored value
name string // The name of the flag as appears on the command line, e.g. "force" for a --force flag
usage string // one line description of the flag, e.g. "Force deletion without confirmation"
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
short rune // Optional shorthand version of the flag, e.g. "f" for a -f flag
}

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

flag := Flag[T]{
value: p,
name: name,
usage: usage,
short: short,
value: p,
name: name,
usage: usage,
short: short,
envVar: config.EnvVar,
}

return flag, nil
Expand Down Expand Up @@ -89,6 +91,29 @@ func (f Flag[T]) Default() string {
return f.String()
}

// EnvVar returns the name of the environment variable associated with this flag,
// or an empty string if none was configured.
func (f Flag[T]) EnvVar() string {
return f.envVar
}

// IsSlice reports whether the flag holds a slice value that accumulates repeated
// calls to Set. Returns false for []byte and net.IP, which are parsed atomically.
func (f Flag[T]) IsSlice() bool {
if f.value == nil {
return false
}

switch any(*f.value).(type) {
case []int, []int8, []int16, []int32, []int64,
[]uint, []uint16, []uint32, []uint64,
[]float32, []float64, []string:
return true
default:
return false
}
}

// NoArgValue returns a string representation of value the flag should hold
// when it is given no arguments on the command line. For example a boolean flag
// --delete, when passed without arguments implies --delete true.
Expand Down
61 changes: 56 additions & 5 deletions internal/flag/set.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"errors"
"fmt"
"iter"
"os"
"slices"
"strings"

Expand All @@ -13,17 +14,19 @@ import (

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

// NewSet builds and returns a new set of flags.
func NewSet() *Set {
return &Set{
flags: make(map[string]Value),
shorthands: make(map[rune]Value),
envVars: make(map[string]string),
}
}

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

set.flags[name] = f

if f.envVar != "" {
set.envVars[name] = f.envVar
}

// Only add the shorthand if it wasn't opted out of
if short != flag.NoShortHand {
set.shorthands[short] = f
Expand Down Expand Up @@ -143,11 +150,17 @@ func (s *Set) ExtraArgs() []string {
}

// Parse parses flags and their values from the command line.
func (s *Set) Parse(args []string) (err error) {
func (s *Set) Parse(args []string) error {
var err error

if s == nil {
return errors.New("Parse called on a nil set")
}

if err = s.applyEnvVars(); err != nil {
return fmt.Errorf("could not set flag from env: %w", err)
}

for len(args) > 0 {
arg := args[0] // The argument we're currently inspecting
args = args[1:] // Remainder
Expand Down Expand Up @@ -202,6 +215,44 @@ func (s *Set) All() iter.Seq2[string, Value] {
}
}

// applyEnvVars looks up each configured environment variable and applies its value
// to the corresponding flag. It is called at the start of Parse so that CLI args
// parsed afterward naturally override these values.
//
// Slice flags accept comma-separated values e.g. MYTOOL_ITEMS='one,two,three'.
// Empty and unset variables are ignored.
func (s *Set) applyEnvVars() error {
for name, envName := range s.envVars {
val, ok := os.LookupEnv(envName)
if !ok || val == "" {
continue
}

f, ok := s.flags[name]
if !ok {
return fmt.Errorf("flag %q does not exist", name)
}

if f.IsSlice() {
for item := range strings.SplitSeq(val, ",") {
if item = strings.TrimSpace(item); item != "" {
if err := f.Set(item); err != nil {
return fmt.Errorf("env var %s: %w", envName, err)
}
}
}

continue
}

if err := f.Set(val); err != nil {
return fmt.Errorf("env var %s: %w", envName, err)
}
}

return nil
}

// parseLongFlag parses a single long flag e.g. --delete. It is passed
// the possible long flag and the rest of the argument list and returns
// the remaining arguments after it's done parsing to the caller.
Expand Down
Loading