Repository: gruntwork-io/terragrunt (language: go)

Issue:
Improve error handling of incorrectly used flags
When a flag is used on the wrong Terragrunt command, users should be given guidance on the best way to address it.

```bash
$terragrunt --experiment cli-redesign --terragrunt-out-dir /tmp/plan  run  -- plan
19:11:55.541 ERROR  OpenTofu has no command named "/tmp/plan". To see all of OpenTofu's top-level commands, run: tofu -help
```

We should do a preliminary assessment of potentially misused flags and surface a better error so that users know how to recover more easily.

e.g.

```bash
$terragrunt --experiment cli-redesign --terragrunt-out-dir /tmp/plan  run  -- plan
19:11:55.541 ERROR  flag --terragrunt-out-dir is not a valid flag for plan. Did you mean to use 'run-all plan'?
```

Blocked by #3723 


Discussion / hints:

Fix patch:
diff --git a/cli/app.go b/cli/app.go
index 572fbf08ed..1bd2bf2205 100644
--- a/cli/app.go
+++ b/cli/app.go
@@ -20,6 +20,7 @@ import (
 
 	"github.com/gruntwork-io/terragrunt/cli/commands"
 	"github.com/gruntwork-io/terragrunt/cli/commands/graph"
+	"github.com/gruntwork-io/terragrunt/cli/flags"
 	"github.com/gruntwork-io/terragrunt/cli/flags/global"
 
 	"github.com/gruntwork-io/go-commons/version"
@@ -49,6 +50,8 @@ type App struct {
 
 // NewApp creates the Terragrunt CLI App.
 func NewApp(opts *options.TerragruntOptions) *App {
+	terragruntCommands := commands.New(opts)
+
 	app := cli.NewApp()
 	app.Name = "terragrunt"
 	app.Usage = "Terragrunt is a flexible orchestration tool that allows Infrastructure as Code written in OpenTofu/Terraform to scale.\nFor documentation, see https://terragrunt.gruntwork.io/."
@@ -57,10 +60,11 @@ func NewApp(opts *options.TerragruntOptions) *App {
 	app.Writer = opts.Writer
 	app.ErrWriter = opts.ErrWriter
 	app.Flags = global.NewFlagsWithDeprecatedMovedFlags(opts)
-	app.Commands = commands.New(opts).WrapAction(WrapWithTelemetry(opts))
+	app.Commands = terragruntCommands.WrapAction(WrapWithTelemetry(opts))
 	app.Before = beforeAction(opts)
 	app.OsExiter = OSExiter
 	app.ExitErrHandler = ExitErrHandler
+	app.FlagErrHandler = flags.ErrorHandler(terragruntCommands)
 
 	return &App{app, opts}
 }
diff --git a/cli/flags/error_handler.go b/cli/flags/error_handler.go
new file mode 100644
index 0000000000..624ff76f23
--- /dev/null
+++ b/cli/flags/error_handler.go
@@ -0,0 +1,64 @@
+package flags
+
+import (
+	"slices"
+	"strings"
+
+	"github.com/gruntwork-io/terragrunt/internal/cli"
+	"github.com/gruntwork-io/terragrunt/internal/errors"
+	"github.com/gruntwork-io/terragrunt/util"
+)
+
+// ErrorHandler returns `FlagErrHandlerFunc` which takes a flag parsing error
+// and tries to suggest the correct command to use with this flag. Otherwise returns the error as is.
+func ErrorHandler(commands cli.Commands) cli.FlagErrHandlerFunc {
+	return func(ctx *cli.Context, err error) error {
+		var undefinedFlagErr cli.UndefinedFlagError
+		if !errors.As(err, &undefinedFlagErr) {
+			return err
+		}
+
+		undefFlag := string(undefinedFlagErr)
+
+		if cmds, flag := findFlagInCommands(commands, undefFlag); cmds != nil {
+			var (
+				flagHint = util.FirstElement(util.RemoveEmptyElements(flag.Names()))
+				cmdHint  = strings.Join(cmds.Names(), " ")
+			)
+
+			if ctx.Parent().Command == nil {
+				return NewGlobalFlagHintError(undefFlag, cmdHint, flagHint)
+			}
+
+			return NewCommandFlagHintError(ctx.Command.Name, undefFlag, cmdHint, flagHint)
+		}
+
+		return err
+	}
+}
+
+func findFlagInCommands(commands cli.Commands, undefFlag string) (cli.Commands, cli.Flag) {
+	if len(commands) == 0 {
+		return nil, nil
+	}
+
+	for _, cmd := range commands {
+		for _, flag := range cmd.Flags {
+			flagNames := flag.Names()
+
+			if flag, ok := flag.(interface{ DeprecatedNames() []string }); ok {
+				flagNames = append(flagNames, flag.DeprecatedNames()...)
+			}
+
+			if slices.Contains(flagNames, undefFlag) {
+				return cli.Commands{cmd}, flag
+			}
+		}
+
+		if cmds, flag := findFlagInCommands(cmd.Subcommands, undefFlag); cmds != nil {
+			return append(cli.Commands{cmd}, cmds...), flag
+		}
+	}
+
+	return nil, nil
+}
diff --git a/cli/flags/errors.go b/cli/flags/errors.go
new file mode 100644
index 0000000000..dee28518b5
--- /dev/null
+++ b/cli/flags/errors.go
@@ -0,0 +1,45 @@
+package flags
+
+import "fmt"
+
+var _ error = new(GlobalFlagHintError)
+
+type GlobalFlagHintError struct {
+	undefFlag string
+	cmdHint   string
+	flagHint  string
+}
+
+func NewGlobalFlagHintError(undefFlag, cmdHint, flagHint string) *GlobalFlagHintError {
+	return &GlobalFlagHintError{
+		undefFlag: undefFlag,
+		cmdHint:   cmdHint,
+		flagHint:  flagHint,
+	}
+}
+
+func (err GlobalFlagHintError) Error() string {
+	return fmt.Sprintf("flag `--%s` is not a valid global flag. Did you mean to use `%s --%s`?", err.undefFlag, err.cmdHint, err.flagHint)
+}
+
+var _ error = new(CommandFlagHintError)
+
+type CommandFlagHintError struct {
+	undefFlag string
+	wrongCmd  string
+	cmdHint   string
+	flagHint  string
+}
+
+func NewCommandFlagHintError(wrongCmd, undefFlag, cmdHint, flagHint string) *CommandFlagHintError {
+	return &CommandFlagHintError{
+		undefFlag: undefFlag,
+		wrongCmd:  wrongCmd,
+		cmdHint:   cmdHint,
+		flagHint:  flagHint,
+	}
+}
+
+func (err CommandFlagHintError) Error() string {
+	return fmt.Sprintf("flag `--%s` is not a valid flag for `%s`. Did you mean to use `%s --%s`?", err.undefFlag, err.wrongCmd, err.cmdHint, err.flagHint)
+}
diff --git a/cli/flags/flag.go b/cli/flags/flag.go
index 933ad24f56..0fd9a95d84 100644
--- a/cli/flags/flag.go
+++ b/cli/flags/flag.go
@@ -51,6 +51,21 @@ func (newFlag *Flag) TakesValue() bool {
 	return !ok || !val
 }
 
+// DeprecatedNames returns all deprecated names for this flag.
+func (newFlag *Flag) DeprecatedNames() []string {
+	var names []string
+
+	if flag, ok := newFlag.Flag.(interface{ DeprecatedNames() []string }); ok {
+		names = flag.DeprecatedNames()
+	}
+
+	for _, deprecated := range newFlag.deprecatedFlags {
+		names = append(names, deprecated.Names()...)
+	}
+
+	return names
+}
+
 // Value implements `cli.Flag` interface.
 func (newFlag *Flag) Value() cli.FlagValue {
 	for _, deprecatedFlag := range newFlag.deprecatedFlags {
diff --git a/internal/cli/app.go b/internal/cli/app.go
index 682d83daee..ce15134620 100644
--- a/internal/cli/app.go
+++ b/internal/cli/app.go
@@ -49,6 +49,9 @@ type App struct {
 	// is used as the default behavior.
 	ExitErrHandler ExitErrHandlerFunc
 
+	// FlagErrHandler processes any error encountered while parsing flags.
+	FlagErrHandler FlagErrHandlerFunc
+
 	// Autocomplete enables or disables subcommand auto-completion support.
 	// This is enabled by default when NewApp is called. Otherwise, this
 	// must enabled explicitly.
diff --git a/internal/cli/command.go b/internal/cli/command.go
index 4fdf566978..911b78b961 100644
--- a/internal/cli/command.go
+++ b/internal/cli/command.go
@@ -6,8 +6,6 @@ import (
 	"strings"
 )
 
-const ErrFlagUndefined = "flag provided but not defined:"
-
 type Command struct {
 	// Name is the command name.
 	Name string
@@ -102,12 +100,16 @@ func (cmd *Command) VisibleSubcommands() Commands {
 // If this is the final command, starts its execution.
 func (cmd *Command) Run(ctx *Context, args Args) (err error) {
 	args, err = cmd.parseFlags(ctx, args.Slice())
+	ctx = ctx.NewCommandContext(cmd, args)
+
 	if err != nil {
+		if flagErrHandler := ctx.App.FlagErrHandler; flagErrHandler != nil {
+			err = flagErrHandler(ctx, err)
+		}
+
 		return NewExitError(err, ExitCodeGeneralError)
 	}
 
-	ctx = ctx.NewCommandContext(cmd, args)
-
 	subCmdName := ctx.Args().CommandName()
 	subCmdArgs := ctx.Args().Remove(subCmdName)
 	subCmd := cmd.Subcommand(subCmdName)
@@ -223,8 +225,8 @@ func (cmd *Command) flagSetParse(ctx *Context, flagSet *libflag.FlagSet, args Ar
 		}
 
 		if errStr := err.Error(); strings.HasPrefix(errStr, ErrFlagUndefined) {
-			err = UndefinedFlagError(errStr)
 			undefArg = strings.Trim(strings.TrimPrefix(errStr, ErrFlagUndefined), " -")
+			err = UndefinedFlagError(undefArg)
 		} else {
 			break
 		}
diff --git a/internal/cli/commands.go b/internal/cli/commands.go
index 4052d0ee9b..61697175a5 100644
--- a/internal/cli/commands.go
+++ b/internal/cli/commands.go
@@ -19,6 +19,17 @@ func (commands Commands) Get(name string) *Command {
 	return nil
 }
 
+// Names returns names of the commands.
+func (commands Commands) Names() []string {
+	var names = make([]string, len(commands))
+
+	for i, cmd := range commands {
+		names[i] = cmd.Name
+	}
+
+	return names
+}
+
 // Add adds a new cmd to the list.
 func (commands *Commands) Add(cmd *Command) {
 	*commands = append(*commands, cmd)
diff --git a/internal/cli/errors.go b/internal/cli/errors.go
index 964e06fb07..b3662500fc 100644
--- a/internal/cli/errors.go
+++ b/internal/cli/errors.go
@@ -107,8 +107,10 @@ func (err InvalidValueError) Unwrap() error {
 	return err.underlyingError
 }
 
+const ErrFlagUndefined = "flag provided but not defined:"
+
 type UndefinedFlagError string
 
-func (err UndefinedFlagError) Error() string {
-	return string(err)
+func (flag UndefinedFlagError) Error() string {
+	return ErrFlagUndefined + " -" + string(flag)
 }
diff --git a/internal/cli/funcs.go b/internal/cli/funcs.go
index d7c0755857..4cb88a98be 100644
--- a/internal/cli/funcs.go
+++ b/internal/cli/funcs.go
@@ -21,3 +21,6 @@ type SplitterFunc func(s, sep string) []string
 // ExitErrHandlerFunc is executed if provided in order to handle exitError values
 // returned by Actions and Before/After functions.
 type ExitErrHandlerFunc func(ctx *Context, err error) error
+
+// FlagErrHandlerFunc is executed if an error occurs while parsing flags.
+type FlagErrHandlerFunc func(ctx *Context, err error) error


Test patch:
diff --git a/cli/app_test.go b/cli/app_test.go
index a05910ac12..bb9525f658 100644
--- a/cli/app_test.go
+++ b/cli/app_test.go
@@ -61,13 +61,13 @@ func TestParseTerragruntOptionsFromArgs(t *testing.T) {
 		{
 			[]string{"--foo", "--bar"},
 			mockOptions(t, util.JoinPath(workingDir, config.DefaultTerragruntConfigPath), workingDir, []string{"-foo", "-bar"}, false, "", false, false, defaultLogLevel, false),
-			clipkg.UndefinedFlagError("flag provided but not defined: -foo"),
+			clipkg.UndefinedFlagError("foo"),
 		},
 
 		{
 			[]string{"--foo", "apply", "--bar"},
 			mockOptions(t, util.JoinPath(workingDir, config.DefaultTerragruntConfigPath), workingDir, []string{"apply", "-foo", "-bar"}, false, "", false, false, defaultLogLevel, false),
-			clipkg.UndefinedFlagError("flag provided but not defined: -foo"),
+			clipkg.UndefinedFlagError("foo"),
 		},
 
 		{
diff --git a/test/fixtures/cli-flag-hints/main.hcl b/test/fixtures/cli-flag-hints/main.hcl
new file mode 100644
index 0000000000..bb7b160deb
--- /dev/null
+++ b/test/fixtures/cli-flag-hints/main.hcl
@@ -0,0 +1,1 @@
+# Intentionally empty
diff --git a/test/fixtures/cli-flag-hints/terragrunt.hcl b/test/fixtures/cli-flag-hints/terragrunt.hcl
new file mode 100644
index 0000000000..bb7b160deb
--- /dev/null
+++ b/test/fixtures/cli-flag-hints/terragrunt.hcl
@@ -0,0 +1,1 @@
+# Intentionally empty
diff --git a/test/integration_test.go b/test/integration_test.go
index 63687561fc..48195fa5f3 100644
--- a/test/integration_test.go
+++ b/test/integration_test.go
@@ -17,6 +17,7 @@ import (
 	"github.com/gruntwork-io/terragrunt/cli/commands/run"
 	runall "github.com/gruntwork-io/terragrunt/cli/commands/run-all"
 	terragruntinfo "github.com/gruntwork-io/terragrunt/cli/commands/terragrunt-info"
+	"github.com/gruntwork-io/terragrunt/cli/flags"
 	"github.com/gruntwork-io/terragrunt/codegen"
 	"github.com/gruntwork-io/terragrunt/config"
 	"github.com/gruntwork-io/terragrunt/internal/errors"
@@ -107,6 +108,7 @@ const (
 	testFixtureExecCmd                        = "fixtures/exec-cmd"
 	textFixtureDisjointSymlinks               = "fixtures/stack/disjoint-symlinks"
 	testFixtureLogStreaming                   = "fixtures/streaming"
+	testFixtureCLIFlagHints                   = "fixtures/cli-flag-hints"
 
 	terraformFolder = ".terraform"
 
@@ -116,6 +118,38 @@ const (
 	terragruntCache      = ".terragrunt-cache"
 )
 
+func TestCLIFlagHints(t *testing.T) {
+	t.Parallel()
+
+	testCases := []struct {
+		args          string
+		expectedError error
+	}{
+		{
+			"-raw init",
+			flags.NewGlobalFlagHintError("raw", "stack output", "raw"),
+		},
+		{
+			"run --no-include-root",
+			flags.NewCommandFlagHintError("run", "no-include-root", "catalog", "no-include-root"),
+		},
+	}
+
+	for i, testCase := range testCases {
+		t.Run(fmt.Sprintf("testCase-%d", i), func(t *testing.T) {
+			t.Parallel()
+
+			helpers.CleanupTerraformFolder(t, testFixtureCLIFlagHints)
+			rootPath := helpers.CopyEnvironment(t, testFixtureCLIFlagHints)
+			rootPath, err := filepath.EvalSymlinks(rootPath)
+			require.NoError(t, err)
+
+			_, _, err = helpers.RunTerragruntCommandWithOutput(t, "terragrunt "+testCase.args+" --working-dir "+rootPath)
+			assert.EqualError(t, err, testCase.expectedError.Error())
+		})
+	}
+}
+
 func TestExecCommand(t *testing.T) {
 	t.Parallel()