From 4f27f5f7328175d4d9e59af17af292f12fb86d89 Mon Sep 17 00:00:00 2001 From: idelchi Date: Mon, 20 Jul 2026 21:46:13 +0200 Subject: [PATCH 1/4] Add Required field to single-value arguments --- args.go | 5 +++ args_test.go | 75 +++++++++++++++++++++++++++++++++++++++++ godoc-current.txt | 1 + testdata/godoc-v3.x.txt | 1 + 4 files changed, 82 insertions(+) diff --git a/args.go b/args.go index b3acfd5ccc..9f77ccfe07 100644 --- a/args.go +++ b/args.go @@ -89,6 +89,7 @@ type ArgumentBase[T any, C any, VC ValueCreator[T, C]] struct { Value T `json:"value"` // the default value of this argument Destination *T `json:"-"` // the destination point for this argument UsageText string `json:"usageText"` // the usage text to show + Required bool `json:"required"` // whether the argument is required or not Config C `json:"config"` // config for this argument similar to Flag Config value *T @@ -110,6 +111,10 @@ func (a *ArgumentBase[T, C, VC]) Usage() string { func (a *ArgumentBase[T, C, VC]) Parse(s []string) ([]string, error) { tracef("calling arg%[1] parse with args %[2]", a.Name, s) + if a.Required && len(s) == 0 { + return s, fmt.Errorf("required argument %q not set", a.Name) + } + var vc VC var t T value := vc.Create(a.Value, &t, a.Config) diff --git a/args_test.go b/args_test.go index 7ae337487f..a4f130a3f1 100644 --- a/args_test.go +++ b/args_test.go @@ -552,6 +552,81 @@ func TestSingleOptionalArg(t *testing.T) { } } +func TestSingleRequiredArg(t *testing.T) { + tests := []struct { + name string + args []string + argValue string + exp string + expErr string + }{ + { + name: "no args", + args: []string{"foo"}, + expErr: `required argument "sa" not set`, + }, + { + name: "no arg with def value", + args: []string{"foo"}, + argValue: "bar", + expErr: `required argument "sa" not set`, + }, + { + name: "one arg", + args: []string{"foo", "zbar"}, + exp: "zbar", + }, + { + name: "empty string arg", + args: []string{"foo", ""}, + exp: "", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + cmd := buildMinimalTestCommand() + var s1 string + arg := &StringArg{ + Name: "sa", + Value: test.argValue, + Destination: &s1, + Required: true, + } + cmd.Arguments = []Argument{ + arg, + } + + err := cmd.Run(buildTestContext(t), test.args) + r := require.New(t) + if test.expErr != "" { + r.EqualError(err, test.expErr) + return + } + r.NoError(err) + r.Equal(test.exp, s1) + }) + } +} + +func TestChainedRequiredArgs(t *testing.T) { + cmd := buildMinimalTestCommand() + cmd.Arguments = []Argument{ + &StringArg{ + Name: "first", + Required: true, + }, + &StringArg{ + Name: "second", + Required: true, + }, + } + + r := require.New(t) + r.EqualError(cmd.Run(buildTestContext(t), []string{"foo", "one"}), `required argument "second" not set`) + r.NoError(cmd.Run(buildTestContext(t), []string{"foo", "one", "two"})) +} + func TestUnboundedArgs(t *testing.T) { arg := &StringArgs{ Min: 0, diff --git a/godoc-current.txt b/godoc-current.txt index d680c5f544..8f1889452e 100644 --- a/godoc-current.txt +++ b/godoc-current.txt @@ -294,6 +294,7 @@ type ArgumentBase[T any, C any, VC ValueCreator[T, C]] struct { Value T `json:"value"` // the default value of this argument Destination *T `json:"-"` // the destination point for this argument UsageText string `json:"usageText"` // the usage text to show + Required bool `json:"required"` // whether the argument is required or not Config C `json:"config"` // config for this argument similar to Flag Config // Has unexported fields. diff --git a/testdata/godoc-v3.x.txt b/testdata/godoc-v3.x.txt index d680c5f544..8f1889452e 100644 --- a/testdata/godoc-v3.x.txt +++ b/testdata/godoc-v3.x.txt @@ -294,6 +294,7 @@ type ArgumentBase[T any, C any, VC ValueCreator[T, C]] struct { Value T `json:"value"` // the default value of this argument Destination *T `json:"-"` // the destination point for this argument UsageText string `json:"usageText"` // the usage text to show + Required bool `json:"required"` // whether the argument is required or not Config C `json:"config"` // config for this argument similar to Flag Config // Has unexported fields. From 84d3b75f4c6936ccb5a46c2ab27d12e5b424bf4e Mon Sep 17 00:00:00 2001 From: idelchi Date: Mon, 20 Jul 2026 21:49:53 +0200 Subject: [PATCH 2/4] Render optional single-value arguments bracketed in help --- args.go | 5 +++- args_test.go | 8 +++++- docs/v3/examples/arguments/advanced.md | 39 ++++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 2 deletions(-) diff --git a/args.go b/args.go index 9f77ccfe07..dee84091e2 100644 --- a/args.go +++ b/args.go @@ -104,7 +104,10 @@ func (a *ArgumentBase[T, C, VC]) Usage() string { return a.UsageText } - usageFormat := "%[1]s" + usageFormat := "[%[1]s]" + if a.Required { + usageFormat = "%[1]s" + } return fmt.Sprintf(usageFormat, a.Name) } diff --git a/args_test.go b/args_test.go index a4f130a3f1..3f7c647418 100644 --- a/args_test.go +++ b/args_test.go @@ -422,10 +422,16 @@ func TestArgUsage(t *testing.T) { tests := []struct { name string usage string + required bool expected string }{ { name: "default", + expected: "[ia]", + }, + { + name: "required", + required: true, expected: "ia", }, { @@ -436,7 +442,7 @@ func TestArgUsage(t *testing.T) { } for _, test := range tests { t.Run(test.name, func(t *testing.T) { - arg.UsageText = test.usage + arg.UsageText, arg.Required = test.usage, test.required require.Equal(t, test.expected, arg.Usage()) }) } diff --git a/docs/v3/examples/arguments/advanced.md b/docs/v3/examples/arguments/advanced.md index 7ab3da9a55..5eb91341d5 100644 --- a/docs/v3/examples/arguments/advanced.md +++ b/docs/v3/examples/arguments/advanced.md @@ -111,6 +111,45 @@ Some of the basic types arguments supported are This is ok for single value arguments. Any number of these single value arguments can be concatenated in the `Arguments` slice field of `Command`. +Single value arguments are optional by default. If the argument is not provided the default `Value` is used instead. +You can mark a single value argument as *required* by setting the `Required` field to `true`. If a user does not +provide a required argument, they will be shown an error message. + + +```go +package main + +import ( + "fmt" + "log" + "os" + "context" + + "github.com/urfave/cli/v3" +) + +func main() { + cmd := &cli.Command{ + Arguments: []cli.Argument{ + &cli.IntArg{ + Name: "someint", + Required: true, + }, + }, + Action: func(ctx context.Context, cmd *cli.Command) error { + fmt.Printf("We got %d", cmd.IntArg("someint")) + return nil + }, + } + + if err := cmd.Run(context.Background(), os.Args); err != nil { + log.Fatal(err) + } +} +``` + The library also support multi value arguments for e.g ```go package main import ( + "context" "fmt" "log" "os" - "context" "github.com/urfave/cli/v3" ) diff --git a/errors.go b/errors.go index ffd6471f23..ff0dfa51d7 100644 --- a/errors.go +++ b/errors.go @@ -61,6 +61,22 @@ func (e *errRequiredFlags) Error() string { return fmt.Sprintf("Required flags %q not set", joinedMissingFlags) } +type requiredArgumentsErr interface { + error +} + +type errRequiredArguments struct { + missingArguments []string +} + +func (e *errRequiredArguments) Error() string { + if len(e.missingArguments) == 1 { + return fmt.Sprintf("Required argument %q not set", e.missingArguments[0]) + } + joinedMissingArguments := strings.Join(e.missingArguments, ", ") + return fmt.Sprintf("Required arguments %q not set", joinedMissingArguments) +} + type mutuallyExclusiveGroup struct { flag1Name string flag2Name string diff --git a/errors_test.go b/errors_test.go index e995b6f250..fd4d18c77c 100644 --- a/errors_test.go +++ b/errors_test.go @@ -233,6 +233,18 @@ func TestErrRequiredFlags_Error(t *testing.T) { assert.Equal(t, expectedMsg, err.Error()) } +func TestErrRequiredArguments_Error(t *testing.T) { + missingArguments := []string{"first", "second"} + err := &errRequiredArguments{missingArguments: missingArguments} + expectedMsg := "Required arguments \"first, second\" not set" + assert.Equal(t, expectedMsg, err.Error()) + + missingArguments = []string{"first"} + err = &errRequiredArguments{missingArguments: missingArguments} + expectedMsg = "Required argument \"first\" not set" + assert.Equal(t, expectedMsg, err.Error()) +} + func TestHandleExitCoder_ExitCoderEmptyMessage(t *testing.T) { exitCode := 0 called := false diff --git a/help_test.go b/help_test.go index a3f3476a86..2548dfc1ff 100644 --- a/help_test.go +++ b/help_test.go @@ -38,6 +38,38 @@ func Test_ShowRootCommandHelp_NoVersion(t *testing.T) { } } +func TestArgumentRequiredUsageInCommandHelp(t *testing.T) { + tests := []struct { + name string + required bool + expected string + }{ + {name: "optional", expected: "test run [options] [sa]"}, + {name: "required", required: true, expected: "test run [options] sa"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + output := &bytes.Buffer{} + cmd := &Command{ + Name: "test", + Writer: output, + Commands: []*Command{ + { + Name: "run", + Arguments: []Argument{ + &StringArg{Name: "sa", Required: test.required}, + }, + }, + }, + } + + require.NoError(t, cmd.Run(buildTestContext(t), []string{"test", "run", "--help"})) + require.Contains(t, output.String(), test.expected) + }) + } +} + func Test_ShowRootCommandHelp_HideVersion(t *testing.T) { output := new(bytes.Buffer) cmd := &Command{Writer: output} From 7187b7dcbdc7b6960fe7a03fa3dd84e93401ede8 Mon Sep 17 00:00:00 2001 From: idelchi Date: Sun, 16 Aug 2026 12:43:38 +0200 Subject: [PATCH 4/4] Address required argument review follow-up --- args_test.go | 46 ++++++++++++++++++++++++++++++++++++++++++++++ command.go | 10 ++++++++++ command_run.go | 33 +++++++++++++++++++-------------- 3 files changed, 75 insertions(+), 14 deletions(-) diff --git a/args_test.go b/args_test.go index 09cad66ed2..ac3d754a14 100644 --- a/args_test.go +++ b/args_test.go @@ -674,6 +674,52 @@ func TestRequiredArgAfterOptionalArg(t *testing.T) { r.NoError(cmd.Run(buildTestContext(t), []string{"foo", "one", "two"})) } +func TestRequiredArgAfterMultiValueArgUsesRequiredErrorHandling(t *testing.T) { + writer := &bytes.Buffer{} + errWriter := &bytes.Buffer{} + cmd := buildMinimalTestCommand() + cmd.Writer = writer + cmd.ErrWriter = errWriter + cmd.Arguments = []Argument{ + &StringArgs{Name: "rest", Min: 0, Max: -1}, + &StringArg{Name: "required", Required: true}, + } + + err := cmd.Run(buildTestContext(t), []string{"foo", "one", "two"}) + + r := require.New(t) + r.IsType(&errRequiredArguments{}, err) + r.True(cmd.isInError) + r.Contains(errWriter.String(), `Incorrect Usage: Required argument "required" not set`) + r.Contains(writer.String(), "NAME:") +} + +func TestCheckRequiredArgumentsSkipsHelpAndCompletionCommands(t *testing.T) { + tests := []struct { + name string + cmd *Command + }{ + { + name: "built-in help", + cmd: &Command{builtInHelp: true}, + }, + { + name: "completion", + cmd: &Command{isCompletionCommand: true}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + test.cmd.Arguments = []Argument{ + &StringArg{Name: "required", Required: true}, + } + + require.NoError(t, test.cmd.checkRequiredArguments()) + }) + } +} + func TestRequiredArgWithOnUsageError(t *testing.T) { expectedErr := errors.New("OnUsageError") cmd := buildMinimalTestCommand() diff --git a/command.go b/command.go index 2b43d69af1..6f80294755 100644 --- a/command.go +++ b/command.go @@ -455,9 +455,19 @@ func (cmd *Command) checkRequiredFlags() requiredFlagsErr { } func (cmd *Command) checkRequiredArguments() requiredArgumentsErr { + // The help and completion commands are allowed to run without + // enforcement of required arguments, since they do not invoke user + // actions that depend on those argument values. + if cmd.builtInHelp || cmd.isCompletionCommand { + return nil + } + tracef("checking for required arguments (cmd=%[1]q)", cmd.Name) missingArguments := []string{} + // This count-based precheck relies on required single-value arguments + // being declared before optional or multi-value arguments, as documented. + // Argument.Parse remains the backstop for unsupported orderings. providedArguments := cmd.Args().Len() for index, arg := range cmd.Arguments { diff --git a/command_run.go b/command_run.go index 254cd48517..e0fd3f435c 100644 --- a/command_run.go +++ b/command_run.go @@ -350,20 +350,7 @@ func (cmd *Command) run(ctx context.Context, osArgs []string) (_ context.Context requiredErr = err } if requiredErr != nil { - cmd.isInError = true - if cmd.OnUsageError != nil { - requiredErr = cmd.OnUsageError(ctx, cmd, requiredErr, cmd.parent != nil) - } else { - fmt.Fprintf(cmd.Root().ErrWriter, "Incorrect Usage: %s\n\n", requiredErr.Error()) - if cmd.parent == nil { - _ = ShowRootCommandHelp(cmd) - } else { - if err := ShowCommandHelp(ctx, cmd.parent, cmd.Name); err != nil { - _ = ShowSubcommandHelp(cmd) - } - } - } - return ctx, requiredErr + return cmd.handleRequiredError(ctx, requiredErr) } // Run the command action. @@ -375,6 +362,9 @@ func (cmd *Command) run(ctx context.Context, osArgs []string) (_ context.Context rargs, err = arg.Parse(rargs) if err != nil { tracef("calling with %[1]v (cmd=%[2]q)", err, cmd.Name) + if _, ok := err.(*errRequiredArguments); ok { + return cmd.handleRequiredError(ctx, err) + } if cmd.OnUsageError != nil { err = cmd.OnUsageError(ctx, cmd, err, cmd.parent != nil) } @@ -394,6 +384,21 @@ func (cmd *Command) run(ctx context.Context, osArgs []string) (_ context.Context return ctx, deferErr } +func (cmd *Command) handleRequiredError(ctx context.Context, err error) (context.Context, error) { + cmd.isInError = true + if cmd.OnUsageError != nil { + err = cmd.OnUsageError(ctx, cmd, err, cmd.parent != nil) + } else { + fmt.Fprintf(cmd.Root().ErrWriter, "Incorrect Usage: %s\n\n", err.Error()) + if cmd.parent == nil { + _ = ShowRootCommandHelp(cmd) + } else if helpErr := ShowCommandHelp(ctx, cmd.parent, cmd.Name); helpErr != nil { + _ = ShowSubcommandHelp(cmd) + } + } + return ctx, err +} + func commandChain(cmd *Command) []*Command { var cmdChain []*Command for p := cmd; p != nil; p = p.parent {