diff --git a/.claude/agents/flag-handler.md b/.claude/agents/flag-handler.md index a5520c1c2c..ff7f7261c8 100644 --- a/.claude/agents/flag-handler.md +++ b/.claude/agents/flag-handler.md @@ -383,6 +383,166 @@ RunE: func(cmd *cobra.Command, args []string) error { } ``` +## Interactive Prompts (NEW) + +Atmos supports interactive prompts for missing required values when running in an interactive terminal. + +### Three Use Cases + +**Use Case 1: Missing Required Flags** +When a required flag is not provided, show an interactive selector: +```bash +$ atmos describe component vpc +? Choose a stack: + ue2-dev +> ue2-prod +``` + +**Use Case 2: Optional Value Flags (Sentinel Pattern)** +When a flag is used without a value (like `--identity`), trigger interactive selection: +```bash +$ atmos list stacks --format +? Choose output format: + yaml +> json + table +``` + +**Use Case 3: Missing Required Positional Arguments** +When a required positional argument is missing, show a selector: +```bash +$ atmos theme show +? Choose a theme to preview: + Dracula + Tokyo Night +> Nord +``` + +### Enabling Interactive Prompts + +Interactive prompts are enabled by default and require: +1. `--interactive` flag is `true` (default: true) +2. stdin is a TTY +3. Not running in CI environment + +Users can disable with `--interactive=false` or `ATMOS_INTERACTIVE=false`. + +### Implementation + +#### Use Case 1: Missing Required Flags + +```go +// In init(): +parser := flags.NewStandardParser( + flags.WithStringFlag("stack", "s", "", "Stack name"), + flags.WithCompletionPrompt("stack", "Choose a stack", stackFlagCompletion), +) +``` + +The flag must be marked as required in Cobra: +```go +cmd.MarkFlagRequired("stack") +``` + +#### Use Case 2: Optional Value Flags + +```go +// In init(): +parser := flags.NewStandardParser( + flags.WithStringFlag("identity", "i", "", "Identity to use"), + flags.WithOptionalValuePrompt("identity", "Choose identity", identityCompletion), +) + +// Set NoOptDefVal to sentinel value +cmd.Flags().Lookup("identity").NoOptDefVal = "__SELECT__" +``` + +#### Use Case 3: Missing Required Positional Arguments + +```go +// In init(): +builder := flags.NewPositionalArgsBuilder() +builder.AddArg(&flags.PositionalArgSpec{ + Name: "theme-name", + Description: "Theme name to preview", + Required: true, + CompletionFunc: ThemesArgCompletion, + PromptTitle: "Choose a theme to preview", +}) +specs, validator, usage := builder.Build() + +parser := flags.NewStandardParser( + flags.WithPositionalArgPrompt("theme-name", "Choose a theme to preview", ThemesArgCompletion), +) +parser.SetPositionalArgs(specs, validator, usage) + +// Update command Use and Args +cmd.Use = "show " + usage // "show " +cmd.Args = validator +``` + +#### Completion Functions + +Completion functions provide the list of options for the interactive selector: + +```go +func StackFlagCompletion(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + // Return list of stack names + return []string{"ue2-dev", "ue2-prod", "uw2-staging"}, cobra.ShellCompDirectiveNoFileComp +} +``` + +#### Graceful Degradation + +Prompts automatically degrade when not interactive: +- Returns empty string (lets Cobra validation handle the error) +- No panic or crash +- Works seamlessly in CI/non-TTY environments + +### Example: atmos theme show + +Complete working example in `cmd/theme/show.go`: + +```go +func init() { + builder := flags.NewPositionalArgsBuilder() + builder.AddArg(&flags.PositionalArgSpec{ + Name: "theme-name", + Description: "Theme name to preview", + Required: true, + CompletionFunc: ThemesArgCompletion, + PromptTitle: "Choose a theme to preview", + }) + specs, validator, usage := builder.Build() + + themeShowParser = flags.NewStandardParser( + flags.WithPositionalArgPrompt("theme-name", "Choose a theme to preview", ThemesArgCompletion), + ) + themeShowParser.SetPositionalArgs(specs, validator, usage) + + themeShowCmd.Use = "show " + usage + themeShowCmd.Args = validator + themeShowParser.RegisterFlags(themeShowCmd) +} + +func executeThemeShow(cmd *cobra.Command, args []string) error { + // Parse handles interactive prompts automatically + parsed, err := themeShowParser.Parse(cmd.Context(), args) + if err != nil { + return err + } + + if len(parsed.PositionalArgs) == 0 { + return errUtils.Build(errUtils.ErrInvalidPositionalArgs). + WithHintf("Theme name is required"). + Err() + } + + themeName := parsed.PositionalArgs[0] + // ... execute command +} +``` + ## Global Flags All commands inherit global flags automatically: @@ -398,6 +558,7 @@ type global.Flags struct { ForceColor bool ForceTTY bool Mask bool + Interactive bool // Enable interactive prompts (default: true) Pager string } ``` diff --git a/cmd/theme/show.go b/cmd/theme/show.go index 462ed839c7..41ca3bb106 100644 --- a/cmd/theme/show.go +++ b/cmd/theme/show.go @@ -14,6 +14,24 @@ import ( "github.com/cloudposse/atmos/pkg/ui/theme" ) +// ThemesArgCompletion provides auto-completion for theme names. +func ThemesArgCompletion(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + defer perf.Track(nil, "theme.ThemesArgCompletion")() + + registry, err := theme.NewRegistry() + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + themes := registry.List() + names := make([]string, 0, len(themes)) + for _, t := range themes { + names = append(names, t.Name) + } + + return names, cobra.ShellCompDirectiveNoFileComp +} + //go:embed markdown/atmos_theme_show_usage.md var themeShowUsage string @@ -27,19 +45,39 @@ type ThemeShowOptions struct { // themeShowCmd shows details and preview of a specific theme. var themeShowCmd = &cobra.Command{ - Use: "show [theme-name]", + Use: "show", Short: "Show details and preview of a specific theme", Long: "Display color palette and sample UI elements for a specific terminal theme.", Example: themeShowUsage, - Args: cobra.ExactArgs(1), - RunE: executeThemeShow, + // Args validator will be set by positional args builder. + RunE: executeThemeShow, } func init() { - // Create flag parser (no flags currently, but sets up the pattern). - themeShowParser = flags.NewStandardFlagParser() + // Build positional args specification. + builder := flags.NewPositionalArgsBuilder() + builder.AddArg(&flags.PositionalArgSpec{ + Name: "theme-name", + Description: "Theme name to preview", + Required: true, + TargetField: "ThemeName", + }) + specs, validator, usage := builder.Build() - // Register flags with cobra. + // Create flag parser with interactive prompt config. + themeShowParser = flags.NewStandardFlagParser( + flags.WithPositionalArgPrompt("theme-name", "Choose a theme to preview", ThemesArgCompletion), + ) + + // Set positional args configuration. + themeShowParser.SetPositionalArgs(specs, validator, usage) + + // Update command's Use field with positional args usage. + themeShowCmd.Use = "show " + usage + + // Register flags with cobra (registers positional arg completion). + // The flag handler will set a prompt-aware Args validator automatically + // when prompts are configured, allowing missing args to trigger prompts. themeShowParser.RegisterFlags(themeShowCmd) // Bind both env vars and pflags to viper for full precedence support (flag > env > config > default). @@ -59,9 +97,24 @@ func init() { func executeThemeShow(cmd *cobra.Command, args []string) error { defer perf.Track(atmosConfigPtr, "theme.show.RunE")() - // Parse command arguments into options. + // Parse flags and positional args (handles interactive prompts). + parsed, err := themeShowParser.Parse(cmd.Context(), args) + if err != nil { + return err + } + + // Extract theme name from positional args. + if len(parsed.PositionalArgs) == 0 { + return errUtils.Build(errUtils.ErrInvalidPositionalArgs). + WithExplanation("Theme name is required"). + WithHintf("Run `atmos list themes` to see all available themes"). + WithHint("Browse themes at https://atmos.tools/cli/commands/theme/browse"). + WithExitCode(2). + Err() + } + opts := &ThemeShowOptions{ - ThemeName: args[0], + ThemeName: parsed.PositionalArgs[0], } result := theme.ShowTheme(theme.ShowThemeOptions{ diff --git a/cmd/theme/theme_test.go b/cmd/theme/theme_test.go index d761094ffe..aa9b78ec0c 100644 --- a/cmd/theme/theme_test.go +++ b/cmd/theme/theme_test.go @@ -35,7 +35,7 @@ func TestThemeCommand(t *testing.T) { t.Run("has show subcommand", func(t *testing.T) { hasShowCmd := false for _, subCmd := range themeCmd.Commands() { - if subCmd.Use == "show [theme-name]" { + if subCmd.Use == "show " { hasShowCmd = true break } @@ -111,15 +111,16 @@ func TestThemeListCommand(t *testing.T) { func TestThemeShowCommand(t *testing.T) { t.Run("show command exists", func(t *testing.T) { - assert.Equal(t, "show [theme-name]", themeShowCmd.Use) + assert.Equal(t, "show ", themeShowCmd.Use) assert.NotEmpty(t, themeShowCmd.Short) assert.NotEmpty(t, themeShowCmd.Long) }) - t.Run("requires exactly one argument", func(t *testing.T) { - // Validate Args is set to ExactArgs(1). + t.Run("accepts zero or one argument (prompt-aware)", func(t *testing.T) { + // Validate Args is prompt-aware: allows 0 or 1 argument, rejects more than 1. + // Zero arguments allowed because interactive prompts will handle missing args. err := themeShowCmd.Args(themeShowCmd, []string{}) - assert.Error(t, err, "show command should require exactly one argument") + assert.NoError(t, err, "show command should allow zero arguments (prompts will handle)") err = themeShowCmd.Args(themeShowCmd, []string{"dracula"}) assert.NoError(t, err, "show command should accept one argument") @@ -921,6 +922,79 @@ func TestThemeListResultError(t *testing.T) { }) } +func TestThemesArgCompletion(t *testing.T) { + t.Run("returns list of theme names", func(t *testing.T) { + cmd := &cobra.Command{Use: "test"} + args := []string{} + + themes, directive := ThemesArgCompletion(cmd, args, "") + + // Should return some themes. + assert.NotEmpty(t, themes, "should return at least one theme") + + // Should disable file completion. + assert.Equal(t, cobra.ShellCompDirectiveNoFileComp, directive) + }) + + t.Run("returns expected built-in themes", func(t *testing.T) { + cmd := &cobra.Command{Use: "test"} + args := []string{} + + themes, _ := ThemesArgCompletion(cmd, args, "") + + // Check for some expected built-in themes. + assert.Contains(t, themes, "atmos", "should contain 'atmos' theme") + assert.Contains(t, themes, "Dracula", "should contain 'Dracula' theme") + }) + + t.Run("ignores toComplete parameter", func(t *testing.T) { + cmd := &cobra.Command{Use: "test"} + args := []string{} + + // The function returns all themes regardless of toComplete. + themes1, _ := ThemesArgCompletion(cmd, args, "") + themes2, _ := ThemesArgCompletion(cmd, args, "dra") + + // Both should return the same list. + assert.Equal(t, themes1, themes2, "should return same themes regardless of toComplete") + }) + + t.Run("ignores args parameter", func(t *testing.T) { + cmd := &cobra.Command{Use: "test"} + + themes1, _ := ThemesArgCompletion(cmd, []string{}, "") + themes2, _ := ThemesArgCompletion(cmd, []string{"arg1", "arg2"}, "") + + // Both should return the same list. + assert.Equal(t, themes1, themes2, "should return same themes regardless of args") + }) +} + +func TestExecuteThemeShowMissingArgs(t *testing.T) { + // Initialize I/O context and formatter for testing. + ioCtx, err := iolib.NewContext() + require.NoError(t, err, "Failed to create I/O context") + ui.InitFormatter(ioCtx) + + t.Run("returns error when no positional args provided", func(t *testing.T) { + // Setup + oldAtmosConfig := atmosConfigPtr + defer func() { atmosConfigPtr = oldAtmosConfig }() + atmosConfigPtr = nil + + // Disable interactive mode to prevent prompting. + viper.Set("interactive", false) + defer viper.Reset() + + // Execute with empty args. + err := executeThemeShow(themeShowCmd, []string{}) + + // Should return an error for missing theme name. + require.Error(t, err, "executeThemeShow should return an error when no args provided") + assert.ErrorIs(t, err, errUtils.ErrInvalidPositionalArgs, "should be invalid positional args error") + }) +} + func TestExecuteThemeShow(t *testing.T) { // Initialize I/O context and formatter for testing (required for ui.Markdown). ioCtx, err := iolib.NewContext() diff --git a/docs/developing-atmos-commands.md b/docs/developing-atmos-commands.md index 6a22f0dca6..0bdbfb7d61 100644 --- a/docs/developing-atmos-commands.md +++ b/docs/developing-atmos-commands.md @@ -792,9 +792,188 @@ See these commands for reference: --- +## Interactive Prompts (Recommended) + +**We recommend using interactive prompts** to make commands more user-friendly. Similar to shell autocomplete, prompts help users discover available options without memorizing values or checking documentation. + +Atmos provides built-in interactive selection menus for missing required flags and positional arguments using the Charmbracelet Huh library. This creates a better user experience by: + +- **Reducing cognitive load** - Users don't need to remember exact names +- **Preventing typos** - Selection from a list eliminates spelling errors +- **Improving discoverability** - Users see what options are available +- **Graceful degradation** - Automatically disabled in CI/non-TTY environments + +Prompts automatically appear when: + +1. **TTY is available** (stdin is a terminal) +2. **Not running in CI** (detected automatically) +3. **`--interactive` flag is true** (default: `true`, can be disabled with `--interactive=false` or `ATMOS_INTERACTIVE=false`) + +When disabled or unavailable, commands fall back to standard validation errors with helpful hints. + +### Use Case 1: Missing Required Positional Arguments + +When a command has a required positional argument with completion options, you can configure an interactive prompt: + +```go +// cmd/theme/show.go +func init() { + // Build positional args with prompt + builder := flags.NewPositionalArgsBuilder() + builder.AddArg(&flags.PositionalArgSpec{ + Name: "theme-name", + Description: "Theme name to preview", + Required: true, + TargetField: "ThemeName", + CompletionFunc: ThemesArgCompletion, // Provides options + }) + specs, validator, usage := builder.Build() + + // Create parser with interactive prompt + themeShowParser = flags.NewStandardFlagParser( + flags.WithPositionalArgPrompt( + "theme-name", // Arg name + "Choose a theme to preview", // Prompt title + ThemesArgCompletion, // Completion function + ), + ) + + // Set positional args + themeShowParser.SetPositionalArgs(specs, validator, usage) + themeShowCmd.Use = "show " + usage + + // Register flags (sets prompt-aware validator) + themeShowParser.RegisterFlags(themeShowCmd) +} +``` + +**Behavior:** +- `atmos theme show` → Shows interactive selector +- `atmos theme show dracula` → Uses provided value directly +- `atmos theme show --interactive=false` → Shows error if missing + +### Use Case 2: Optional Value Flags (Sentinel Pattern) + +For flags like `--identity` that can be provided with or without a value: + +```go +parser := flags.NewStandardFlagParser( + flags.WithStringFlag("identity", "i", "", "Identity name (use --identity to select interactively)"), + flags.WithNoOptDefVal("identity", cfg.IdentityFlagSelectValue), // Sentinel value + flags.WithOptionalValuePrompt( + "identity", + "Choose an identity", + IdentitiesCompletion, // Function that returns available identities + ), +) +``` + +**Behavior:** +- `atmos cmd --identity` → Shows interactive selector (flag set to sentinel) +- `atmos cmd --identity=admin` → Uses "admin" directly +- `atmos cmd` → Uses empty/default value + +### Use Case 3: Missing Required Flags + +For required flags with completion options: + +```go +parser := flags.NewStandardFlagParser( + flags.WithStackFlag(true), // Required + flags.WithCompletionPrompt( + "stack", + "Choose a stack", + StacksCompletion, // Function that returns available stacks + ), +) +``` + +**Behavior:** +- `atmos cmd` → Shows interactive selector if no stack provided +- `atmos cmd --stack=prod` → Uses provided value directly +- `ATMOS_STACK=prod atmos cmd` → Uses environment variable + +### Completion Functions + +Completion functions must match the `CompletionFunc` signature: + +```go +type CompletionFunc func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) +``` + +**Example:** +```go +func ThemesArgCompletion(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + themes := theme.GetRegisteredThemeNames() + return themes, cobra.ShellCompDirectiveNoFileComp +} +``` + +### Disabling Interactive Mode + +Users can disable interactive prompts: + +**Via flag:** +```bash +atmos theme show --interactive=false +``` + +**Via environment variable:** +```bash +ATMOS_INTERACTIVE=false atmos theme show +``` + +**In configuration:** +```yaml +# atmos.yaml +interactive: false +``` + +### Error Handling + +When users abort a prompt (Ctrl-C), the error is propagated: + +```bash +$ atmos theme show +? Choose a theme to preview +^C +# Error: prompt failed: user aborted +``` + +When not interactive (CI, piped, disabled), standard validation errors appear: + +```bash +$ echo "" | atmos theme show +# Error: invalid positional arguments +## Explanation +Theme name is required +## Hints +💡 Run `atmos list themes` to see all available themes +💡 Browse themes at https://atmos.tools/cli/commands/theme/browse +``` + +### Best Practices + +1. **Always provide completion functions** - Prompts need options to display +2. **Use descriptive prompt titles** - "Choose a theme to preview" not "Select theme" +3. **Handle empty results gracefully** - If completion returns no options, prompt is skipped +4. **Test non-interactive scenarios** - Ensure commands work in CI/pipelines +5. **Document the feature** - Update command help text to mention interactive selection + +### Example Commands + +See these commands for reference implementations: + +- **Positional args**: `cmd/theme/show.go` - Interactive theme selection +- **Optional value flags**: Auth commands with `--identity` flag +- **Required flags**: Commands using `WithCompletionPrompt` + +--- + ## Further Reading - [I/O and UI Output Guide](io-and-ui-output.md) - **How to handle output in commands** +- [Flag Handler Documentation](../.claude/agents/flag-handler.md) - **Complete flag handler guide** - [Command Registry Pattern PRD](prd/command-registry-pattern.md) - [Cobra Documentation](https://github.com/spf13/cobra) - [Atmos Custom Commands](/core-concepts/custom-commands) diff --git a/errors/errors.go b/errors/errors.go index c49cf10c39..b627ca946b 100644 --- a/errors/errors.go +++ b/errors/errors.go @@ -647,6 +647,10 @@ var ( ErrPathResolutionFailed = errors.New("failed to resolve component from path") ErrPathIsComponentBase = errors.New("must specify a component directory, not the base directory") ErrAmbiguousComponentPath = errors.New("ambiguous component path") + + // Interactive prompt errors. + ErrInteractiveModeNotAvailable = errors.New("interactive mode not available") + ErrNoOptionsAvailable = errors.New("no options available") ) // ExitCodeError is a typed error that preserves subcommand exit codes. diff --git a/internal/tui/utils/utils.go b/internal/tui/utils/utils.go index 1b96fc463c..718a43c363 100644 --- a/internal/tui/utils/utils.go +++ b/internal/tui/utils/utils.go @@ -11,7 +11,6 @@ import ( "github.com/arsham/figurine/figurine" "github.com/charmbracelet/glamour" "github.com/charmbracelet/huh" - "github.com/charmbracelet/lipgloss" "github.com/jwalton/go-supportscolor" "github.com/spf13/viper" xterm "golang.org/x/term" @@ -19,6 +18,7 @@ import ( "github.com/cloudposse/atmos/pkg/data" "github.com/cloudposse/atmos/pkg/schema" mdstyle "github.com/cloudposse/atmos/pkg/ui/markdown" + "github.com/cloudposse/atmos/pkg/ui/theme" ) const ( @@ -162,12 +162,27 @@ func RenderMarkdown(markdownText string, style string) (string, error) { } // NewAtmosHuhTheme returns the Atmos-styled Huh theme for interactive prompts. +// Uses the current theme's color scheme from pkg/ui/theme for consistency. func NewAtmosHuhTheme() *huh.Theme { t := huh.ThemeCharm() - cream := lipgloss.AdaptiveColor{Light: "#FFFDF5", Dark: "#FFFDF5"} - purple := lipgloss.AdaptiveColor{Light: "#5B00FF", Dark: "#5B00FF"} - t.Focused.FocusedButton = t.Focused.FocusedButton.Foreground(cream).Background(purple) - t.Focused.SelectSelector = t.Focused.SelectSelector.Foreground(purple) - t.Blurred.Title = t.Blurred.Title.Foreground(purple) + + // Get current theme styles for consistent colors. + styles := theme.GetCurrentStyles() + if styles == nil { + return t + } + + // Extract colors from theme for interactive elements. + buttonForeground := styles.Interactive.ButtonForeground.GetForeground() + buttonBackground := styles.Interactive.ButtonBackground.GetBackground() + primaryColor := styles.Selected.GetForeground() + + // Use theme's colors for interactive elements. + t.Focused.FocusedButton = t.Focused.FocusedButton. + Foreground(buttonForeground). + Background(buttonBackground) + t.Focused.SelectSelector = t.Focused.SelectSelector.Foreground(primaryColor) + t.Blurred.Title = t.Blurred.Title.Foreground(primaryColor) + return t } diff --git a/internal/tui/utils/utils_test.go b/internal/tui/utils/utils_test.go index b901a61840..6eccc4a2c0 100644 --- a/internal/tui/utils/utils_test.go +++ b/internal/tui/utils/utils_test.go @@ -7,6 +7,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/cloudposse/atmos/pkg/ui/theme" ) func TestHighlightCode(t *testing.T) { @@ -446,3 +448,30 @@ func TestRenderMarkdown_EdgeCases(t *testing.T) { // Just verify it renders without error - exact formatting may vary. }) } + +// TestNewAtmosHuhTheme tests the NewAtmosHuhTheme function. +func TestNewAtmosHuhTheme(t *testing.T) { + t.Run("returns a theme wired to current Atmos styles", func(t *testing.T) { + huhTheme := NewAtmosHuhTheme() + require.NotNil(t, huhTheme, "NewAtmosHuhTheme should return a non-nil theme") + + // Get the current Atmos styles to verify the theme uses them. + styles := theme.GetCurrentStyles() + require.NotNil(t, styles, "theme.GetCurrentStyles should return non-nil styles for this test") + + // Extract expected colors from Atmos theme. + expectedPrimary := styles.Selected.GetForeground() + expectedBtnFg := styles.Interactive.ButtonForeground.GetForeground() + expectedBtnBg := styles.Interactive.ButtonBackground.GetBackground() + + // Verify the Huh theme is wired to Atmos colors. + assert.Equal(t, expectedPrimary, huhTheme.Focused.SelectSelector.GetForeground(), + "SelectSelector should use theme's primary color") + assert.Equal(t, expectedPrimary, huhTheme.Blurred.Title.GetForeground(), + "Blurred.Title should use theme's primary color") + assert.Equal(t, expectedBtnFg, huhTheme.Focused.FocusedButton.GetForeground(), + "FocusedButton foreground should use theme's button foreground") + assert.Equal(t, expectedBtnBg, huhTheme.Focused.FocusedButton.GetBackground(), + "FocusedButton background should use theme's button background") + }) +} diff --git a/pkg/flags/global/flags.go b/pkg/flags/global/flags.go index 2ec3d194cd..a057323562 100644 --- a/pkg/flags/global/flags.go +++ b/pkg/flags/global/flags.go @@ -40,9 +40,10 @@ type Flags struct { NoColor bool // Terminal and I/O configuration. - ForceColor bool // Force color output even when not a TTY (--force-color). - ForceTTY bool // Force TTY mode with sane defaults (--force-tty). - Mask bool // Enable automatic masking of sensitive data (--mask). + ForceColor bool // Force color output even when not a TTY (--force-color). + ForceTTY bool // Force TTY mode with sane defaults (--force-tty). + Mask bool // Enable automatic masking of sensitive data (--mask). + Interactive bool // Enable interactive prompts for missing required flags, optional value flags using the sentinel pattern, and missing positional arguments (--interactive). // Output configuration. Pager PagerSelector @@ -81,6 +82,7 @@ func NewFlags() Flags { ForceColor: false, ForceTTY: false, Mask: true, // Enabled by default for security. + Interactive: true, // Enabled by default for better UX. Profile: []string{}, // No profiles active by default. ProfilerPort: DefaultProfilerPort, ProfilerHost: "localhost", diff --git a/pkg/flags/global_builder.go b/pkg/flags/global_builder.go index 530335eb0b..564ea5d2eb 100644 --- a/pkg/flags/global_builder.go +++ b/pkg/flags/global_builder.go @@ -100,6 +100,10 @@ func (b *GlobalOptionsBuilder) registerTerminalFlags(defaults *global.Flags) { b.WithForceTTY() b.WithMask() + // Interactive prompts configuration. + b.options = append(b.options, WithBoolFlag("interactive", "", defaults.Interactive, "Enable interactive prompts for missing required flags, optional value flags using the sentinel pattern, and missing positional arguments (requires TTY, disabled in CI)")) + b.options = append(b.options, WithEnvVars("interactive", "ATMOS_INTERACTIVE")) + // Output configuration - pager with NoOptDefVal. b.options = append(b.options, WithStringFlag("pager", "", defaults.Pager.Value(), "Enable pager for output (--pager or --pager=true to enable, --pager=false to disable, --pager=less to use specific pager)")) b.options = append(b.options, WithEnvVars("pager", "ATMOS_PAGER", "PAGER")) diff --git a/pkg/flags/interactive.go b/pkg/flags/interactive.go new file mode 100644 index 0000000000..141625084b --- /dev/null +++ b/pkg/flags/interactive.go @@ -0,0 +1,178 @@ +package flags + +import ( + "fmt" + + "github.com/charmbracelet/huh" + "github.com/spf13/cobra" + "github.com/spf13/viper" + + errUtils "github.com/cloudposse/atmos/errors" + "github.com/cloudposse/atmos/internal/tui/templates/term" + uiutils "github.com/cloudposse/atmos/internal/tui/utils" + cfg "github.com/cloudposse/atmos/pkg/config" + "github.com/cloudposse/atmos/pkg/perf" + "github.com/cloudposse/atmos/pkg/telemetry" +) + +// isInteractive checks if interactive prompts should be shown. +// Interactive mode requires: +// 1. --interactive flag is true (or ATMOS_INTERACTIVE env var). +// 2. Stdin is a TTY (for user input). +// 3. Not running in CI environment. +// +// This ensures prompts only appear in truly interactive contexts and gracefully +// degrade to standard errors in pipelines, scripts, and CI environments. +func isInteractive() bool { + defer perf.Track(nil, "flags.isInteractive")() + + // Check if interactive mode is enabled via flag or environment. + if !viper.GetBool("interactive") { + return false + } + + // Check if stdin is a TTY and not in CI. + return term.IsTTYSupportForStdin() && !telemetry.IsCI() +} + +// PromptForValue shows an interactive Huh selector with the given options. +// Returns the selected value or an error. +// +// This is the core prompting function used by all three use cases: +// 1. Missing required flags. +// 2. Optional value flags (sentinel pattern). +// 3. Missing required positional arguments. +func PromptForValue(name, title string, options []string) (string, error) { + defer perf.Track(nil, "flags.PromptForValue")() + + if !isInteractive() { + return "", errUtils.ErrInteractiveModeNotAvailable + } + + if len(options) == 0 { + return "", fmt.Errorf("%w: %s", errUtils.ErrNoOptionsAvailable, name) + } + + var choice string + + // Create Huh selector with Atmos theme. + // Limit height to 20 rows to prevent excessive scrolling and reduce terminal rendering artifacts. + // Note: Huh v0.8.0 has case-sensitive filtering (by design). + // Users can filter by typing "/" followed by search text, but it only matches exact case. + // Example: typing "dark" matches "neobones_dark" but not "Builtin Dark". + // TODO: Consider filing upstream feature request for case-insensitive filtering option. + selector := huh.NewSelect[string](). + Value(&choice). + Options(huh.NewOptions(options...)...). + Title(title). + Height(20). + WithTheme(uiutils.NewAtmosHuhTheme()) + + // Run selector. + if err := selector.Run(); err != nil { + return "", fmt.Errorf("prompt failed: %w", err) + } + + return choice, nil +} + +// PromptForMissingRequired prompts for a required flag that is missing. +// This is Use Case 1: Missing Required Flags. +// +// Example: +// +// $ atmos describe component vpc +// ? Choose a stack +// ue2-dev +// > ue2-prod +func PromptForMissingRequired(flagName, promptTitle string, completionFunc CompletionFunc, cmd *cobra.Command, args []string) (string, error) { + defer perf.Track(nil, "flags.PromptForMissingRequired")() + + if !isInteractive() { + return "", nil // Gracefully return empty - Cobra will handle the error. + } + + // Call completion function to get options. + options, _ := completionFunc(cmd, args, "") + if len(options) == 0 { + return "", nil // No options available, let Cobra handle the error. + } + + return PromptForValue(flagName, promptTitle, options) +} + +// OptionalValuePromptContext holds the context for prompting when a flag is used without a value. +type OptionalValuePromptContext struct { + FlagName string + FlagValue string + PromptTitle string + CompletionFunc CompletionFunc + Cmd *cobra.Command + Args []string +} + +// PromptForOptionalValue prompts for a flag that was used without a value. +// This is Use Case 2: Optional Value Flags (like the --identity pattern). +// +// The flag must have NoOptDefVal set to cfg.IdentityFlagSelectValue ("__SELECT__"). +// When user provides --flag without value, Cobra sets it to the sentinel value, +// and we detect this to show the prompt. +// +// Example: +// +// $ atmos list stacks --format +// ? Choose output format +// yaml +// > json +// table +func PromptForOptionalValue(ctx *OptionalValuePromptContext) (string, error) { + defer perf.Track(nil, "flags.PromptForOptionalValue")() + + if ctx == nil { + return "", fmt.Errorf("%w: optional value prompt context", errUtils.ErrNilInput) + } + + // Check if flag value matches the sentinel (indicating user wants interactive selection). + if ctx.FlagValue != cfg.IdentityFlagSelectValue { + return ctx.FlagValue, nil // Real value provided, no prompt needed. + } + + if !isInteractive() { + return "", nil // Gracefully return empty - command can use default. + } + + // Call completion function to get options. + options, _ := ctx.CompletionFunc(ctx.Cmd, ctx.Args, "") + if len(options) == 0 { + return "", nil // No options available, use default. + } + + return PromptForValue(ctx.FlagName, ctx.PromptTitle, options) +} + +// PromptForPositionalArg prompts for a required positional argument that is missing. +// This is Use Case 3: Missing Required Positional Arguments. +// +// Example: +// +// $ atmos theme show +// ? Choose a theme to preview +// Dracula +// Tokyo Night +// > Nord +func PromptForPositionalArg(argName, promptTitle string, completionFunc CompletionFunc, cmd *cobra.Command, currentArgs []string) (string, error) { + defer perf.Track(nil, "flags.PromptForPositionalArg")() + + if !isInteractive() { + return "", nil // Gracefully return empty - Cobra will handle the error. + } + + // Call completion function to get options. + // Pass current args in case completion is context-dependent (e.g., stack completion depends on component). + options, _ := completionFunc(cmd, currentArgs, "") + if len(options) == 0 { + return "", nil // No options available, let Cobra handle the error. + } + + return PromptForValue(argName, promptTitle, options) +} diff --git a/pkg/flags/interactive_test.go b/pkg/flags/interactive_test.go new file mode 100644 index 0000000000..fd51a3238e --- /dev/null +++ b/pkg/flags/interactive_test.go @@ -0,0 +1,755 @@ +//nolint:dupl // Test functions intentionally have similar structure +package flags + +import ( + "testing" + + "github.com/spf13/cobra" + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestIsInteractive tests the isInteractive function. +func TestIsInteractive(t *testing.T) { + // Save original viper state. + originalInteractive := viper.GetBool("interactive") + defer func() { + viper.Set("interactive", originalInteractive) + }() + + t.Run("returns false when interactive flag is disabled", func(t *testing.T) { + viper.Set("interactive", false) + result := isInteractive() + assert.False(t, result, "should return false when interactive flag is disabled") + }) + + t.Run("respects interactive flag setting", func(t *testing.T) { + viper.Set("interactive", true) + // Note: Actual result depends on TTY and CI environment. + // We just verify the function runs without error. + _ = isInteractive() + }) +} + +// TestPromptForValue tests the PromptForValue function. +func TestPromptForValue(t *testing.T) { + // Save original viper state. + originalInteractive := viper.GetBool("interactive") + defer func() { + viper.Set("interactive", originalInteractive) + }() + + t.Run("returns error when not interactive", func(t *testing.T) { + viper.Set("interactive", false) + _, err := PromptForValue("test-flag", "Choose option", []string{"option1", "option2"}) + assert.Error(t, err, "should return error when not interactive") + assert.Contains(t, err.Error(), "interactive mode not available") + }) + + t.Run("returns error when no options available", func(t *testing.T) { + viper.Set("interactive", false) // Ensure non-interactive for predictable test. + _, err := PromptForValue("test-flag", "Choose option", []string{}) + assert.Error(t, err, "should return error when no options available") + }) +} + +// TestPromptForMissingRequired tests the PromptForMissingRequired function. +func TestPromptForMissingRequired(t *testing.T) { + // Save original viper state. + originalInteractive := viper.GetBool("interactive") + defer func() { + viper.Set("interactive", originalInteractive) + }() + + cmd := &cobra.Command{Use: "test"} + args := []string{} + + completionFunc := func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return []string{"option1", "option2", "option3"}, cobra.ShellCompDirectiveNoFileComp + } + + t.Run("returns empty when not interactive", func(t *testing.T) { + viper.Set("interactive", false) + result, err := PromptForMissingRequired("test-flag", "Choose option", completionFunc, cmd, args) + assert.NoError(t, err, "should not return error when not interactive") + assert.Empty(t, result, "should return empty string when not interactive") + }) + + t.Run("returns empty when no options available", func(t *testing.T) { + viper.Set("interactive", false) + emptyCompletionFunc := func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return []string{}, cobra.ShellCompDirectiveNoFileComp + } + result, err := PromptForMissingRequired("test-flag", "Choose option", emptyCompletionFunc, cmd, args) + assert.NoError(t, err, "should not return error when no options") + assert.Empty(t, result, "should return empty string when no options") + }) +} + +// TestPromptForOptionalValue tests the PromptForOptionalValue function. +func TestPromptForOptionalValue(t *testing.T) { + // Save original viper state. + originalInteractive := viper.GetBool("interactive") + defer func() { + viper.Set("interactive", originalInteractive) + }() + + cmd := &cobra.Command{Use: "test"} + args := []string{} + + completionFunc := func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return []string{"option1", "option2"}, cobra.ShellCompDirectiveNoFileComp + } + + t.Run("returns value unchanged when not sentinel", func(t *testing.T) { + result, err := PromptForOptionalValue(&OptionalValuePromptContext{ + FlagName: "test-flag", + FlagValue: "real-value", + PromptTitle: "Choose option", + CompletionFunc: completionFunc, + Cmd: cmd, + Args: args, + }) + assert.NoError(t, err, "should not return error") + assert.Equal(t, "real-value", result, "should return unchanged value when not sentinel") + }) + + t.Run("returns empty when not interactive and value is sentinel", func(t *testing.T) { + viper.Set("interactive", false) + result, err := PromptForOptionalValue(&OptionalValuePromptContext{ + FlagName: "test-flag", + FlagValue: "__SELECT__", + PromptTitle: "Choose option", + CompletionFunc: completionFunc, + Cmd: cmd, + Args: args, + }) + assert.NoError(t, err, "should not return error when not interactive") + assert.Empty(t, result, "should return empty when not interactive") + }) + + t.Run("returns error when context is nil", func(t *testing.T) { + result, err := PromptForOptionalValue(nil) + assert.Error(t, err, "should return error when context is nil") + assert.Empty(t, result, "should return empty result when context is nil") + assert.Contains(t, err.Error(), "nil", "error should mention nil input") + }) +} + +// TestPromptForPositionalArg tests the PromptForPositionalArg function. +func TestPromptForPositionalArg(t *testing.T) { + // Save original viper state. + originalInteractive := viper.GetBool("interactive") + defer func() { + viper.Set("interactive", originalInteractive) + }() + + cmd := &cobra.Command{Use: "test"} + currentArgs := []string{} + + completionFunc := func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return []string{"arg1", "arg2", "arg3"}, cobra.ShellCompDirectiveNoFileComp + } + + t.Run("returns empty when not interactive", func(t *testing.T) { + viper.Set("interactive", false) + result, err := PromptForPositionalArg("test-arg", "Choose argument", completionFunc, cmd, currentArgs) + assert.NoError(t, err, "should not return error when not interactive") + assert.Empty(t, result, "should return empty string when not interactive") + }) + + t.Run("returns empty when no options available", func(t *testing.T) { + viper.Set("interactive", false) + emptyCompletionFunc := func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return []string{}, cobra.ShellCompDirectiveNoFileComp + } + result, err := PromptForPositionalArg("test-arg", "Choose argument", emptyCompletionFunc, cmd, currentArgs) + assert.NoError(t, err, "should not return error when no options") + assert.Empty(t, result, "should return empty string when no options") + }) +} + +// TestStandardFlagParser_PromptForOptionalValueFlags tests Use Case 2. +func TestStandardFlagParser_PromptForOptionalValueFlags(t *testing.T) { + // Save original viper state. + originalInteractive := viper.GetBool("interactive") + defer func() { + viper.Set("interactive", originalInteractive) + }() + + completionFunc := func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return []string{"identity1", "identity2"}, cobra.ShellCompDirectiveNoFileComp + } + + t.Run("skips prompt when flag value is not sentinel", func(t *testing.T) { + parser := NewStandardFlagParser( + WithOptionalValuePrompt("identity", "Choose identity", completionFunc), + ) + + result := &ParsedConfig{ + Flags: map[string]interface{}{"identity": "real-value"}, + PositionalArgs: []string{}, + } + + err := parser.promptForOptionalValueFlags(result, nil) + assert.NoError(t, err, "should not return error") + assert.Equal(t, "real-value", result.Flags["identity"], "should not change value when not sentinel") + }) + + t.Run("skips prompt when not interactive", func(t *testing.T) { + viper.Set("interactive", false) + + parser := NewStandardFlagParser( + WithOptionalValuePrompt("identity", "Choose identity", completionFunc), + ) + + result := &ParsedConfig{ + Flags: map[string]interface{}{"identity": "__SELECT__"}, + PositionalArgs: []string{}, + } + + err := parser.promptForOptionalValueFlags(result, nil) + assert.NoError(t, err, "should not return error when not interactive") + assert.Equal(t, "__SELECT__", result.Flags["identity"], "should keep sentinel when not interactive") + }) +} + +// TestStandardFlagParser_PromptForMissingRequiredFlags tests Use Case 1. +func TestStandardFlagParser_PromptForMissingRequiredFlags(t *testing.T) { + // Save original viper state. + originalInteractive := viper.GetBool("interactive") + defer func() { + viper.Set("interactive", originalInteractive) + }() + + completionFunc := func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return []string{"stack1", "stack2"}, cobra.ShellCompDirectiveNoFileComp + } + + t.Run("skips prompt when flag has value", func(t *testing.T) { + parser := NewStandardFlagParser( + WithCompletionPrompt("stack", "Choose stack", completionFunc), + ) + + result := &ParsedConfig{ + Flags: map[string]interface{}{"stack": "prod"}, + PositionalArgs: []string{}, + } + + err := parser.promptForMissingRequiredFlags(result, nil) + assert.NoError(t, err, "should not return error") + assert.Equal(t, "prod", result.Flags["stack"], "should not change value when already set") + }) + + t.Run("skips prompt when not interactive", func(t *testing.T) { + viper.Set("interactive", false) + + parser := NewStandardFlagParser( + WithCompletionPrompt("stack", "Choose stack", completionFunc), + ) + + result := &ParsedConfig{ + Flags: map[string]interface{}{"stack": ""}, + PositionalArgs: []string{}, + } + + err := parser.promptForMissingRequiredFlags(result, nil) + assert.NoError(t, err, "should not return error when not interactive") + assert.Equal(t, "", result.Flags["stack"], "should keep empty value when not interactive") + }) +} + +// TestStandardFlagParser_PromptForMissingPositionalArgs tests Use Case 3. +func TestStandardFlagParser_PromptForMissingPositionalArgs(t *testing.T) { + // Save original viper state. + originalInteractive := viper.GetBool("interactive") + defer func() { + viper.Set("interactive", originalInteractive) + }() + + completionFunc := func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return []string{"theme1", "theme2"}, cobra.ShellCompDirectiveNoFileComp + } + + t.Run("skips prompt when argument is provided", func(t *testing.T) { + builder := NewPositionalArgsBuilder() + builder.AddArg(&PositionalArgSpec{ + Name: "theme-name", + Description: "Theme name", + Required: true, + CompletionFunc: completionFunc, + PromptTitle: "Choose theme", + }) + specs, validator, usage := builder.Build() + + parser := NewStandardFlagParser( + WithPositionalArgPrompt("theme-name", "Choose theme", completionFunc), + ) + parser.SetPositionalArgs(specs, validator, usage) + + result := &ParsedConfig{ + Flags: map[string]interface{}{}, + PositionalArgs: []string{"dracula"}, // Argument already provided. + } + + err := parser.promptForMissingPositionalArgs(result) + assert.NoError(t, err, "should not return error") + assert.Equal(t, []string{"dracula"}, result.PositionalArgs, "should not change args when provided") + }) + + t.Run("skips prompt when not interactive", func(t *testing.T) { + viper.Set("interactive", false) + + builder := NewPositionalArgsBuilder() + builder.AddArg(&PositionalArgSpec{ + Name: "theme-name", + Description: "Theme name", + Required: true, + CompletionFunc: completionFunc, + PromptTitle: "Choose theme", + }) + specs, validator, usage := builder.Build() + + parser := NewStandardFlagParser( + WithPositionalArgPrompt("theme-name", "Choose theme", completionFunc), + ) + parser.SetPositionalArgs(specs, validator, usage) + + result := &ParsedConfig{ + Flags: map[string]interface{}{}, + PositionalArgs: []string{}, // No argument provided. + } + + err := parser.promptForMissingPositionalArgs(result) + assert.NoError(t, err, "should not return error when not interactive") + assert.Empty(t, result.PositionalArgs, "should keep empty args when not interactive") + }) +} + +// TestStandardFlagParser_HandleInteractivePrompts tests the overall prompting flow. +func TestStandardFlagParser_HandleInteractivePrompts(t *testing.T) { + // Save original viper state. + originalInteractive := viper.GetBool("interactive") + defer func() { + viper.Set("interactive", originalInteractive) + }() + + t.Run("executes all three use cases in correct order", func(t *testing.T) { + viper.Set("interactive", false) // Ensure non-interactive for predictable test. + + parser := NewStandardFlagParser() + + result := &ParsedConfig{ + Flags: map[string]interface{}{}, + PositionalArgs: []string{}, + } + + err := parser.handleInteractivePrompts(result, nil) + assert.NoError(t, err, "should execute all prompting use cases without error") + }) + + t.Run("handles nil combinedFlags gracefully", func(t *testing.T) { + parser := NewStandardFlagParser() + + result := &ParsedConfig{ + Flags: map[string]interface{}{}, + PositionalArgs: []string{}, + } + + err := parser.handleInteractivePrompts(result, nil) + assert.NoError(t, err, "should handle nil combinedFlags") + }) +} + +// TestWithCompletionPrompt tests the WithCompletionPrompt option. +func TestWithCompletionPrompt(t *testing.T) { + completionFunc := func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return []string{"option1", "option2"}, cobra.ShellCompDirectiveNoFileComp + } + + parser := NewStandardFlagParser( + WithCompletionPrompt("test-flag", "Choose option", completionFunc), + ) + + require.NotNil(t, parser.flagPrompts, "flagPrompts map should be initialized") + assert.Contains(t, parser.flagPrompts, "test-flag", "should contain prompt config for test-flag") + + config := parser.flagPrompts["test-flag"] + require.NotNil(t, config, "prompt config should not be nil") + assert.Equal(t, "Choose option", config.PromptTitle, "should set correct prompt title") + assert.NotNil(t, config.CompletionFunc, "should set completion function") +} + +// TestWithOptionalValuePrompt tests the WithOptionalValuePrompt option. +func TestWithOptionalValuePrompt(t *testing.T) { + completionFunc := func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return []string{"identity1", "identity2"}, cobra.ShellCompDirectiveNoFileComp + } + + parser := NewStandardFlagParser( + WithOptionalValuePrompt("identity", "Choose identity", completionFunc), + ) + + require.NotNil(t, parser.optionalValuePrompts, "optionalValuePrompts map should be initialized") + assert.Contains(t, parser.optionalValuePrompts, "identity", "should contain prompt config for identity") + + config := parser.optionalValuePrompts["identity"] + require.NotNil(t, config, "prompt config should not be nil") + assert.Equal(t, "Choose identity", config.PromptTitle, "should set correct prompt title") + assert.NotNil(t, config.CompletionFunc, "should set completion function") +} + +// TestWithPositionalArgPrompt tests the WithPositionalArgPrompt option. +func TestWithPositionalArgPrompt(t *testing.T) { + completionFunc := func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return []string{"arg1", "arg2"}, cobra.ShellCompDirectiveNoFileComp + } + + parser := NewStandardFlagParser( + WithPositionalArgPrompt("test-arg", "Choose argument", completionFunc), + ) + + require.NotNil(t, parser.positionalPrompts, "positionalPrompts map should be initialized") + assert.Contains(t, parser.positionalPrompts, "test-arg", "should contain prompt config for test-arg") + + config := parser.positionalPrompts["test-arg"] + require.NotNil(t, config, "prompt config should not be nil") + assert.Equal(t, "Choose argument", config.PromptTitle, "should set correct prompt title") + assert.NotNil(t, config.CompletionFunc, "should set completion function") +} + +// TestPromptForValue_EmptyOptions tests PromptForValue with empty options while interactive mode attempts to activate. +func TestPromptForValue_EmptyOptions(t *testing.T) { + // Save original viper state. + originalInteractive := viper.GetBool("interactive") + defer func() { + viper.Set("interactive", originalInteractive) + }() + + t.Run("returns error with flag name when options are empty and not interactive", func(t *testing.T) { + viper.Set("interactive", false) + _, err := PromptForValue("my-flag", "Choose option", []string{}) + assert.Error(t, err, "should return error when no options") + // Error should be ErrInteractiveModeNotAvailable since that check comes first. + assert.Contains(t, err.Error(), "interactive mode not available") + }) +} + +// TestPromptForMissingRequired_CompletionFuncCalled verifies completion function is invoked. +func TestPromptForMissingRequired_CompletionFuncCalled(t *testing.T) { + // Save original viper state. + originalInteractive := viper.GetBool("interactive") + defer func() { + viper.Set("interactive", originalInteractive) + }() + + cmd := &cobra.Command{Use: "test"} + args := []string{"arg1", "arg2"} + + t.Run("completion function receives correct arguments", func(t *testing.T) { + viper.Set("interactive", false) + + var receivedCmd *cobra.Command + var receivedArgs []string + var receivedToComplete string + + completionFunc := func(c *cobra.Command, a []string, tc string) ([]string, cobra.ShellCompDirective) { + receivedCmd = c + receivedArgs = a + receivedToComplete = tc + return []string{"option1"}, cobra.ShellCompDirectiveNoFileComp + } + + // Even though we're not interactive, we can verify the function would be called + // by checking that non-interactive mode returns empty without calling completion. + result, err := PromptForMissingRequired("test-flag", "Choose", completionFunc, cmd, args) + assert.NoError(t, err) + assert.Empty(t, result) + // In non-interactive mode, completion func is NOT called (short-circuits first). + assert.Nil(t, receivedCmd, "completion func should not be called in non-interactive mode") + assert.Nil(t, receivedArgs, "completion func should not be called in non-interactive mode") + assert.Empty(t, receivedToComplete, "completion func should not be called in non-interactive mode") + }) +} + +// TestPromptForOptionalValue_NonSentinelValues tests various non-sentinel values. +func TestPromptForOptionalValue_NonSentinelValues(t *testing.T) { + // Save original viper state. + originalInteractive := viper.GetBool("interactive") + defer func() { + viper.Set("interactive", originalInteractive) + }() + + cmd := &cobra.Command{Use: "test"} + args := []string{} + + completionFunc := func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return []string{"option1", "option2"}, cobra.ShellCompDirectiveNoFileComp + } + + testCases := []struct { + name string + value string + expected string + }{ + {"empty string is not sentinel", "", ""}, + {"regular value unchanged", "my-identity", "my-identity"}, + {"value with spaces unchanged", "my identity name", "my identity name"}, + {"numeric value unchanged", "12345", "12345"}, + {"partial sentinel unchanged", "__SELECT", "__SELECT"}, + {"sentinel suffix unchanged", "SELECT__", "SELECT__"}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result, err := PromptForOptionalValue(&OptionalValuePromptContext{ + FlagName: "identity", + FlagValue: tc.value, + PromptTitle: "Choose", + CompletionFunc: completionFunc, + Cmd: cmd, + Args: args, + }) + assert.NoError(t, err) + assert.Equal(t, tc.expected, result) + }) + } +} + +// TestPromptForOptionalValue_SentinelWithEmptyCompletions tests sentinel with no options. +func TestPromptForOptionalValue_SentinelWithEmptyCompletions(t *testing.T) { + // Save original viper state. + originalInteractive := viper.GetBool("interactive") + defer func() { + viper.Set("interactive", originalInteractive) + }() + + cmd := &cobra.Command{Use: "test"} + args := []string{} + + emptyCompletionFunc := func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return []string{}, cobra.ShellCompDirectiveNoFileComp + } + + t.Run("sentinel with empty completions returns empty when not interactive", func(t *testing.T) { + viper.Set("interactive", false) + result, err := PromptForOptionalValue(&OptionalValuePromptContext{ + FlagName: "identity", + FlagValue: "__SELECT__", + PromptTitle: "Choose", + CompletionFunc: emptyCompletionFunc, + Cmd: cmd, + Args: args, + }) + assert.NoError(t, err) + assert.Empty(t, result, "should return empty when not interactive") + }) +} + +// TestPromptForPositionalArg_WithCurrentArgs tests that current args are passed correctly. +func TestPromptForPositionalArg_WithCurrentArgs(t *testing.T) { + // Save original viper state. + originalInteractive := viper.GetBool("interactive") + defer func() { + viper.Set("interactive", originalInteractive) + }() + + cmd := &cobra.Command{Use: "test"} + + t.Run("current args are not passed to completion in non-interactive mode", func(t *testing.T) { + viper.Set("interactive", false) + + var receivedArgs []string + completionFunc := func(c *cobra.Command, a []string, tc string) ([]string, cobra.ShellCompDirective) { + receivedArgs = a + return []string{"arg1"}, cobra.ShellCompDirectiveNoFileComp + } + + currentArgs := []string{"component-name", "stack-name"} + result, err := PromptForPositionalArg("theme", "Choose theme", completionFunc, cmd, currentArgs) + assert.NoError(t, err) + assert.Empty(t, result) + // In non-interactive mode, completion func is NOT called. + assert.Nil(t, receivedArgs, "completion func should not be called in non-interactive mode") + }) +} + +// TestStandardFlagParser_PromptForMissingRequiredFlags_FlagNotInMap tests missing flag in flags map. +func TestStandardFlagParser_PromptForMissingRequiredFlags_FlagNotInMap(t *testing.T) { + // Save original viper state. + originalInteractive := viper.GetBool("interactive") + defer func() { + viper.Set("interactive", originalInteractive) + }() + + completionFunc := func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return []string{"stack1", "stack2"}, cobra.ShellCompDirectiveNoFileComp + } + + t.Run("handles flag not present in flags map", func(t *testing.T) { + viper.Set("interactive", false) + + parser := NewStandardFlagParser( + WithCompletionPrompt("stack", "Choose stack", completionFunc), + ) + + result := &ParsedConfig{ + Flags: map[string]interface{}{}, // stack not in map + PositionalArgs: []string{}, + } + + err := parser.promptForMissingRequiredFlags(result, nil) + assert.NoError(t, err, "should not return error when flag not in map") + }) + + t.Run("handles nil flag value in flags map", func(t *testing.T) { + viper.Set("interactive", false) + + parser := NewStandardFlagParser( + WithCompletionPrompt("stack", "Choose stack", completionFunc), + ) + + result := &ParsedConfig{ + Flags: map[string]interface{}{"stack": nil}, // nil value + PositionalArgs: []string{}, + } + + err := parser.promptForMissingRequiredFlags(result, nil) + assert.NoError(t, err, "should not return error when flag value is nil") + }) +} + +// TestStandardFlagParser_PromptForOptionalValueFlags_FlagNotInMap tests missing flag scenarios. +func TestStandardFlagParser_PromptForOptionalValueFlags_FlagNotInMap(t *testing.T) { + // Save original viper state. + originalInteractive := viper.GetBool("interactive") + defer func() { + viper.Set("interactive", originalInteractive) + }() + + completionFunc := func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return []string{"identity1", "identity2"}, cobra.ShellCompDirectiveNoFileComp + } + + t.Run("handles flag not present in flags map", func(t *testing.T) { + viper.Set("interactive", false) + + parser := NewStandardFlagParser( + WithOptionalValuePrompt("identity", "Choose identity", completionFunc), + ) + + result := &ParsedConfig{ + Flags: map[string]interface{}{}, // identity not in map + PositionalArgs: []string{}, + } + + err := parser.promptForOptionalValueFlags(result, nil) + assert.NoError(t, err, "should not return error when flag not in map") + }) + + t.Run("handles non-string flag value", func(t *testing.T) { + viper.Set("interactive", false) + + parser := NewStandardFlagParser( + WithOptionalValuePrompt("identity", "Choose identity", completionFunc), + ) + + result := &ParsedConfig{ + Flags: map[string]interface{}{"identity": 123}, // non-string value + PositionalArgs: []string{}, + } + + err := parser.promptForOptionalValueFlags(result, nil) + assert.NoError(t, err, "should not return error when flag value is not a string") + assert.Equal(t, 123, result.Flags["identity"], "should not change non-string value") + }) +} + +// TestStandardFlagParser_PromptForMissingPositionalArgs_MultipleArgs tests multiple positional args. +func TestStandardFlagParser_PromptForMissingPositionalArgs_MultipleArgs(t *testing.T) { + // Save original viper state. + originalInteractive := viper.GetBool("interactive") + defer func() { + viper.Set("interactive", originalInteractive) + }() + + completionFunc := func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return []string{"option1", "option2"}, cobra.ShellCompDirectiveNoFileComp + } + + t.Run("handles multiple positional args with some provided", func(t *testing.T) { + viper.Set("interactive", false) + + builder := NewPositionalArgsBuilder() + builder.AddArg(&PositionalArgSpec{ + Name: "component", + Description: "Component name", + Required: true, + CompletionFunc: completionFunc, + PromptTitle: "Choose component", + }) + builder.AddArg(&PositionalArgSpec{ + Name: "stack", + Description: "Stack name", + Required: true, + CompletionFunc: completionFunc, + PromptTitle: "Choose stack", + }) + specs, validator, usage := builder.Build() + + parser := NewStandardFlagParser( + WithPositionalArgPrompt("component", "Choose component", completionFunc), + WithPositionalArgPrompt("stack", "Choose stack", completionFunc), + ) + parser.SetPositionalArgs(specs, validator, usage) + + result := &ParsedConfig{ + Flags: map[string]interface{}{}, + PositionalArgs: []string{"vpc"}, // Only first arg provided. + } + + err := parser.promptForMissingPositionalArgs(result) + assert.NoError(t, err, "should not return error") + // In non-interactive mode, missing second arg is not prompted for. + assert.Equal(t, []string{"vpc"}, result.PositionalArgs, "should keep provided args unchanged") + }) + + t.Run("handles no positional args specs", func(t *testing.T) { + viper.Set("interactive", false) + + parser := NewStandardFlagParser() + // No positional args specs set. + + result := &ParsedConfig{ + Flags: map[string]interface{}{}, + PositionalArgs: []string{}, + } + + err := parser.promptForMissingPositionalArgs(result) + assert.NoError(t, err, "should not return error when no specs") + }) +} + +// TestIsInteractive_TTYAndCIBehavior tests TTY and CI detection behavior. +func TestIsInteractive_TTYAndCIBehavior(t *testing.T) { + // Save original viper state. + originalInteractive := viper.GetBool("interactive") + defer func() { + viper.Set("interactive", originalInteractive) + }() + + t.Run("returns false when interactive is disabled regardless of TTY", func(t *testing.T) { + viper.Set("interactive", false) + result := isInteractive() + assert.False(t, result, "should return false when interactive flag is disabled") + }) + + t.Run("returns value based on TTY and CI when interactive is enabled", func(t *testing.T) { + viper.Set("interactive", true) + // In test environment, this typically returns false (no TTY or CI detected). + // We just verify the function executes without panic. + _ = isInteractive() + // Result depends on actual environment; just verify no panic. + }) +} diff --git a/pkg/flags/options.go b/pkg/flags/options.go index 29e7171424..059bf4189e 100644 --- a/pkg/flags/options.go +++ b/pkg/flags/options.go @@ -1,6 +1,7 @@ package flags import ( + cfg "github.com/cloudposse/atmos/pkg/config" "github.com/cloudposse/atmos/pkg/perf" ) @@ -21,6 +22,17 @@ type Option func(*parserConfig) type parserConfig struct { registry *FlagRegistry viperPrefix string // Prefix for Viper keys (optional) + + // Interactive prompt configuration. + flagPrompts map[string]*flagPromptConfig // Flag name -> prompt config for required flags + optionalValuePrompts map[string]*flagPromptConfig // Flag name -> prompt config for optional value flags + positionalPrompts map[string]*flagPromptConfig // Arg name -> prompt config for positional args +} + +// flagPromptConfig holds the configuration for an interactive prompt. +type flagPromptConfig struct { + PromptTitle string // Title for the interactive selector + CompletionFunc CompletionFunc // Function to get available options } // WithStringFlag adds a string flag to the parser configuration. @@ -310,3 +322,96 @@ func WithRegistry(registry *FlagRegistry) Option { cfg.registry = registry } } + +// WithCompletionPrompt enables interactive prompts for a required flag when the flag is missing. +// This is Use Case 1: Missing Required Flags. +// +// When the flag is required but not provided, and the terminal is interactive, +// the user will be shown a selector with options from the completion function. +// +// Example: +// +// WithRequiredStringFlag("stack", "s", "Stack name"), +// WithCompletionPrompt("stack", "Choose a stack", stackFlagCompletion), +func WithCompletionPrompt(flagName, promptTitle string, completionFunc CompletionFunc) Option { + defer perf.Track(nil, "flags.WithCompletionPrompt")() + + return func(cfg *parserConfig) { + if cfg.flagPrompts == nil { + cfg.flagPrompts = make(map[string]*flagPromptConfig) + } + cfg.flagPrompts[flagName] = &flagPromptConfig{ + PromptTitle: promptTitle, + CompletionFunc: completionFunc, + } + } +} + +// WithOptionalValuePrompt enables interactive prompts for a flag when used without a value. +// This is Use Case 2: Optional Value Flags (like --identity pattern). +// +// The flag's NoOptDefVal will be set to cfg.IdentityFlagSelectValue ("__SELECT__"). +// When the user provides --flag without a value, Cobra sets it to the sentinel value, +// and we detect this to show the interactive prompt. +// +// Example: +// +// WithStringFlag("format", "", "yaml", "Output format"), +// WithOptionalValuePrompt("format", "Choose output format", formatCompletionFunc), +// +// Result: +// - `--format` → shows interactive selector +// - `--format=json` → uses "json" (no prompt) +// - no flag → uses default "yaml" (no prompt) +func WithOptionalValuePrompt(flagName, promptTitle string, completionFunc CompletionFunc) Option { + defer perf.Track(nil, "flags.WithOptionalValuePrompt")() + + return func(c *parserConfig) { + // Set NoOptDefVal to sentinel value. + flag := c.registry.Get(flagName) + if strFlag, ok := flag.(*StringFlag); ok { + strFlag.NoOptDefVal = cfg.IdentityFlagSelectValue + } + + // Store prompt config. + if c.optionalValuePrompts == nil { + c.optionalValuePrompts = make(map[string]*flagPromptConfig) + } + c.optionalValuePrompts[flagName] = &flagPromptConfig{ + PromptTitle: promptTitle, + CompletionFunc: completionFunc, + } + } +} + +// WithPositionalArgPrompt enables interactive prompts for a positional argument when missing. +// This is Use Case 3: Missing Required Positional Arguments. +// +// When the positional argument is required but not provided, and the terminal is interactive, +// the user will be shown a selector with options from the completion function. +// +// Note: This requires the positional argument to be configured via PositionalArgSpec +// with CompletionFunc and PromptTitle fields set. +// +// Example: +// +// argsBuilder := flags.NewPositionalArgsBuilder() +// argsBuilder.AddArg(&flags.PositionalArgSpec{ +// Name: "theme-name", +// Required: true, +// CompletionFunc: themeNameCompletion, +// PromptTitle: "Choose a theme to preview", +// }) +func WithPositionalArgPrompt(argName, promptTitle string, completionFunc CompletionFunc) Option { + defer perf.Track(nil, "flags.WithPositionalArgPrompt")() + + return func(cfg *parserConfig) { + if cfg.positionalPrompts == nil { + cfg.positionalPrompts = make(map[string]*flagPromptConfig) + } + cfg.positionalPrompts[argName] = &flagPromptConfig{ + PromptTitle: promptTitle, + CompletionFunc: completionFunc, + } + } +} diff --git a/pkg/flags/options_test.go b/pkg/flags/options_test.go index 0a2d056e1b..eb6d291ddf 100644 --- a/pkg/flags/options_test.go +++ b/pkg/flags/options_test.go @@ -166,3 +166,180 @@ func TestWithRegistry(t *testing.T) { assert.Equal(t, customRegistry, cfg.registry) assert.NotNil(t, cfg.registry.Get("custom")) } + +func TestWithStringSliceFlag(t *testing.T) { + cfg := &parserConfig{registry: NewFlagRegistry()} + + opt := WithStringSliceFlag("components", "c", []string{"vpc", "eks"}, "Filter by components") + opt(cfg) + + flag := cfg.registry.Get("components") + assert.NotNil(t, flag) + + sliceFlag, ok := flag.(*StringSliceFlag) + assert.True(t, ok) + assert.Equal(t, "components", sliceFlag.Name) + assert.Equal(t, "c", sliceFlag.Shorthand) + assert.Equal(t, []string{"vpc", "eks"}, sliceFlag.Default) + assert.Equal(t, "Filter by components", sliceFlag.Description) +} + +func TestWithRequiredStringFlag(t *testing.T) { + cfg := &parserConfig{registry: NewFlagRegistry()} + + opt := WithRequiredStringFlag("stack", "s", "Stack name (required)") + opt(cfg) + + flag := cfg.registry.Get("stack") + assert.NotNil(t, flag) + + strFlag, ok := flag.(*StringFlag) + assert.True(t, ok) + assert.Equal(t, "stack", strFlag.Name) + assert.Equal(t, "s", strFlag.Shorthand) + assert.Equal(t, "", strFlag.Default, "required flags should have empty default") + assert.True(t, strFlag.Required, "flag should be marked as required") +} + +func TestWithIdentityFlag(t *testing.T) { + cfg := &parserConfig{registry: NewFlagRegistry()} + + opt := WithIdentityFlag() + opt(cfg) + + flag := cfg.registry.Get("identity") + // The identity flag is registered in GlobalFlagsRegistry. + // If it exists there, it should be added to this registry. + if GlobalFlagsRegistry().Get("identity") != nil { + assert.NotNil(t, flag, "identity flag should be registered when available in global registry") + } +} + +func TestWithCommonFlags(t *testing.T) { + cfg := &parserConfig{registry: NewFlagRegistry()} + + opt := WithCommonFlags() + opt(cfg) + + // Should have common flags like stack and dry-run. + assert.NotNil(t, cfg.registry.Get("stack"), "should have stack flag") + assert.NotNil(t, cfg.registry.Get("dry-run"), "should have dry-run flag") + + // Verify no duplicate registration (identity may be in both global and common). + count := cfg.registry.Count() + assert.Greater(t, count, 0, "should have registered flags") +} + +func TestWithViperPrefix(t *testing.T) { + cfg := &parserConfig{registry: NewFlagRegistry()} + + opt := WithViperPrefix("terraform") + opt(cfg) + + assert.Equal(t, "terraform", cfg.viperPrefix, "should set viper prefix") +} + +func TestWithValidValues(t *testing.T) { + cfg := &parserConfig{registry: NewFlagRegistry()} + + // First add a string flag. + WithStringFlag("format", "f", "yaml", "Output format")(cfg) + + // Then set valid values. + opt := WithValidValues("format", "json", "yaml", "table") + opt(cfg) + + flag := cfg.registry.Get("format") + assert.NotNil(t, flag) + + strFlag, ok := flag.(*StringFlag) + assert.True(t, ok) + assert.Equal(t, []string{"json", "yaml", "table"}, strFlag.ValidValues) +} + +func TestWithValidValues_NonExistentFlag(t *testing.T) { + cfg := &parserConfig{registry: NewFlagRegistry()} + + // Try to set valid values for a flag that doesn't exist. + opt := WithValidValues("nonexistent", "value1", "value2") + opt(cfg) + + // Should not panic and flag should not exist. + assert.Nil(t, cfg.registry.Get("nonexistent")) +} + +func TestWithNoOptDefVal_NonExistentFlag(t *testing.T) { + cfg := &parserConfig{registry: NewFlagRegistry()} + + // Try to set NoOptDefVal for a flag that doesn't exist. + opt := WithNoOptDefVal("nonexistent", "__SELECT__") + opt(cfg) + + // Should not panic and flag should not exist. + assert.Nil(t, cfg.registry.Get("nonexistent")) +} + +func TestWithEnvVars_BoolFlag(t *testing.T) { + cfg := &parserConfig{registry: NewFlagRegistry()} + + // First add a bool flag. + WithBoolFlag("verbose", "v", false, "Verbose output")(cfg) + + // Then add env vars. + opt := WithEnvVars("verbose", "ATMOS_VERBOSE", "VERBOSE") + opt(cfg) + + flag := cfg.registry.Get("verbose") + assert.NotNil(t, flag) + + boolFlag, ok := flag.(*BoolFlag) + assert.True(t, ok) + assert.Equal(t, []string{"ATMOS_VERBOSE", "VERBOSE"}, boolFlag.EnvVars) +} + +func TestWithEnvVars_IntFlag(t *testing.T) { + cfg := &parserConfig{registry: NewFlagRegistry()} + + // First add an int flag. + WithIntFlag("timeout", "t", 30, "Timeout in seconds")(cfg) + + // Then add env vars. + opt := WithEnvVars("timeout", "ATMOS_TIMEOUT", "TIMEOUT") + opt(cfg) + + flag := cfg.registry.Get("timeout") + assert.NotNil(t, flag) + + intFlag, ok := flag.(*IntFlag) + assert.True(t, ok) + assert.Equal(t, []string{"ATMOS_TIMEOUT", "TIMEOUT"}, intFlag.EnvVars) +} + +func TestWithEnvVars_StringSliceFlag(t *testing.T) { + cfg := &parserConfig{registry: NewFlagRegistry()} + + // First add a string slice flag. + WithStringSliceFlag("components", "", []string{}, "Components list")(cfg) + + // Then add env vars. + opt := WithEnvVars("components", "ATMOS_COMPONENTS") + opt(cfg) + + flag := cfg.registry.Get("components") + assert.NotNil(t, flag) + + sliceFlag, ok := flag.(*StringSliceFlag) + assert.True(t, ok) + assert.Equal(t, []string{"ATMOS_COMPONENTS"}, sliceFlag.EnvVars) +} + +func TestWithEnvVars_NonExistentFlag(t *testing.T) { + cfg := &parserConfig{registry: NewFlagRegistry()} + + // Try to add env vars to a flag that doesn't exist. + opt := WithEnvVars("nonexistent", "ATMOS_NONEXISTENT") + opt(cfg) + + // Should not panic and flag should not exist. + assert.Nil(t, cfg.registry.Get("nonexistent")) +} diff --git a/pkg/flags/positional_args_builder.go b/pkg/flags/positional_args_builder.go index 04642360df..cbe7db7037 100644 --- a/pkg/flags/positional_args_builder.go +++ b/pkg/flags/positional_args_builder.go @@ -14,16 +14,20 @@ import ( // Example: // // spec := &PositionalArgSpec{ -// Name: "component", -// Description: "Component name", -// Required: true, -// TargetField: "Component", // Field name in options struct (e.g., TerraformOptions.Component) +// Name: "component", +// Description: "Component name", +// Required: true, +// TargetField: "Component", // Field name in options struct (e.g., TerraformOptions.Component) +// CompletionFunc: ComponentsArgCompletion, +// PromptTitle: "Choose a component", // } type PositionalArgSpec struct { - Name string // Argument name (e.g., "component", "workflow") - Description string // Human-readable description for usage/help - Required bool // Whether this argument is required - TargetField string // Name of field in Options struct to populate (e.g., "Component") + Name string // Argument name (e.g., "component", "workflow") + Description string // Human-readable description for usage/help + Required bool // Whether this argument is required + TargetField string // Name of field in Options struct to populate (e.g., "Component") + CompletionFunc CompletionFunc // Optional: Function to provide completion values for interactive prompts + PromptTitle string // Optional: Title for interactive prompt (e.g., "Choose a component") } // PositionalArgsBuilder provides low-level builder pattern for positional arguments. @@ -168,3 +172,57 @@ func (b *PositionalArgsBuilder) generateValidator() cobra.PositionalArgs { // Mixed required/optional - use range validator return cobra.RangeArgs(requiredCount, totalCount) } + +// GeneratePromptAwareValidator creates a prompt-aware Cobra PositionalArgs validator. +// This validator allows missing required args when interactive prompts are configured, +// enabling the Parse() method to show prompts instead of failing validation. +// +// Logic: +// - If interactive mode available AND prompts configured: allow 0 to totalCount args +// - Otherwise: use standard validator (enforces required args immediately) +// +// This solves the timing issue where Cobra's Args validation happens BEFORE RunE, +// preventing Parse() from ever being called to show prompts. +// +// Parameters: +// - hasPrompts: Whether interactive prompts are configured for positional args +// +// Returns: +// - Prompt-aware validator that allows missing args when prompts will handle them +func (b *PositionalArgsBuilder) GeneratePromptAwareValidator(hasPrompts bool) cobra.PositionalArgs { + defer perf.Track(nil, "flags.PositionalArgsBuilder.GeneratePromptAwareValidator")() + + // Get the standard validator + standardValidator := b.generateValidator() + + // If no prompts configured, use standard validator + if !hasPrompts { + return standardValidator + } + + // If prompts configured, create a validator that allows missing args + // when interactive mode is available + totalCount := len(b.specs) + + return func(cmd *cobra.Command, args []string) error { + // Check if interactive mode is available. + // Note: We can't call isInteractive() here because viper may not be initialized yet. + // Instead, we allow missing args and let Parse() handle the actual prompting. + // If prompts fail (not interactive), Parse() will return appropriate error. + + // Allow 0 to totalCount args when prompts are configured. + if len(args) > totalCount { + //nolint:err113 // Dynamic error needed for Cobra validation message + return fmt.Errorf("accepts at most %d arg(s), received %d", totalCount, len(args)) + } + + // If all args provided, still validate with standard validator + // to catch any other validation issues + if len(args) == totalCount { + return standardValidator(cmd, args) + } + + // Allow missing args - prompts will handle them in Parse() + return nil + } +} diff --git a/pkg/flags/standard.go b/pkg/flags/standard.go index 4a0b73b6bd..84b3830557 100644 --- a/pkg/flags/standard.go +++ b/pkg/flags/standard.go @@ -3,6 +3,7 @@ package flags import ( "context" "fmt" + "sort" "strings" "github.com/spf13/cobra" @@ -35,14 +36,17 @@ import ( // parser.RegisterFlags(cmd) // parser.BindToViper(viper.GetViper()) type StandardFlagParser struct { - registry *FlagRegistry - cmd *cobra.Command // Command for manual flag parsing - viper *viper.Viper // Viper instance for precedence handling - viperPrefix string - validValues map[string][]string // Valid values for flags (flag name -> valid values) - validationMsgs map[string]string // Custom validation error messages (flag name -> message) - parsedFlags *pflag.FlagSet // Combined FlagSet used in last Parse() call (for Changed checks) - positionalArgs *positionalArgsConfig // Positional argument configuration + registry *FlagRegistry + cmd *cobra.Command // Command for manual flag parsing + viper *viper.Viper // Viper instance for precedence handling + viperPrefix string + validValues map[string][]string // Valid values for flags (flag name -> valid values) + validationMsgs map[string]string // Custom validation error messages (flag name -> message) + parsedFlags *pflag.FlagSet // Combined FlagSet used in last Parse() call (for Changed checks) + positionalArgs *positionalArgsConfig // Positional argument configuration + flagPrompts map[string]*flagPromptConfig // Prompt configs for missing required flags (Use Case 1) + optionalValuePrompts map[string]*flagPromptConfig // Prompt configs for optional value flags (Use Case 2) + positionalPrompts map[string]*flagPromptConfig // Prompt configs for missing positional args (Use Case 3) } // NewStandardFlagParser creates a new StandardFlagParser with the given options. @@ -58,7 +62,10 @@ func NewStandardFlagParser(opts ...Option) *StandardFlagParser { defer perf.Track(nil, "flags.NewStandardFlagParser")() config := &parserConfig{ - registry: NewFlagRegistry(), + registry: NewFlagRegistry(), + flagPrompts: make(map[string]*flagPromptConfig), + optionalValuePrompts: make(map[string]*flagPromptConfig), + positionalPrompts: make(map[string]*flagPromptConfig), } // Apply options @@ -67,10 +74,13 @@ func NewStandardFlagParser(opts ...Option) *StandardFlagParser { } return &StandardFlagParser{ - registry: config.registry, - viperPrefix: config.viperPrefix, - validValues: make(map[string][]string), - validationMsgs: make(map[string]string), + registry: config.registry, + viperPrefix: config.viperPrefix, + validValues: make(map[string][]string), + validationMsgs: make(map[string]string), + flagPrompts: config.flagPrompts, + optionalValuePrompts: config.optionalValuePrompts, + positionalPrompts: config.positionalPrompts, } } @@ -110,6 +120,9 @@ func (p *StandardFlagParser) ParsedFlags() *pflag.FlagSet { // For commands that need to pass unknown flags to external tools (terraform, helmfile, packer), // those commands should set DisableFlagParsing=true manually in their command definition. // This is a temporary measure until the compatibility flags system is fully integrated. +// +// If positional args with prompts are configured, this sets a prompt-aware Args validator +// that allows missing required args when interactive mode is available. func (p *StandardFlagParser) RegisterFlags(cmd *cobra.Command) { defer perf.Track(nil, "flags.StandardFlagParser.RegisterFlags")() @@ -137,6 +150,43 @@ func (p *StandardFlagParser) RegisterFlags(cmd *cobra.Command) { // Auto-register completion functions for flags with valid values. p.registerCompletions(cmd) + + // If positional args with prompts are configured, set prompt-aware validator + p.registerPositionalArgsValidator(cmd) +} + +// registerPositionalArgsValidator sets a prompt-aware Args validator on the command +// if positional args are configured and prompts exist. +// +// This allows missing required positional args when interactive prompts will handle them, +// solving the timing issue where Cobra's Args validation happens before Parse() can prompt. +func (p *StandardFlagParser) registerPositionalArgsValidator(cmd *cobra.Command) { + defer perf.Track(nil, "flags.StandardFlagParser.registerPositionalArgsValidator")() + + // Only set validator if positional args are configured + if p.positionalArgs == nil || len(p.positionalArgs.specs) == 0 { + return + } + + // Check if any prompts are configured for positional args. + hasPrompts := false + for _, spec := range p.positionalArgs.specs { + if _, exists := p.positionalPrompts[spec.Name]; exists { + hasPrompts = true + break + } + } + + // Only set prompt-aware validator when prompts are configured. + // This avoids overriding any pre-existing cmd.Args validator when not needed. + if hasPrompts { + builder := NewPositionalArgsBuilder() + for _, spec := range p.positionalArgs.specs { + builder.AddArg(spec) + } + validator := builder.GeneratePromptAwareValidator(true) + cmd.Args = validator + } } // GetActualArgs extracts the actual arguments when DisableFlagParsing=true. @@ -499,12 +549,15 @@ func (p *StandardFlagParser) bindChangedFlagsToViper(combinedFlags *pflag.FlagSe } // validatePositionalArgs validates positional args using the configured validator. +// This is called after interactive prompts have had a chance to fill in missing values. +// Wraps validator errors with ErrInvalidPositionalArgs for consistent error handling. func (p *StandardFlagParser) validatePositionalArgs(positionalArgs []string) error { defer perf.Track(nil, "flags.StandardFlagParser.validatePositionalArgs")() if p.positionalArgs != nil && p.positionalArgs.validator != nil { if err := p.positionalArgs.validator(p.cmd, positionalArgs); err != nil { - return err + // Wrap both errors for consistent error handling - allows errors.Is() to match either. + return fmt.Errorf("%w: %w", errUtils.ErrInvalidPositionalArgs, err) } } return nil @@ -574,15 +627,21 @@ func (p *StandardFlagParser) Parse(ctx context.Context, args []string) (*ParsedC return nil, err } - // Step 2: Validate positional args if configured. - if err := p.validatePositionalArgs(result.PositionalArgs); err != nil { + // Step 2: Populate Flags map from Viper with precedence applied. + p.populateFlagsFromViper(result, combinedFlags) + + // Step 3: Handle interactive prompts (all 3 use cases). + // This must happen before positional arg validation because prompts may fill in missing args. + if err := p.handleInteractivePrompts(result, combinedFlags); err != nil { return nil, err } - // Step 3: Populate Flags map from Viper with precedence applied. - p.populateFlagsFromViper(result, combinedFlags) + // Step 4: Validate positional args (after prompts have filled in missing values). + if err := p.validatePositionalArgs(result.PositionalArgs); err != nil { + return nil, err + } - // Step 4: Validate flag values against valid values constraints. + // Step 5: Validate flag values against valid values constraints. if combinedFlags != nil { if err := p.validateFlagValues(result.Flags, combinedFlags); err != nil { return nil, err @@ -729,6 +788,206 @@ func (p *StandardFlagParser) GetIdentityFromCmd(cmd *cobra.Command, v *viper.Vip return v.GetString(viperKey), nil } +// handleInteractivePrompts handles all 3 interactive prompt use cases: +// 1. Missing required flags +// 2. Optional value flags (sentinel pattern) +// 3. Missing required positional arguments. +func (p *StandardFlagParser) handleInteractivePrompts(result *ParsedConfig, combinedFlags *pflag.FlagSet) error { + defer perf.Track(nil, "flags.StandardFlagParser.handleInteractivePrompts")() + + // Use Case 2: Handle optional value flags (--flag without value triggers prompt). + if err := p.promptForOptionalValueFlags(result, combinedFlags); err != nil { + return err + } + + // Use Case 1: Handle missing required flags. + if err := p.promptForMissingRequiredFlags(result, combinedFlags); err != nil { + return err + } + + // Use Case 3: Handle missing required positional arguments. + if err := p.promptForMissingPositionalArgs(result); err != nil { + return err + } + + return nil +} + +// promptForOptionalValueFlags handles Use Case 2: Optional value flags. +// When a flag has NoOptDefVal=cfg.IdentityFlagSelectValue and the user provides the flag +// without a value, prompt for selection. +func (p *StandardFlagParser) promptForOptionalValueFlags(result *ParsedConfig, combinedFlags *pflag.FlagSet) error { + defer perf.Track(nil, "flags.StandardFlagParser.promptForOptionalValueFlags")() + + if combinedFlags == nil || len(p.optionalValuePrompts) == 0 { + return nil + } + + // Sort flag names for deterministic prompt order. + flagNames := make([]string, 0, len(p.optionalValuePrompts)) + for flagName := range p.optionalValuePrompts { + flagNames = append(flagNames, flagName) + } + sort.Strings(flagNames) + + for _, flagName := range flagNames { + promptConfig := p.optionalValuePrompts[flagName] + // Get current flag value. + flagValue, ok := result.Flags[flagName].(string) + if !ok { + continue + } + + // Check if flag value is the sentinel (user wants interactive selection). + if flagValue != cfg.IdentityFlagSelectValue { + continue + } + + // Prompt for value. + selectedValue, err := PromptForOptionalValue(&OptionalValuePromptContext{ + FlagName: flagName, + FlagValue: flagValue, + PromptTitle: promptConfig.PromptTitle, + CompletionFunc: promptConfig.CompletionFunc, + Cmd: p.cmd, + Args: result.PositionalArgs, + }) + if err != nil { + // Prompt failed (user aborted, error occurred, etc.) - return the error. + return err + } + + if selectedValue == "" { + // Not interactive or no options available - fall back to default. + if f := combinedFlags.Lookup(flagName); f != nil { + result.Flags[flagName] = f.DefValue + } else { + result.Flags[flagName] = "" + } + continue + } + + // Update flag value with selection. + result.Flags[flagName] = selectedValue + } + + return nil +} + +// promptForMissingRequiredFlags handles Use Case 1: Missing required flags. +// If a required flag is not set and has a prompt config, show interactive prompt. +func (p *StandardFlagParser) promptForMissingRequiredFlags(result *ParsedConfig, combinedFlags *pflag.FlagSet) error { + defer perf.Track(nil, "flags.StandardFlagParser.promptForMissingRequiredFlags")() + + if combinedFlags == nil || len(p.flagPrompts) == 0 { + return nil + } + + // Sort flag names for deterministic prompt order. + flagNames := make([]string, 0, len(p.flagPrompts)) + for flagName := range p.flagPrompts { + flagNames = append(flagNames, flagName) + } + sort.Strings(flagNames) + + for _, flagName := range flagNames { + if err := p.promptForSingleMissingFlag(flagName, result, combinedFlags); err != nil { + return err + } + } + + return nil +} + +// promptForSingleMissingFlag prompts for a single missing required flag if needed. +func (p *StandardFlagParser) promptForSingleMissingFlag(flagName string, result *ParsedConfig, combinedFlags *pflag.FlagSet) error { + defer perf.Track(nil, "flags.StandardFlagParser.promptForSingleMissingFlag")() + + promptConfig := p.flagPrompts[flagName] + + // Check if flag is missing (empty or not set). + flagValue, ok := result.Flags[flagName].(string) + if ok && flagValue != "" { + return nil // Flag has value, no prompt needed. + } + + // Check if flag was explicitly set to empty (user intentionally passed empty value). + cobraFlag := combinedFlags.Lookup(flagName) + if cobraFlag != nil && cobraFlag.Changed { + return nil // User explicitly set the value (even if empty), don't prompt. + } + + // Prompt for missing required flag. + selectedValue, err := PromptForMissingRequired( + flagName, + promptConfig.PromptTitle, + promptConfig.CompletionFunc, + p.cmd, + result.PositionalArgs, + ) + if err != nil { + return err + } + + if selectedValue != "" { + result.Flags[flagName] = selectedValue + } + + return nil +} + +// promptForMissingPositionalArgs handles Use Case 3: Missing required positional arguments. +// If a required positional arg is missing and has a prompt config, show interactive prompt. +func (p *StandardFlagParser) promptForMissingPositionalArgs(result *ParsedConfig) error { + defer perf.Track(nil, "flags.StandardFlagParser.promptForMissingPositionalArgs")() + + if p.positionalArgs == nil || len(p.positionalPrompts) == 0 { + return nil + } + + // Iterate through positional arg specs in order. + for i, spec := range p.positionalArgs.specs { + // Check if this positional arg is missing. + if i < len(result.PositionalArgs) { + continue // Argument already provided. + } + + // Check if this arg is required and has a prompt config. + if !spec.Required { + continue // Optional arg, no prompt needed. + } + + promptConfig, hasPrompt := p.positionalPrompts[spec.Name] + if !hasPrompt { + continue // No prompt configured for this arg. + } + + // Prompt for missing positional argument. + selectedValue, err := PromptForPositionalArg( + spec.Name, + promptConfig.PromptTitle, + promptConfig.CompletionFunc, + p.cmd, + result.PositionalArgs, + ) + if err != nil { + // Prompt failed (user aborted, error occurred, etc.) - return the error. + return err + } + + if selectedValue == "" { + // Not interactive or no options available - skip this arg. + // The command's validation will catch the missing required arg. + continue + } + + // Append the selected value to positional args. + result.PositionalArgs = append(result.PositionalArgs, selectedValue) + } + + return nil +} + // Reset clears any internal parser state to prevent pollution between test runs. // This resets the command's flag state and the parsedFlags FlagSet. func (p *StandardFlagParser) Reset() { diff --git a/pkg/flags/standard_test.go b/pkg/flags/standard_test.go index 1a6393d484..7ef8e5bded 100644 --- a/pkg/flags/standard_test.go +++ b/pkg/flags/standard_test.go @@ -9,6 +9,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + errUtils "github.com/cloudposse/atmos/errors" cfg "github.com/cloudposse/atmos/pkg/config" ) @@ -174,3 +175,1127 @@ func TestStandardFlagParser_RequiredFlags(t *testing.T) { assert.NotNil(t, componentFlag) // Cobra marks required flags internally, we just verify it's registered } + +// TestStandardFlagParser_ParsedFlags tests the ParsedFlags method. +func TestStandardFlagParser_ParsedFlags(t *testing.T) { + t.Run("returns nil before Parse is called", func(t *testing.T) { + parser := NewStandardFlagParser(WithCommonFlags()) + assert.Nil(t, parser.ParsedFlags(), "should return nil before Parse") + }) + + t.Run("returns combined flags after Parse is called", func(t *testing.T) { + parser := NewStandardFlagParser(WithCommonFlags()) + cmd := &cobra.Command{Use: "test"} + parser.RegisterFlags(cmd) + + ctx := context.Background() + _, err := parser.Parse(ctx, []string{"--stack", "dev"}) + require.NoError(t, err) + + parsedFlags := parser.ParsedFlags() + assert.NotNil(t, parsedFlags, "should return combined flags after Parse") + // The parsedFlags should contain the registered flags. + stackFlag := parsedFlags.Lookup("stack") + assert.NotNil(t, stackFlag, "should contain stack flag") + }) +} + +// TestGetActualArgs tests the GetActualArgs function. +func TestGetActualArgs(t *testing.T) { + t.Run("returns cmd.Flags().Args() when DisableFlagParsing is false", func(t *testing.T) { + cmd := &cobra.Command{Use: "test"} + cmd.DisableFlagParsing = false + // When DisableFlagParsing is false, cmd.Flags().Args() returns parsed positional args. + // For this test, we just verify the function handles this case. + args := GetActualArgs(cmd, []string{"atmos", "test", "arg1", "arg2"}) + // With no actual parsing, this returns empty. + assert.Empty(t, args, "should return empty when no args parsed") + }) + + t.Run("extracts args from osArgs when DisableFlagParsing is true", func(t *testing.T) { + // Create a proper command hierarchy to get CommandPath() = "test". + cmd := &cobra.Command{Use: "test"} + cmd.DisableFlagParsing = true + // Simulate command path "test" (depth 1). + osArgs := []string{"test", "arg1", "arg2"} + args := GetActualArgs(cmd, osArgs) + assert.Equal(t, []string{"arg1", "arg2"}, args, "should extract args after command path") + }) + + t.Run("handles nested command paths", func(t *testing.T) { + rootCmd := &cobra.Command{Use: "atmos"} + parentCmd := &cobra.Command{Use: "describe"} + childCmd := &cobra.Command{Use: "component"} + childCmd.DisableFlagParsing = true + + rootCmd.AddCommand(parentCmd) + parentCmd.AddCommand(childCmd) + + // Command path is "atmos describe component" (depth 3). + osArgs := []string{"atmos", "describe", "component", "vpc", "stack-name"} + args := GetActualArgs(childCmd, osArgs) + assert.Equal(t, []string{"vpc", "stack-name"}, args, "should extract args after nested command path") + }) + + t.Run("returns empty when osArgs shorter than command depth", func(t *testing.T) { + cmd := &cobra.Command{Use: "test"} + cmd.DisableFlagParsing = true + osArgs := []string{"atmos"} + args := GetActualArgs(cmd, osArgs) + assert.Empty(t, args, "should return empty when osArgs is shorter than command depth") + }) +} + +// TestValidateArgsOrNil tests the ValidateArgsOrNil function. +func TestValidateArgsOrNil(t *testing.T) { + t.Run("returns nil when cmd.Args is nil", func(t *testing.T) { + cmd := &cobra.Command{Use: "test"} + cmd.Args = nil + err := ValidateArgsOrNil(cmd, []string{"arg1", "arg2"}) + assert.NoError(t, err, "should return nil when no validator") + }) + + t.Run("returns nil when Args validator passes", func(t *testing.T) { + cmd := &cobra.Command{Use: "test"} + cmd.Args = cobra.ExactArgs(2) + err := ValidateArgsOrNil(cmd, []string{"arg1", "arg2"}) + assert.NoError(t, err, "should return nil when validation passes") + }) + + t.Run("returns error when Args validator fails", func(t *testing.T) { + cmd := &cobra.Command{Use: "test"} + cmd.Args = cobra.ExactArgs(2) + err := ValidateArgsOrNil(cmd, []string{"arg1"}) + assert.Error(t, err, "should return error when validation fails") + }) + + t.Run("handles MinimumNArgs validator", func(t *testing.T) { + cmd := &cobra.Command{Use: "test"} + cmd.Args = cobra.MinimumNArgs(1) + + // Should pass with 1+ args. + err := ValidateArgsOrNil(cmd, []string{"arg1"}) + assert.NoError(t, err, "should pass with minimum args") + + // Should fail with 0 args. + err = ValidateArgsOrNil(cmd, []string{}) + assert.Error(t, err, "should fail with less than minimum args") + }) +} + +// TestStandardFlagParser_SetPositionalArgs tests the SetPositionalArgs method. +func TestStandardFlagParser_SetPositionalArgs(t *testing.T) { + t.Run("sets positional args configuration", func(t *testing.T) { + parser := NewStandardFlagParser() + + specs := []*PositionalArgSpec{ + {Name: "component", Description: "Component name", Required: true}, + {Name: "stack", Description: "Stack name", Required: false}, + } + validator := cobra.MinimumNArgs(1) + usage := " [stack]" + + parser.SetPositionalArgs(specs, validator, usage) + + assert.NotNil(t, parser.positionalArgs, "should set positionalArgs") + assert.Equal(t, specs, parser.positionalArgs.specs, "should set specs") + assert.NotNil(t, parser.positionalArgs.validator, "should set validator") + assert.Equal(t, usage, parser.positionalArgs.usage, "should set usage") + }) +} + +// TestStandardFlagParser_RegisterPositionalArgsValidator tests positional args validator registration. +func TestStandardFlagParser_RegisterPositionalArgsValidator(t *testing.T) { + completionFunc := func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return []string{"option1", "option2"}, cobra.ShellCompDirectiveNoFileComp + } + + t.Run("skips when no positional args configured", func(t *testing.T) { + parser := NewStandardFlagParser() + cmd := &cobra.Command{Use: "test"} + + parser.RegisterFlags(cmd) + + // Args should remain nil when no positional args are configured. + assert.Nil(t, cmd.Args, "should not set Args when no positional args") + }) + + t.Run("sets prompt-aware validator when prompts configured", func(t *testing.T) { + builder := NewPositionalArgsBuilder() + builder.AddArg(&PositionalArgSpec{ + Name: "theme", + Description: "Theme name", + Required: true, + CompletionFunc: completionFunc, + PromptTitle: "Choose theme", + }) + specs, validator, usage := builder.Build() + + parser := NewStandardFlagParser( + WithPositionalArgPrompt("theme", "Choose theme", completionFunc), + ) + parser.SetPositionalArgs(specs, validator, usage) + + cmd := &cobra.Command{Use: "test"} + parser.RegisterFlags(cmd) + + // Args should be set to a prompt-aware validator. + assert.NotNil(t, cmd.Args, "should set Args validator") + }) + + t.Run("does not override cmd.Args when no prompts configured", func(t *testing.T) { + builder := NewPositionalArgsBuilder() + builder.AddArg(&PositionalArgSpec{ + Name: "component", + Description: "Component name", + Required: true, + // No CompletionFunc or PromptTitle. + }) + specs, validator, usage := builder.Build() + + parser := NewStandardFlagParser() + parser.SetPositionalArgs(specs, validator, usage) + + cmd := &cobra.Command{Use: "test"} + parser.RegisterFlags(cmd) + + // Args should NOT be set when no prompts are configured. + // This avoids overriding any pre-existing cmd.Args validator. + assert.Nil(t, cmd.Args, "should not set Args validator when no prompts configured") + }) + + t.Run("preserves existing cmd.Args when no prompts configured", func(t *testing.T) { + builder := NewPositionalArgsBuilder() + builder.AddArg(&PositionalArgSpec{ + Name: "component", + Description: "Component name", + Required: true, + }) + specs, validator, usage := builder.Build() + + parser := NewStandardFlagParser() + parser.SetPositionalArgs(specs, validator, usage) + + // Set a pre-existing validator. + existingValidator := cobra.ExactArgs(1) + cmd := &cobra.Command{Use: "test", Args: existingValidator} + parser.RegisterFlags(cmd) + + // Pre-existing validator should be preserved. + assert.NotNil(t, cmd.Args, "should preserve existing Args validator") + }) +} + +// TestStandardFlagParser_ValidateSingleFlag tests single flag validation. +func TestStandardFlagParser_ValidateSingleFlag(t *testing.T) { + tests := []struct { + name string + flagDefault string + flags map[string]interface{} + expectError bool + errorContains []string + }{ + { + name: "valid value", + flagDefault: "json", + flags: map[string]interface{}{"format": "json"}, + expectError: false, + }, + { + name: "invalid value", + flagDefault: "json", + flags: map[string]interface{}{"format": "xml"}, + expectError: true, + errorContains: []string{"xml", "format"}, + }, + { + name: "empty value", + flagDefault: "", + flags: map[string]interface{}{"format": ""}, + expectError: false, + }, + { + name: "flag not in result", + flagDefault: "json", + flags: map[string]interface{}{}, // format not present. + expectError: false, + }, + } + + validValues := []string{"json", "yaml", "table"} + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + parser := NewStandardFlagParser( + WithStringFlag("format", "f", tt.flagDefault, "Output format"), + WithValidValues("format", validValues...), + ) + + err := parser.validateSingleFlag("format", validValues, tt.flags, nil) + + if tt.expectError { + assert.Error(t, err) + for _, text := range tt.errorContains { + assert.Contains(t, err.Error(), text) + } + } else { + assert.NoError(t, err) + } + }) + } +} + +// TestStandardFlagParser_ParseWithPositionalArgs tests parsing with positional arguments. +func TestStandardFlagParser_ParseWithPositionalArgs(t *testing.T) { + t.Run("parses flags with command registered", func(t *testing.T) { + parser := NewStandardFlagParser( + WithStringFlag("format", "f", "json", "Output format"), + ) + cmd := &cobra.Command{Use: "test"} + parser.RegisterFlags(cmd) + v := viper.New() + err := parser.BindFlagsToViper(cmd, v) + require.NoError(t, err) + + ctx := context.Background() + result, err := parser.Parse(ctx, []string{"--format", "yaml"}) + + require.NoError(t, err) + assert.Equal(t, "yaml", result.Flags["format"]) + }) + + t.Run("handles empty args", func(t *testing.T) { + parser := NewStandardFlagParser( + WithStringFlag("format", "f", "json", "Output format"), + ) + + ctx := context.Background() + result, err := parser.Parse(ctx, []string{}) + + require.NoError(t, err) + assert.Empty(t, result.PositionalArgs) + assert.Empty(t, result.SeparatedArgs) + }) + + t.Run("uses default value when flag not provided", func(t *testing.T) { + parser := NewStandardFlagParser( + WithStringFlag("format", "f", "json", "Output format"), + ) + cmd := &cobra.Command{Use: "test"} + parser.RegisterFlags(cmd) + v := viper.New() + err := parser.BindFlagsToViper(cmd, v) + require.NoError(t, err) + + ctx := context.Background() + result, err := parser.Parse(ctx, []string{}) + + require.NoError(t, err) + assert.Equal(t, "json", result.Flags["format"]) + }) +} + +// TestStandardFlagParser_ValidatePositionalArgs tests positional arg validation. +// Note: Validation now runs AFTER prompts have filled in values (in Parse flow). +// This tests the validatePositionalArgs method directly, which always validates. +func TestStandardFlagParser_ValidatePositionalArgs(t *testing.T) { + t.Run("validates required args after prompts fill values", func(t *testing.T) { + builder := NewPositionalArgsBuilder() + builder.AddArg(&PositionalArgSpec{ + Name: "theme-name", + Description: "Theme name", + Required: true, + }) + specs, validator, usage := builder.Build() + + parser := NewStandardFlagParser() + parser.SetPositionalArgs(specs, validator, usage) + + // Simulates the case after prompts have filled in the value. + err := parser.validatePositionalArgs([]string{"selected-theme"}) + assert.NoError(t, err) + }) + + t.Run("errors when required arg missing", func(t *testing.T) { + builder := NewPositionalArgsBuilder() + builder.AddArg(&PositionalArgSpec{ + Name: "required-arg", + Description: "Required argument", + Required: true, + }) + specs, validator, usage := builder.Build() + + parser := NewStandardFlagParser() + parser.SetPositionalArgs(specs, validator, usage) + + // Should error because required arg is missing (prompts didn't fill it). + err := parser.validatePositionalArgs([]string{}) + assert.Error(t, err) + // Error should wrap ErrInvalidPositionalArgs for consistent error handling. + assert.ErrorIs(t, err, errUtils.ErrInvalidPositionalArgs) + }) + + t.Run("passes with no positional args configured", func(t *testing.T) { + parser := NewStandardFlagParser() + + // No positional args configured, should pass. + err := parser.validatePositionalArgs([]string{}) + assert.NoError(t, err) + }) +} + +// TestStandardFlagParser_GetViperKey tests the getViperKey method. +func TestStandardFlagParser_GetViperKey(t *testing.T) { + t.Run("returns flag name without prefix", func(t *testing.T) { + parser := NewStandardFlagParser() + key := parser.getViperKey("my-flag") + assert.Equal(t, "my-flag", key) + }) + + t.Run("returns prefixed key with prefix", func(t *testing.T) { + parser := NewStandardFlagParser(WithViperPrefix("myprefix")) + key := parser.getViperKey("my-flag") + assert.Equal(t, "myprefix.my-flag", key) + }) +} + +// TestStandardFlagParser_Reset tests the Reset method. +func TestStandardFlagParser_Reset(t *testing.T) { + t.Run("resets command flags to defaults", func(t *testing.T) { + parser := NewStandardFlagParser( + WithStringFlag("format", "f", "json", "Output format"), + ) + cmd := &cobra.Command{Use: "test"} + parser.RegisterFlags(cmd) + + // Set a flag value. + err := cmd.Flags().Set("format", "yaml") + require.NoError(t, err) + + // Verify it's set. + val, _ := cmd.Flags().GetString("format") + assert.Equal(t, "yaml", val) + + // Reset. + parser.Reset() + + // Verify it's reset. + val, _ = cmd.Flags().GetString("format") + assert.Equal(t, "json", val) + }) + + t.Run("handles nil command gracefully", func(t *testing.T) { + parser := NewStandardFlagParser() + // Should not panic. + parser.Reset() + }) +} + +// TestStandardFlagParser_IsFlagExplicitlyChanged tests the isFlagExplicitlyChanged method. +func TestStandardFlagParser_IsFlagExplicitlyChanged(t *testing.T) { + t.Run("returns true when combinedFlags is nil", func(t *testing.T) { + parser := NewStandardFlagParser() + result := parser.isFlagExplicitlyChanged("any-flag", nil) + assert.True(t, result) + }) + + t.Run("returns true when flag is changed", func(t *testing.T) { + parser := NewStandardFlagParser( + WithStringFlag("format", "f", "json", "Output format"), + ) + cmd := &cobra.Command{Use: "test"} + parser.RegisterFlags(cmd) + + // Parse with the flag set. + err := cmd.Flags().Set("format", "yaml") + require.NoError(t, err) + + result := parser.isFlagExplicitlyChanged("format", cmd.Flags()) + assert.True(t, result) + }) + + t.Run("returns false when flag is not changed", func(t *testing.T) { + parser := NewStandardFlagParser( + WithStringFlag("format", "f", "json", "Output format"), + ) + cmd := &cobra.Command{Use: "test"} + parser.RegisterFlags(cmd) + + // Don't set the flag. + result := parser.isFlagExplicitlyChanged("format", cmd.Flags()) + assert.False(t, result) + }) +} + +// TestStandardFlagParser_IsValueValid tests the isValueValid method. +func TestStandardFlagParser_IsValueValid(t *testing.T) { + parser := NewStandardFlagParser() + + tests := []struct { + name string + value string + validValues []string + expected bool + }{ + { + name: "value in list", + value: "json", + validValues: []string{"json", "yaml", "table"}, + expected: true, + }, + { + name: "value not in list", + value: "xml", + validValues: []string{"json", "yaml", "table"}, + expected: false, + }, + { + name: "empty list", + value: "json", + validValues: []string{}, + expected: false, + }, + { + name: "case sensitive match", + value: "JSON", + validValues: []string{"json", "yaml"}, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := parser.isValueValid(tt.value, tt.validValues) + assert.Equal(t, tt.expected, result) + }) + } +} + +// TestStandardFlagParser_CreateValidationError tests error message generation. +func TestStandardFlagParser_CreateValidationError(t *testing.T) { + t.Run("uses default message", func(t *testing.T) { + parser := NewStandardFlagParser( + WithStringFlag("format", "f", "json", "Output format"), + WithValidValues("format", "json", "yaml"), + ) + + err := parser.createValidationError("format", "xml", []string{"json", "yaml"}) + assert.Contains(t, err.Error(), "invalid value") + assert.Contains(t, err.Error(), "xml") + assert.Contains(t, err.Error(), "format") + assert.Contains(t, err.Error(), "json, yaml") + }) +} + +// TestStandardFlagParser_ValidateFlagValues tests flag value validation. +func TestStandardFlagParser_ValidateFlagValues(t *testing.T) { + t.Run("returns nil when no valid values configured", func(t *testing.T) { + parser := NewStandardFlagParser( + WithStringFlag("format", "f", "json", "Output format"), + ) + + flags := map[string]interface{}{"format": "any-value"} + err := parser.validateFlagValues(flags, nil) + assert.NoError(t, err) + }) + + t.Run("validates all flags with valid values", func(t *testing.T) { + parser := NewStandardFlagParser( + WithStringFlag("format", "f", "json", "Output format"), + WithValidValues("format", "json", "yaml"), + ) + cmd := &cobra.Command{Use: "test"} + parser.RegisterFlags(cmd) + + // Set the flag to an invalid value. + err := cmd.Flags().Set("format", "xml") + require.NoError(t, err) + + flags := map[string]interface{}{"format": "xml"} + err = parser.validateFlagValues(flags, cmd.Flags()) + assert.Error(t, err) + }) +} + +// TestStandardFlagParser_GetStringFlagValue tests the getStringFlagValue method. +func TestStandardFlagParser_GetStringFlagValue(t *testing.T) { + t.Run("returns viper value when not empty", func(t *testing.T) { + v := viper.New() + v.Set("format", "yaml") + + parser := NewStandardFlagParser( + WithStringFlag("format", "f", "json", "Output format"), + ) + parser.viper = v + + strFlag := &StringFlag{ + Name: "format", + Default: "json", + } + value := parser.getStringFlagValue(strFlag, "format", "format", nil) + assert.Equal(t, "yaml", value) + }) + + t.Run("returns default when viper empty and flag not changed", func(t *testing.T) { + v := viper.New() + + parser := NewStandardFlagParser( + WithStringFlag("format", "f", "json", "Output format"), + ) + parser.viper = v + cmd := &cobra.Command{Use: "test"} + parser.RegisterFlags(cmd) + + strFlag := &StringFlag{ + Name: "format", + Default: "json", + } + value := parser.getStringFlagValue(strFlag, "format", "format", cmd.Flags()) + assert.Equal(t, "json", value) + }) + + t.Run("returns default when combinedFlags is nil", func(t *testing.T) { + v := viper.New() + + parser := NewStandardFlagParser( + WithStringFlag("format", "f", "json", "Output format"), + ) + parser.viper = v + + strFlag := &StringFlag{ + Name: "format", + Default: "json", + } + value := parser.getStringFlagValue(strFlag, "format", "format", nil) + assert.Equal(t, "json", value) + }) +} + +// TestStandardFlagParser_RegisterCompletions tests completion registration. +// +//nolint:dupl // Test functions for RegisterFlags and RegisterPersistentFlags intentionally have similar structure. +func TestStandardFlagParser_RegisterCompletions(t *testing.T) { + t.Run("registers completions for flags with valid values", func(t *testing.T) { + parser := NewStandardFlagParser( + WithStringFlag("format", "f", "json", "Output format"), + WithValidValues("format", "json", "yaml", "table"), + ) + cmd := &cobra.Command{Use: "test"} + parser.RegisterFlags(cmd) + + // Verify flag exists. + formatFlag := cmd.Flags().Lookup("format") + assert.NotNil(t, formatFlag, "format flag should exist") + }) + + t.Run("skips registration when no valid values configured", func(t *testing.T) { + parser := NewStandardFlagParser( + WithStringFlag("format", "f", "json", "Output format"), + ) + cmd := &cobra.Command{Use: "test"} + parser.RegisterFlags(cmd) + + // Should not panic and flag should exist. + formatFlag := cmd.Flags().Lookup("format") + assert.NotNil(t, formatFlag) + }) + + t.Run("skips registration for nonexistent flags", func(t *testing.T) { + parser := NewStandardFlagParser( + WithValidValues("nonexistent", "value1", "value2"), + ) + cmd := &cobra.Command{Use: "test"} + parser.RegisterFlags(cmd) + + // Should not panic when flag doesn't exist. + nonexistentFlag := cmd.Flags().Lookup("nonexistent") + assert.Nil(t, nonexistentFlag) + }) +} + +// TestStandardFlagParser_RegisterPersistentCompletions tests persistent completion registration. +// +//nolint:dupl // Test functions for RegisterFlags and RegisterPersistentFlags intentionally have similar structure. +func TestStandardFlagParser_RegisterPersistentCompletions(t *testing.T) { + t.Run("registers completions for persistent flags with valid values", func(t *testing.T) { + parser := NewStandardFlagParser( + WithStringFlag("format", "f", "json", "Output format"), + WithValidValues("format", "json", "yaml", "table"), + ) + cmd := &cobra.Command{Use: "test"} + parser.RegisterPersistentFlags(cmd) + + // Verify persistent flag exists. + formatFlag := cmd.PersistentFlags().Lookup("format") + assert.NotNil(t, formatFlag, "format persistent flag should exist") + }) + + t.Run("skips registration when no valid values configured", func(t *testing.T) { + parser := NewStandardFlagParser( + WithStringFlag("format", "f", "json", "Output format"), + ) + cmd := &cobra.Command{Use: "test"} + parser.RegisterPersistentFlags(cmd) + + // Should not panic. + formatFlag := cmd.PersistentFlags().Lookup("format") + assert.NotNil(t, formatFlag) + }) + + t.Run("skips registration for nonexistent persistent flags", func(t *testing.T) { + parser := NewStandardFlagParser( + WithValidValues("nonexistent", "value1", "value2"), + ) + cmd := &cobra.Command{Use: "test"} + parser.RegisterPersistentFlags(cmd) + + // Should not panic. + nonexistentFlag := cmd.PersistentFlags().Lookup("nonexistent") + assert.Nil(t, nonexistentFlag) + }) +} + +// TestStandardFlagParser_ExtractArgs tests the extractArgs method. +func TestStandardFlagParser_ExtractArgs(t *testing.T) { + t.Run("extracts positional args without separator", func(t *testing.T) { + parser := NewStandardFlagParser() + cmd := &cobra.Command{Use: "test"} + parser.RegisterFlags(cmd) + + // Parse args without "--" separator. + ctx := context.Background() + result, err := parser.Parse(ctx, []string{"arg1", "arg2"}) + + require.NoError(t, err) + assert.Equal(t, []string{"arg1", "arg2"}, result.PositionalArgs) + assert.Empty(t, result.SeparatedArgs) + }) + + t.Run("extracts args with separator", func(t *testing.T) { + parser := NewStandardFlagParser() + cmd := &cobra.Command{Use: "test"} + parser.RegisterFlags(cmd) + + // Parse args with "--" separator. + ctx := context.Background() + result, err := parser.Parse(ctx, []string{"pos1", "--", "sep1", "sep2"}) + + require.NoError(t, err) + assert.Equal(t, []string{"pos1"}, result.PositionalArgs) + assert.Equal(t, []string{"sep1", "sep2"}, result.SeparatedArgs) + }) + + t.Run("handles separator at beginning", func(t *testing.T) { + parser := NewStandardFlagParser() + cmd := &cobra.Command{Use: "test"} + parser.RegisterFlags(cmd) + + ctx := context.Background() + result, err := parser.Parse(ctx, []string{"--", "sep1", "sep2"}) + + require.NoError(t, err) + assert.Empty(t, result.PositionalArgs) + assert.Equal(t, []string{"sep1", "sep2"}, result.SeparatedArgs) + }) + + t.Run("handles separator at end", func(t *testing.T) { + parser := NewStandardFlagParser() + cmd := &cobra.Command{Use: "test"} + parser.RegisterFlags(cmd) + + ctx := context.Background() + result, err := parser.Parse(ctx, []string{"pos1", "pos2", "--"}) + + require.NoError(t, err) + assert.Equal(t, []string{"pos1", "pos2"}, result.PositionalArgs) + assert.Empty(t, result.SeparatedArgs) + }) +} + +// TestStandardFlagParser_PromptForSingleMissingFlag tests the helper function. +func TestStandardFlagParser_PromptForSingleMissingFlag(t *testing.T) { + // Save original viper state. + originalInteractive := viper.GetBool("interactive") + defer func() { + viper.Set("interactive", originalInteractive) + }() + + completionFunc := func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return []string{"stack1", "stack2"}, cobra.ShellCompDirectiveNoFileComp + } + + t.Run("skips when flag has value", func(t *testing.T) { + viper.Set("interactive", false) + + parser := NewStandardFlagParser( + WithStringFlag("stack", "s", "", "Stack name"), + WithCompletionPrompt("stack", "Choose stack", completionFunc), + ) + cmd := &cobra.Command{Use: "test"} + parser.RegisterFlags(cmd) + + result := &ParsedConfig{ + Flags: map[string]interface{}{"stack": "prod"}, + PositionalArgs: []string{}, + } + + err := parser.promptForSingleMissingFlag("stack", result, cmd.Flags()) + assert.NoError(t, err) + assert.Equal(t, "prod", result.Flags["stack"]) + }) + + t.Run("skips when flag was explicitly changed", func(t *testing.T) { + viper.Set("interactive", false) + + parser := NewStandardFlagParser( + WithStringFlag("stack", "s", "", "Stack name"), + WithCompletionPrompt("stack", "Choose stack", completionFunc), + ) + cmd := &cobra.Command{Use: "test"} + parser.RegisterFlags(cmd) + + // Explicitly set the flag to empty. + err := cmd.Flags().Set("stack", "") + require.NoError(t, err) + + result := &ParsedConfig{ + Flags: map[string]interface{}{"stack": ""}, + PositionalArgs: []string{}, + } + + err = parser.promptForSingleMissingFlag("stack", result, cmd.Flags()) + assert.NoError(t, err) + // Value should remain empty since it was explicitly set. + assert.Equal(t, "", result.Flags["stack"]) + }) + + t.Run("skips when not interactive", func(t *testing.T) { + viper.Set("interactive", false) + + parser := NewStandardFlagParser( + WithStringFlag("stack", "s", "", "Stack name"), + WithCompletionPrompt("stack", "Choose stack", completionFunc), + ) + cmd := &cobra.Command{Use: "test"} + parser.RegisterFlags(cmd) + + result := &ParsedConfig{ + Flags: map[string]interface{}{"stack": ""}, + PositionalArgs: []string{}, + } + + err := parser.promptForSingleMissingFlag("stack", result, cmd.Flags()) + assert.NoError(t, err) + // Value should remain empty in non-interactive mode. + assert.Equal(t, "", result.Flags["stack"]) + }) +} + +// TestStandardFlagParser_PromptForOptionalValueFlags_FallbackToDefault tests fallback behavior. +func TestStandardFlagParser_PromptForOptionalValueFlags_FallbackToDefault(t *testing.T) { + // Save original viper state. + originalInteractive := viper.GetBool("interactive") + defer func() { + viper.Set("interactive", originalInteractive) + }() + + completionFunc := func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return []string{"identity1", "identity2"}, cobra.ShellCompDirectiveNoFileComp + } + + t.Run("falls back to default when not interactive", func(t *testing.T) { + viper.Set("interactive", false) + + parser := NewStandardFlagParser( + WithStringFlag("identity", "i", "default-identity", "Identity"), + WithOptionalValuePrompt("identity", "Choose identity", completionFunc), + ) + cmd := &cobra.Command{Use: "test"} + parser.RegisterFlags(cmd) + + result := &ParsedConfig{ + Flags: map[string]interface{}{"identity": "__SELECT__"}, + PositionalArgs: []string{}, + } + + err := parser.promptForOptionalValueFlags(result, cmd.Flags()) + assert.NoError(t, err) + // Should fall back to default value since not interactive. + assert.Equal(t, "default-identity", result.Flags["identity"]) + }) + + t.Run("falls back to empty when flag not in combinedFlags", func(t *testing.T) { + viper.Set("interactive", false) + + parser := NewStandardFlagParser( + WithOptionalValuePrompt("identity", "Choose identity", completionFunc), + ) + // Create a command with a different flag (not identity). + cmd := &cobra.Command{Use: "test"} + cmd.Flags().String("other-flag", "", "Other flag") + + result := &ParsedConfig{ + Flags: map[string]interface{}{"identity": "__SELECT__"}, + PositionalArgs: []string{}, + } + + err := parser.promptForOptionalValueFlags(result, cmd.Flags()) + assert.NoError(t, err) + // Should fall back to empty string when flag lookup fails (flag not in combinedFlags). + assert.Equal(t, "", result.Flags["identity"]) + }) + + t.Run("returns early when combinedFlags is nil", func(t *testing.T) { + viper.Set("interactive", false) + + parser := NewStandardFlagParser( + WithOptionalValuePrompt("identity", "Choose identity", completionFunc), + ) + + result := &ParsedConfig{ + Flags: map[string]interface{}{"identity": "__SELECT__"}, + PositionalArgs: []string{}, + } + + err := parser.promptForOptionalValueFlags(result, nil) + assert.NoError(t, err) + // When combinedFlags is nil, the function returns early without processing. + assert.Equal(t, "__SELECT__", result.Flags["identity"]) + }) +} + +// TestStandardFlagParser_RegisterIntFlag tests int flag registration. +func TestStandardFlagParser_RegisterIntFlag(t *testing.T) { + t.Run("registers int flag", func(t *testing.T) { + parser := NewStandardFlagParser( + WithIntFlag("count", "n", 10, "Count value"), + ) + cmd := &cobra.Command{Use: "test"} + parser.RegisterFlags(cmd) + + countFlag := cmd.Flags().Lookup("count") + assert.NotNil(t, countFlag) + assert.Equal(t, "n", countFlag.Shorthand) + assert.Equal(t, "10", countFlag.DefValue) + }) + + t.Run("registers required int flag", func(t *testing.T) { + // Create custom int flag with Required=true. + intFlag := &IntFlag{ + Name: "required-count", + Shorthand: "r", + Default: 0, + Description: "Required count", + Required: true, + } + + parser := NewStandardFlagParser() + parser.registry.Register(intFlag) + + cmd := &cobra.Command{Use: "test"} + parser.RegisterFlags(cmd) + + countFlag := cmd.Flags().Lookup("required-count") + assert.NotNil(t, countFlag) + }) +} + +// TestStandardFlagParser_RegisterStringSliceFlag tests string slice flag registration. +func TestStandardFlagParser_RegisterStringSliceFlag(t *testing.T) { + t.Run("registers string slice flag", func(t *testing.T) { + parser := NewStandardFlagParser( + WithStringSliceFlag("tags", "t", []string{"default"}, "Tag values"), + ) + cmd := &cobra.Command{Use: "test"} + parser.RegisterFlags(cmd) + + tagsFlag := cmd.Flags().Lookup("tags") + assert.NotNil(t, tagsFlag) + assert.Equal(t, "t", tagsFlag.Shorthand) + }) + + t.Run("registers required string slice flag", func(t *testing.T) { + // Create custom string slice flag with Required=true. + sliceFlag := &StringSliceFlag{ + Name: "required-tags", + Shorthand: "r", + Default: []string{}, + Description: "Required tags", + Required: true, + } + + parser := NewStandardFlagParser() + parser.registry.Register(sliceFlag) + + cmd := &cobra.Command{Use: "test"} + parser.RegisterFlags(cmd) + + tagsFlag := cmd.Flags().Lookup("required-tags") + assert.NotNil(t, tagsFlag) + }) +} + +// TestStandardFlagParser_HandleInteractivePrompts_AllCases tests all prompt use cases. +func TestStandardFlagParser_HandleInteractivePrompts_AllCases(t *testing.T) { + // Save original viper state. + originalInteractive := viper.GetBool("interactive") + defer func() { + viper.Set("interactive", originalInteractive) + }() + + completionFunc := func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return []string{"option1", "option2"}, cobra.ShellCompDirectiveNoFileComp + } + + t.Run("handles all three use cases together", func(t *testing.T) { + viper.Set("interactive", false) + + builder := NewPositionalArgsBuilder() + builder.AddArg(&PositionalArgSpec{ + Name: "theme", + Description: "Theme name", + Required: true, + CompletionFunc: completionFunc, + PromptTitle: "Choose theme", + }) + specs, validator, usage := builder.Build() + + parser := NewStandardFlagParser( + WithStringFlag("stack", "s", "", "Stack name"), + WithStringFlag("identity", "i", "default", "Identity"), + WithCompletionPrompt("stack", "Choose stack", completionFunc), + WithOptionalValuePrompt("identity", "Choose identity", completionFunc), + WithPositionalArgPrompt("theme", "Choose theme", completionFunc), + ) + parser.SetPositionalArgs(specs, validator, usage) + + cmd := &cobra.Command{Use: "test"} + parser.RegisterFlags(cmd) + + result := &ParsedConfig{ + Flags: map[string]interface{}{ + "stack": "", + "identity": "__SELECT__", + }, + PositionalArgs: []string{}, + } + + err := parser.handleInteractivePrompts(result, cmd.Flags()) + assert.NoError(t, err) + }) +} + +// TestStandardFlagParser_BindFlagsToViper_EdgeCases tests edge cases in binding. +func TestStandardFlagParser_BindFlagsToViper_EdgeCases(t *testing.T) { + t.Run("handles flag not found in cobra flags", func(t *testing.T) { + parser := NewStandardFlagParser( + WithStringFlag("format", "f", "json", "Output format"), + ) + // Create command but don't register flags to it. + cmd := &cobra.Command{Use: "test"} + v := viper.New() + + // Register flags first. + parser.RegisterFlags(cmd) + + // Now bind - this should work even though some internal flags might not exist. + err := parser.BindFlagsToViper(cmd, v) + assert.NoError(t, err) + }) +} + +// TestStringFlag_GetValidValues tests the GetValidValues method. +func TestStringFlag_GetValidValues(t *testing.T) { + t.Run("returns valid values when set", func(t *testing.T) { + flag := &StringFlag{ + Name: "format", + ValidValues: []string{"json", "yaml", "table"}, + } + values := flag.GetValidValues() + assert.Equal(t, []string{"json", "yaml", "table"}, values) + }) + + t.Run("returns nil when not set", func(t *testing.T) { + flag := &StringFlag{ + Name: "format", + } + values := flag.GetValidValues() + assert.Nil(t, values) + }) +} + +// TestStandardFlagParser_PromptForMissingRequiredFlags_MultipleFlagsOrder tests deterministic order. +func TestStandardFlagParser_PromptForMissingRequiredFlags_MultipleFlagsOrder(t *testing.T) { + // Save original viper state. + originalInteractive := viper.GetBool("interactive") + defer func() { + viper.Set("interactive", originalInteractive) + }() + + viper.Set("interactive", false) + + completionFunc := func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return []string{"option1", "option2"}, cobra.ShellCompDirectiveNoFileComp + } + + t.Run("processes flags in alphabetical order", func(t *testing.T) { + parser := NewStandardFlagParser( + WithCompletionPrompt("zebra", "Choose zebra", completionFunc), + WithCompletionPrompt("apple", "Choose apple", completionFunc), + WithCompletionPrompt("mango", "Choose mango", completionFunc), + ) + + result := &ParsedConfig{ + Flags: map[string]interface{}{"zebra": "", "apple": "", "mango": ""}, + PositionalArgs: []string{}, + } + + err := parser.promptForMissingRequiredFlags(result, nil) + assert.NoError(t, err) + // In non-interactive mode, nothing changes but order is deterministic. + }) +} + +// TestStandardFlagParser_PromptForOptionalValueFlags_MultipleFlagsOrder tests deterministic order. +func TestStandardFlagParser_PromptForOptionalValueFlags_MultipleFlagsOrder(t *testing.T) { + // Save original viper state. + originalInteractive := viper.GetBool("interactive") + defer func() { + viper.Set("interactive", originalInteractive) + }() + + viper.Set("interactive", false) + + completionFunc := func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return []string{"option1", "option2"}, cobra.ShellCompDirectiveNoFileComp + } + + t.Run("processes flags in alphabetical order", func(t *testing.T) { + parser := NewStandardFlagParser( + WithStringFlag("zebra", "", "z-default", "Zebra"), + WithStringFlag("apple", "", "a-default", "Apple"), + WithStringFlag("mango", "", "m-default", "Mango"), + WithOptionalValuePrompt("zebra", "Choose zebra", completionFunc), + WithOptionalValuePrompt("apple", "Choose apple", completionFunc), + WithOptionalValuePrompt("mango", "Choose mango", completionFunc), + ) + cmd := &cobra.Command{Use: "test"} + parser.RegisterFlags(cmd) + + result := &ParsedConfig{ + Flags: map[string]interface{}{ + "zebra": "__SELECT__", + "apple": "__SELECT__", + "mango": "__SELECT__", + }, + PositionalArgs: []string{}, + } + + err := parser.promptForOptionalValueFlags(result, cmd.Flags()) + assert.NoError(t, err) + // Should fall back to defaults in alphabetical order. + assert.Equal(t, "a-default", result.Flags["apple"]) + assert.Equal(t, "m-default", result.Flags["mango"]) + assert.Equal(t, "z-default", result.Flags["zebra"]) + }) +} diff --git a/pkg/flags/types.go b/pkg/flags/types.go index 3c5e7e54fc..2cd068bd97 100644 --- a/pkg/flags/types.go +++ b/pkg/flags/types.go @@ -6,6 +6,20 @@ import ( "github.com/cloudposse/atmos/pkg/perf" ) +// CompletionFunc is a type alias for Cobra's ValidArgsFunction. +// This function provides completion values for flags and positional arguments. +// It's used both for shell completion and for interactive prompts. +// +// Parameters: +// - cmd: The cobra command being completed. +// - args: Positional arguments provided so far. +// - toComplete: Partial string being completed. +// +// Returns: +// - []string: List of completion options. +// - cobra.ShellCompDirective: Directive for shell completion behavior. +type CompletionFunc func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) + // Flag represents a command-line flag configuration. // This is used by FlagRegistry to store reusable flag definitions. type Flag interface { diff --git a/pkg/ui/theme/scheme.go b/pkg/ui/theme/scheme.go index 921d041e00..6d38bceca8 100644 --- a/pkg/ui/theme/scheme.go +++ b/pkg/ui/theme/scheme.go @@ -40,6 +40,10 @@ type ColorScheme struct { // Help/Documentation specific BackgroundHighlight string // Background for highlighted sections (usage/example blocks) + // Interactive prompts (Huh library) + ButtonForeground string // Button text color (light/cream) + ButtonBackground string // Button background color (primary/purple) + // Syntax highlighting ChromaTheme string // Chroma theme name for syntax highlighting @@ -99,6 +103,10 @@ func GenerateColorScheme(t *Theme) ColorScheme { // Help/Documentation specific BackgroundHighlight: t.Black, // Dark background for code blocks + // Interactive prompts (Huh library) + ButtonForeground: t.BrightWhite, // Light cream text + ButtonBackground: t.BrightBlue, // Primary action color (purple/blue) + // Syntax highlighting - map themes to appropriate Chroma themes ChromaTheme: getChromaThemeForAtmosTheme(t), diff --git a/pkg/ui/theme/styles.go b/pkg/ui/theme/styles.go index a81daf9f8e..1f248a0a3d 100644 --- a/pkg/ui/theme/styles.go +++ b/pkg/ui/theme/styles.go @@ -70,6 +70,12 @@ type StyleSet struct { BorderUnfocused lipgloss.Style } + // Interactive prompt styles for the Huh library's buttons and UI elements. + Interactive struct { + ButtonForeground lipgloss.Style // Button text style + ButtonBackground lipgloss.Style // Button background style + } + // Diff/Output styles Diff struct { Added lipgloss.Style @@ -147,6 +153,9 @@ func GetStyles(scheme *ColorScheme) *StyleSet { // TUI component styles TUI: getTUIStyles(scheme), + // Interactive prompt styles. + Interactive: getInteractiveStyles(scheme), + // Diff/Output styles Diff: getDiffStyles(scheme), @@ -239,6 +248,20 @@ func getTUIStyles(scheme *ColorScheme) struct { } } +// getInteractiveStyles returns the interactive prompt styles for the given color scheme. +func getInteractiveStyles(scheme *ColorScheme) struct { + ButtonForeground lipgloss.Style + ButtonBackground lipgloss.Style +} { + return struct { + ButtonForeground lipgloss.Style + ButtonBackground lipgloss.Style + }{ + ButtonForeground: lipgloss.NewStyle().Foreground(lipgloss.Color(scheme.ButtonForeground)), + ButtonBackground: lipgloss.NewStyle().Background(lipgloss.Color(scheme.ButtonBackground)), + } +} + // getDiffStyles returns the diff/output styles for the given color scheme. func getDiffStyles(scheme *ColorScheme) struct { Added lipgloss.Style diff --git a/tests/snapshots/TestCLICommands_atmos_--help.stdout.golden b/tests/snapshots/TestCLICommands_atmos_--help.stdout.golden index 0d06506057..18080d10a5 100644 --- a/tests/snapshots/TestCLICommands_atmos_--help.stdout.golden +++ b/tests/snapshots/TestCLICommands_atmos_--help.stdout.golden @@ -67,6 +67,10 @@ FLAGS --identity string Identity to use for authentication. Use --identity to select interactively, -- identity=NAME to specify + --interactive Enable interactive prompts for missing required flags, optional value flags using the + sentinel pattern, and missing positional arguments (requires TTY, disabled in CI) + (default true) + --logs-file string The file to write Atmos logs to. Logs can be written to any file or any standard file descriptor, including '/dev/stdout', '/dev/stderr' and '/dev/null' (default /dev/stderr) diff --git a/tests/snapshots/TestCLICommands_atmos_--help_config_aliases_section.stdout.golden b/tests/snapshots/TestCLICommands_atmos_--help_config_aliases_section.stdout.golden index 3af3dce3b8..a63c63159d 100644 --- a/tests/snapshots/TestCLICommands_atmos_--help_config_aliases_section.stdout.golden +++ b/tests/snapshots/TestCLICommands_atmos_--help_config_aliases_section.stdout.golden @@ -73,6 +73,10 @@ FLAGS --identity string Identity to use for authentication. Use --identity to select interactively, -- identity=NAME to specify + --interactive Enable interactive prompts for missing required flags, optional value flags using the + sentinel pattern, and missing positional arguments (requires TTY, disabled in CI) + (default true) + --logs-file string The file to write Atmos logs to. Logs can be written to any file or any standard file descriptor, including '/dev/stdout', '/dev/stderr' and '/dev/null' (default /dev/stderr) diff --git a/tests/snapshots/TestCLICommands_atmos_about_--help.stdout.golden b/tests/snapshots/TestCLICommands_atmos_about_--help.stdout.golden index aa16699159..14588bd5e0 100644 --- a/tests/snapshots/TestCLICommands_atmos_about_--help.stdout.golden +++ b/tests/snapshots/TestCLICommands_atmos_about_--help.stdout.golden @@ -47,6 +47,10 @@ GLOBAL FLAGS --identity string Identity to use for authentication. Use --identity to select interactively, -- identity=NAME to specify + --interactive Enable interactive prompts for missing required flags, optional value flags using the + sentinel pattern, and missing positional arguments (requires TTY, disabled in CI) + (default true) + --logs-file string The file to write Atmos logs to. Logs can be written to any file or any standard file descriptor, including '/dev/stdout', '/dev/stderr' and '/dev/null' (default /dev/stderr) diff --git a/tests/snapshots/TestCLICommands_atmos_atlantis_--help.stdout.golden b/tests/snapshots/TestCLICommands_atmos_atlantis_--help.stdout.golden index 8751213e8b..be29a2cf1d 100644 --- a/tests/snapshots/TestCLICommands_atmos_atlantis_--help.stdout.golden +++ b/tests/snapshots/TestCLICommands_atmos_atlantis_--help.stdout.golden @@ -46,6 +46,10 @@ GLOBAL FLAGS --identity string Identity to use for authentication. Use --identity to select interactively, -- identity=NAME to specify + --interactive Enable interactive prompts for missing required flags, optional value flags using the + sentinel pattern, and missing positional arguments (requires TTY, disabled in CI) + (default true) + --logs-file string The file to write Atmos logs to. Logs can be written to any file or any standard file descriptor, including '/dev/stdout', '/dev/stderr' and '/dev/null' (default /dev/stderr) diff --git a/tests/snapshots/TestCLICommands_atmos_atlantis_generate_--help.stdout.golden b/tests/snapshots/TestCLICommands_atmos_atlantis_generate_--help.stdout.golden index 15b4841de2..4ea03da2e4 100644 --- a/tests/snapshots/TestCLICommands_atmos_atlantis_generate_--help.stdout.golden +++ b/tests/snapshots/TestCLICommands_atmos_atlantis_generate_--help.stdout.golden @@ -46,6 +46,10 @@ GLOBAL FLAGS --identity string Identity to use for authentication. Use --identity to select interactively, -- identity=NAME to specify + --interactive Enable interactive prompts for missing required flags, optional value flags using the + sentinel pattern, and missing positional arguments (requires TTY, disabled in CI) + (default true) + --logs-file string The file to write Atmos logs to. Logs can be written to any file or any standard file descriptor, including '/dev/stdout', '/dev/stderr' and '/dev/null' (default /dev/stderr) diff --git a/tests/snapshots/TestCLICommands_atmos_atlantis_generate_help.stdout.golden b/tests/snapshots/TestCLICommands_atmos_atlantis_generate_help.stdout.golden index 15b4841de2..4ea03da2e4 100644 --- a/tests/snapshots/TestCLICommands_atmos_atlantis_generate_help.stdout.golden +++ b/tests/snapshots/TestCLICommands_atmos_atlantis_generate_help.stdout.golden @@ -46,6 +46,10 @@ GLOBAL FLAGS --identity string Identity to use for authentication. Use --identity to select interactively, -- identity=NAME to specify + --interactive Enable interactive prompts for missing required flags, optional value flags using the + sentinel pattern, and missing positional arguments (requires TTY, disabled in CI) + (default true) + --logs-file string The file to write Atmos logs to. Logs can be written to any file or any standard file descriptor, including '/dev/stdout', '/dev/stderr' and '/dev/null' (default /dev/stderr) diff --git a/tests/snapshots/TestCLICommands_atmos_atlantis_generate_repo-config_--help.stdout.golden b/tests/snapshots/TestCLICommands_atmos_atlantis_generate_repo-config_--help.stdout.golden index a5fecfc90a..7945bd037a 100644 --- a/tests/snapshots/TestCLICommands_atmos_atlantis_generate_repo-config_--help.stdout.golden +++ b/tests/snapshots/TestCLICommands_atmos_atlantis_generate_repo-config_--help.stdout.golden @@ -144,6 +144,10 @@ GLOBAL FLAGS --identity string Identity to use for authentication. Use --identity to select interactively, -- identity=NAME to specify + --interactive Enable interactive prompts for missing required flags, optional value flags using the + sentinel pattern, and missing positional arguments (requires TTY, disabled in CI) + (default true) + --logs-file string The file to write Atmos logs to. Logs can be written to any file or any standard file descriptor, including '/dev/stdout', '/dev/stderr' and '/dev/null' (default /dev/stderr) diff --git a/tests/snapshots/TestCLICommands_atmos_atlantis_generate_repo-config_help.stdout.golden b/tests/snapshots/TestCLICommands_atmos_atlantis_generate_repo-config_help.stdout.golden index 7051bdc833..6537e60f63 100644 --- a/tests/snapshots/TestCLICommands_atmos_atlantis_generate_repo-config_help.stdout.golden +++ b/tests/snapshots/TestCLICommands_atmos_atlantis_generate_repo-config_help.stdout.golden @@ -144,6 +144,10 @@ GLOBAL FLAGS --identity string Identity to use for authentication. Use --identity to select interactively, -- identity=NAME to specify + --interactive Enable interactive prompts for missing required flags, optional value flags using the + sentinel pattern, and missing positional arguments (requires TTY, disabled in CI) + (default true) + --logs-file string The file to write Atmos logs to. Logs can be written to any file or any standard file descriptor, including '/dev/stdout', '/dev/stderr' and '/dev/null' (default /dev/stderr) diff --git a/tests/snapshots/TestCLICommands_atmos_atlantis_help.stdout.golden b/tests/snapshots/TestCLICommands_atmos_atlantis_help.stdout.golden index 8751213e8b..be29a2cf1d 100644 --- a/tests/snapshots/TestCLICommands_atmos_atlantis_help.stdout.golden +++ b/tests/snapshots/TestCLICommands_atmos_atlantis_help.stdout.golden @@ -46,6 +46,10 @@ GLOBAL FLAGS --identity string Identity to use for authentication. Use --identity to select interactively, -- identity=NAME to specify + --interactive Enable interactive prompts for missing required flags, optional value flags using the + sentinel pattern, and missing positional arguments (requires TTY, disabled in CI) + (default true) + --logs-file string The file to write Atmos logs to. Logs can be written to any file or any standard file descriptor, including '/dev/stdout', '/dev/stderr' and '/dev/null' (default /dev/stderr) diff --git a/tests/snapshots/TestCLICommands_atmos_auth_env_--help.stdout.golden b/tests/snapshots/TestCLICommands_atmos_auth_env_--help.stdout.golden index 0900f4cdc0..464a784829 100644 --- a/tests/snapshots/TestCLICommands_atmos_auth_env_--help.stdout.golden +++ b/tests/snapshots/TestCLICommands_atmos_auth_env_--help.stdout.golden @@ -42,6 +42,10 @@ GLOBAL FLAGS -i, --identity string Specify the target identity to assume. Use without value to interactively select. + --interactive Enable interactive prompts for missing required flags, optional value flags using the + sentinel pattern, and missing positional arguments (requires TTY, disabled in CI) + (default true) + --logs-file string The file to write Atmos logs to. Logs can be written to any file or any standard file descriptor, including '/dev/stdout', '/dev/stderr' and '/dev/null' (default /dev/stderr) diff --git a/tests/snapshots/TestCLICommands_atmos_auth_exec_--help.stdout.golden b/tests/snapshots/TestCLICommands_atmos_auth_exec_--help.stdout.golden index 663f475fcb..11cb3e71c0 100644 --- a/tests/snapshots/TestCLICommands_atmos_auth_exec_--help.stdout.golden +++ b/tests/snapshots/TestCLICommands_atmos_auth_exec_--help.stdout.golden @@ -45,6 +45,10 @@ GLOBAL FLAGS -i, --identity string Specify the target identity to assume. Use without value to interactively select. + --interactive Enable interactive prompts for missing required flags, optional value flags using the + sentinel pattern, and missing positional arguments (requires TTY, disabled in CI) + (default true) + --logs-file string The file to write Atmos logs to. Logs can be written to any file or any standard file descriptor, including '/dev/stdout', '/dev/stderr' and '/dev/null' (default /dev/stderr) diff --git a/tests/snapshots/TestCLICommands_atmos_auth_login_--help.stdout.golden b/tests/snapshots/TestCLICommands_atmos_auth_login_--help.stdout.golden index ce6bf9463a..aa3ca8d298 100644 --- a/tests/snapshots/TestCLICommands_atmos_auth_login_--help.stdout.golden +++ b/tests/snapshots/TestCLICommands_atmos_auth_login_--help.stdout.golden @@ -39,6 +39,10 @@ GLOBAL FLAGS -i, --identity string Specify the target identity to assume. Use without value to interactively select. + --interactive Enable interactive prompts for missing required flags, optional value flags using the + sentinel pattern, and missing positional arguments (requires TTY, disabled in CI) + (default true) + --logs-file string The file to write Atmos logs to. Logs can be written to any file or any standard file descriptor, including '/dev/stdout', '/dev/stderr' and '/dev/null' (default /dev/stderr) diff --git a/tests/snapshots/TestCLICommands_atmos_auth_user_configure_--help.stdout.golden b/tests/snapshots/TestCLICommands_atmos_auth_user_configure_--help.stdout.golden index 16b2577c38..c99638a835 100644 --- a/tests/snapshots/TestCLICommands_atmos_auth_user_configure_--help.stdout.golden +++ b/tests/snapshots/TestCLICommands_atmos_auth_user_configure_--help.stdout.golden @@ -37,6 +37,10 @@ GLOBAL FLAGS -i, --identity string Specify the target identity to assume. Use without value to interactively select. + --interactive Enable interactive prompts for missing required flags, optional value flags using the + sentinel pattern, and missing positional arguments (requires TTY, disabled in CI) + (default true) + --logs-file string The file to write Atmos logs to. Logs can be written to any file or any standard file descriptor, including '/dev/stdout', '/dev/stderr' and '/dev/null' (default /dev/stderr) diff --git a/tests/snapshots/TestCLICommands_atmos_auth_validate_--help.stdout.golden b/tests/snapshots/TestCLICommands_atmos_auth_validate_--help.stdout.golden index 210df2d3d1..cb55201e1a 100644 --- a/tests/snapshots/TestCLICommands_atmos_auth_validate_--help.stdout.golden +++ b/tests/snapshots/TestCLICommands_atmos_auth_validate_--help.stdout.golden @@ -40,6 +40,10 @@ GLOBAL FLAGS -i, --identity string Specify the target identity to assume. Use without value to interactively select. + --interactive Enable interactive prompts for missing required flags, optional value flags using the + sentinel pattern, and missing positional arguments (requires TTY, disabled in CI) + (default true) + --logs-file string The file to write Atmos logs to. Logs can be written to any file or any standard file descriptor, including '/dev/stdout', '/dev/stderr' and '/dev/null' (default /dev/stderr) diff --git a/tests/snapshots/TestCLICommands_atmos_auth_whoami_--help.stdout.golden b/tests/snapshots/TestCLICommands_atmos_auth_whoami_--help.stdout.golden index b75d5335db..39905e3421 100644 --- a/tests/snapshots/TestCLICommands_atmos_auth_whoami_--help.stdout.golden +++ b/tests/snapshots/TestCLICommands_atmos_auth_whoami_--help.stdout.golden @@ -39,6 +39,10 @@ GLOBAL FLAGS -i, --identity string Specify the target identity to assume. Use without value to interactively select. + --interactive Enable interactive prompts for missing required flags, optional value flags using the + sentinel pattern, and missing positional arguments (requires TTY, disabled in CI) + (default true) + --logs-file string The file to write Atmos logs to. Logs can be written to any file or any standard file descriptor, including '/dev/stdout', '/dev/stderr' and '/dev/null' (default /dev/stderr) diff --git a/tests/snapshots/TestCLICommands_atmos_helmfile_--help.stdout.golden b/tests/snapshots/TestCLICommands_atmos_helmfile_--help.stdout.golden index 8c7ce3ffe7..0331151f4a 100644 --- a/tests/snapshots/TestCLICommands_atmos_helmfile_--help.stdout.golden +++ b/tests/snapshots/TestCLICommands_atmos_helmfile_--help.stdout.golden @@ -58,6 +58,10 @@ GLOBAL FLAGS --identity string Identity to use for authentication. Use --identity to select interactively, -- identity=NAME to specify + --interactive Enable interactive prompts for missing required flags, optional value flags using the + sentinel pattern, and missing positional arguments (requires TTY, disabled in CI) + (default true) + --logs-file string The file to write Atmos logs to. Logs can be written to any file or any standard file descriptor, including '/dev/stdout', '/dev/stderr' and '/dev/null' (default /dev/stderr) diff --git a/tests/snapshots/TestCLICommands_atmos_helmfile_apply_--help.stdout.golden b/tests/snapshots/TestCLICommands_atmos_helmfile_apply_--help.stdout.golden index 4ef353136f..d7f35dd0d7 100644 --- a/tests/snapshots/TestCLICommands_atmos_helmfile_apply_--help.stdout.golden +++ b/tests/snapshots/TestCLICommands_atmos_helmfile_apply_--help.stdout.golden @@ -47,6 +47,10 @@ GLOBAL FLAGS --identity string Identity to use for authentication. Use --identity to select interactively, -- identity=NAME to specify + --interactive Enable interactive prompts for missing required flags, optional value flags using the + sentinel pattern, and missing positional arguments (requires TTY, disabled in CI) + (default true) + --logs-file string The file to write Atmos logs to. Logs can be written to any file or any standard file descriptor, including '/dev/stdout', '/dev/stderr' and '/dev/null' (default /dev/stderr) diff --git a/tests/snapshots/TestCLICommands_atmos_helmfile_apply_help.stdout.golden b/tests/snapshots/TestCLICommands_atmos_helmfile_apply_help.stdout.golden index 4ef353136f..d7f35dd0d7 100644 --- a/tests/snapshots/TestCLICommands_atmos_helmfile_apply_help.stdout.golden +++ b/tests/snapshots/TestCLICommands_atmos_helmfile_apply_help.stdout.golden @@ -47,6 +47,10 @@ GLOBAL FLAGS --identity string Identity to use for authentication. Use --identity to select interactively, -- identity=NAME to specify + --interactive Enable interactive prompts for missing required flags, optional value flags using the + sentinel pattern, and missing positional arguments (requires TTY, disabled in CI) + (default true) + --logs-file string The file to write Atmos logs to. Logs can be written to any file or any standard file descriptor, including '/dev/stdout', '/dev/stderr' and '/dev/null' (default /dev/stderr) diff --git a/tests/snapshots/TestCLICommands_atmos_helmfile_help.stdout.golden b/tests/snapshots/TestCLICommands_atmos_helmfile_help.stdout.golden index 8c7ce3ffe7..0331151f4a 100644 --- a/tests/snapshots/TestCLICommands_atmos_helmfile_help.stdout.golden +++ b/tests/snapshots/TestCLICommands_atmos_helmfile_help.stdout.golden @@ -58,6 +58,10 @@ GLOBAL FLAGS --identity string Identity to use for authentication. Use --identity to select interactively, -- identity=NAME to specify + --interactive Enable interactive prompts for missing required flags, optional value flags using the + sentinel pattern, and missing positional arguments (requires TTY, disabled in CI) + (default true) + --logs-file string The file to write Atmos logs to. Logs can be written to any file or any standard file descriptor, including '/dev/stdout', '/dev/stderr' and '/dev/null' (default /dev/stderr) diff --git a/tests/snapshots/TestCLICommands_atmos_terraform_--help.stdout.golden b/tests/snapshots/TestCLICommands_atmos_terraform_--help.stdout.golden index ec7f7588d4..4e8608384e 100644 --- a/tests/snapshots/TestCLICommands_atmos_terraform_--help.stdout.golden +++ b/tests/snapshots/TestCLICommands_atmos_terraform_--help.stdout.golden @@ -266,6 +266,10 @@ GLOBAL FLAGS --heatmap-mode string Heatmap visualization mode: bar, sparkline, table (press 1-3 to switch in TUI) (default bar) + --interactive Enable interactive prompts for missing required flags, optional value flags using the + sentinel pattern, and missing positional arguments (requires TTY, disabled in CI) + (default true) + --logs-file string The file to write Atmos logs to. Logs can be written to any file or any standard file descriptor, including '/dev/stdout', '/dev/stderr' and '/dev/null' (default /dev/stderr) diff --git a/tests/snapshots/TestCLICommands_atmos_terraform_--help_alias_subcommand_check.stdout.golden b/tests/snapshots/TestCLICommands_atmos_terraform_--help_alias_subcommand_check.stdout.golden index 98d58fff63..bbee4275bf 100644 --- a/tests/snapshots/TestCLICommands_atmos_terraform_--help_alias_subcommand_check.stdout.golden +++ b/tests/snapshots/TestCLICommands_atmos_terraform_--help_alias_subcommand_check.stdout.golden @@ -265,6 +265,10 @@ GLOBAL FLAGS --heatmap-mode string Heatmap visualization mode: bar, sparkline, table (press 1-3 to switch in TUI) (default bar) + --interactive Enable interactive prompts for missing required flags, optional value flags using the + sentinel pattern, and missing positional arguments (requires TTY, disabled in CI) + (default true) + --logs-file string The file to write Atmos logs to. Logs can be written to any file or any standard file descriptor, including '/dev/stdout', '/dev/stderr' and '/dev/null' (default /dev/stderr) diff --git a/tests/snapshots/TestCLICommands_atmos_terraform_apply_--help.stdout.golden b/tests/snapshots/TestCLICommands_atmos_terraform_apply_--help.stdout.golden index 51adb1b160..56a5ad8c44 100644 --- a/tests/snapshots/TestCLICommands_atmos_terraform_apply_--help.stdout.golden +++ b/tests/snapshots/TestCLICommands_atmos_terraform_apply_--help.stdout.golden @@ -81,6 +81,10 @@ GLOBAL FLAGS --init-pass-vars Pass the generated varfile to terraform init using the --var-file flag. OpenTofu supports passing a varfile to init to dynamically configure backends + --interactive Enable interactive prompts for missing required flags, optional value flags using + the sentinel pattern, and missing positional arguments (requires TTY, disabled in + CI) (default true) + --logs-file string The file to write Atmos logs to. Logs can be written to any file or any standard file descriptor, including '/dev/stdout', '/dev/stderr' and '/dev/null' (default /dev/stderr) diff --git a/tests/snapshots/TestCLICommands_atmos_terraform_apply_help.stdout.golden b/tests/snapshots/TestCLICommands_atmos_terraform_apply_help.stdout.golden index 51adb1b160..56a5ad8c44 100644 --- a/tests/snapshots/TestCLICommands_atmos_terraform_apply_help.stdout.golden +++ b/tests/snapshots/TestCLICommands_atmos_terraform_apply_help.stdout.golden @@ -81,6 +81,10 @@ GLOBAL FLAGS --init-pass-vars Pass the generated varfile to terraform init using the --var-file flag. OpenTofu supports passing a varfile to init to dynamically configure backends + --interactive Enable interactive prompts for missing required flags, optional value flags using + the sentinel pattern, and missing positional arguments (requires TTY, disabled in + CI) (default true) + --logs-file string The file to write Atmos logs to. Logs can be written to any file or any standard file descriptor, including '/dev/stdout', '/dev/stderr' and '/dev/null' (default /dev/stderr) diff --git a/tests/snapshots/TestCLICommands_atmos_terraform_help.stdout.golden b/tests/snapshots/TestCLICommands_atmos_terraform_help.stdout.golden index dbace276fa..a909a6ad66 100644 --- a/tests/snapshots/TestCLICommands_atmos_terraform_help.stdout.golden +++ b/tests/snapshots/TestCLICommands_atmos_terraform_help.stdout.golden @@ -266,6 +266,10 @@ GLOBAL FLAGS --heatmap-mode string Heatmap visualization mode: bar, sparkline, table (press 1-3 to switch in TUI) (default bar) + --interactive Enable interactive prompts for missing required flags, optional value flags using the + sentinel pattern, and missing positional arguments (requires TTY, disabled in CI) + (default true) + --logs-file string The file to write Atmos logs to. Logs can be written to any file or any standard file descriptor, including '/dev/stdout', '/dev/stderr' and '/dev/null' (default /dev/stderr) diff --git a/tests/snapshots/TestCLICommands_atmos_validate_editorconfig_--help.stdout.golden b/tests/snapshots/TestCLICommands_atmos_validate_editorconfig_--help.stdout.golden index 751d7db102..c6548335ba 100644 --- a/tests/snapshots/TestCLICommands_atmos_validate_editorconfig_--help.stdout.golden +++ b/tests/snapshots/TestCLICommands_atmos_validate_editorconfig_--help.stdout.golden @@ -62,6 +62,10 @@ GLOBAL FLAGS --identity string Identity to use for authentication. Use --identity to select interactively, -- identity=NAME to specify + --interactive Enable interactive prompts for missing required flags, optional value flags using the + sentinel pattern, and missing positional arguments (requires TTY, disabled in CI) + (default true) + --logs-file string The file to write Atmos logs to. Logs can be written to any file or any standard file descriptor, including '/dev/stdout', '/dev/stderr' and '/dev/null' (default /dev/stderr) diff --git a/tests/snapshots/TestCLICommands_atmos_validate_editorconfig_help.stdout.golden b/tests/snapshots/TestCLICommands_atmos_validate_editorconfig_help.stdout.golden index 751d7db102..c6548335ba 100644 --- a/tests/snapshots/TestCLICommands_atmos_validate_editorconfig_help.stdout.golden +++ b/tests/snapshots/TestCLICommands_atmos_validate_editorconfig_help.stdout.golden @@ -62,6 +62,10 @@ GLOBAL FLAGS --identity string Identity to use for authentication. Use --identity to select interactively, -- identity=NAME to specify + --interactive Enable interactive prompts for missing required flags, optional value flags using the + sentinel pattern, and missing positional arguments (requires TTY, disabled in CI) + (default true) + --logs-file string The file to write Atmos logs to. Logs can be written to any file or any standard file descriptor, including '/dev/stdout', '/dev/stderr' and '/dev/null' (default /dev/stderr) diff --git a/tests/snapshots/TestCLICommands_config_alias_tp_--help_shows_terraform_plan_help.stdout.golden b/tests/snapshots/TestCLICommands_config_alias_tp_--help_shows_terraform_plan_help.stdout.golden index aec6e9be8f..215282ef54 100644 --- a/tests/snapshots/TestCLICommands_config_alias_tp_--help_shows_terraform_plan_help.stdout.golden +++ b/tests/snapshots/TestCLICommands_config_alias_tp_--help_shows_terraform_plan_help.stdout.golden @@ -83,6 +83,10 @@ GLOBAL FLAGS --init-pass-vars Pass the generated varfile to terraform init using the --var-file flag. OpenTofu supports passing a varfile to init to dynamically configure backends + --interactive Enable interactive prompts for missing required flags, optional value flags using + the sentinel pattern, and missing positional arguments (requires TTY, disabled in + CI) (default true) + --logs-file string The file to write Atmos logs to. Logs can be written to any file or any standard file descriptor, including '/dev/stdout', '/dev/stderr' and '/dev/null' (default /dev/stderr) diff --git a/tests/snapshots/TestCLICommands_config_alias_tr_--help_shows_terraform_help.stdout.golden b/tests/snapshots/TestCLICommands_config_alias_tr_--help_shows_terraform_help.stdout.golden index 98d58fff63..bbee4275bf 100644 --- a/tests/snapshots/TestCLICommands_config_alias_tr_--help_shows_terraform_help.stdout.golden +++ b/tests/snapshots/TestCLICommands_config_alias_tr_--help_shows_terraform_help.stdout.golden @@ -265,6 +265,10 @@ GLOBAL FLAGS --heatmap-mode string Heatmap visualization mode: bar, sparkline, table (press 1-3 to switch in TUI) (default bar) + --interactive Enable interactive prompts for missing required flags, optional value flags using the + sentinel pattern, and missing positional arguments (requires TTY, disabled in CI) + (default true) + --logs-file string The file to write Atmos logs to. Logs can be written to any file or any standard file descriptor, including '/dev/stdout', '/dev/stderr' and '/dev/null' (default /dev/stderr) diff --git a/website/blog/2025-11-18-interactive-flag-prompts.mdx b/website/blog/2025-11-18-interactive-flag-prompts.mdx new file mode 100644 index 0000000000..b81c237471 --- /dev/null +++ b/website/blog/2025-11-18-interactive-flag-prompts.mdx @@ -0,0 +1,138 @@ +--- +slug: interactive-flag-prompts +title: "Interactive Prompts for Missing Required Flags" +authors: [osterman] +tags: [enhancement, dx] +--- + +Atmos now includes interactive prompts for missing required flags and positional arguments, making commands more discoverable and user-friendly. This feature is being gradually rolled out across commands. + + + +## What Changed + +Commands with required flags or positional arguments now automatically prompt you to select from available options when values are missing. This works just like shell autocomplete, helping you discover available options without memorizing values or checking documentation. + +For example, running `atmos theme show` without arguments now displays an interactive menu: + +```bash +atmos theme show +# ↓ Shows interactive selector with available themes +``` + +Previously, you'd see an error message requiring you to check the docs for valid values. + +## Why This Matters + +Interactive prompts significantly improve the developer experience by: + +1. **Reducing cognitive load** - No need to remember all valid values for flags +2. **Faster workflow** - Select from a menu instead of typing and potentially mistyping +3. **Better discoverability** - See what options are available without leaving the terminal +4. **CI-friendly** - Automatically disabled in non-interactive environments (CI/CD, piped output) + +## How It Works + +Interactive prompts appear when all these conditions are met: + +1. **TTY detected** - You're running in an interactive terminal +2. **Required flag missing** - The command has a required flag without a value +3. **`--interactive` flag is true** - Enabled by default, disable with `--interactive=false` + +### Flag Patterns + +**Interactive selector with empty value:** +```bash +# Prompts for identity selection +atmos auth whoami --identity + +# Prompts for theme selection +atmos theme show --theme +``` + +**Interactive selector when flag omitted:** +```bash +# Prompts if stack completion is available +atmos terraform plan mycomponent +``` + +**Skip prompt with explicit value:** +```bash +# No prompt - uses provided value +atmos theme show --theme default +atmos auth whoami --identity production +``` + +## Disabling Interactive Prompts + +For scripting or CI/CD environments, disable prompts using: + +**Via flag:** +```bash +atmos theme show --interactive=false +``` + +**Via environment variable:** +```bash +export ATMOS_INTERACTIVE=false +atmos theme show +``` + +**Via configuration:** +```yaml +# atmos.yaml +settings: + interactive: false +``` + +Interactive prompts are automatically disabled when: +- Running in CI (detected via `CI` environment variable) +- Output is piped or redirected +- Not running in a TTY + +## Examples in Action + +### Theme Selection +```bash +$ atmos theme show +? Select a theme: + > default + github-dark + dracula + monokai +``` + +### Identity Selection +```bash +$ atmos auth whoami --identity +? Select an identity: + > production + staging + development +``` + +### Positional Arguments +```bash +$ atmos describe component +? Select a component: + > vpc + eks-cluster + rds-aurora +``` + +:::tip Filtering Options +When a selector has many options, you can filter by typing `/` followed by your search text. Note that filtering is case-sensitive. +::: + +## Rollout Status + +This feature is currently being rolled out across Atmos commands. Initial support is available for: +- `atmos theme show` - Theme selection. +- `atmos auth` commands - Identity selection. + +**Coming soon:** We're actively working to add interactive prompts to core functionality including `atmos terraform`, `atmos helmfile`, and `atmos packer`. These commands will prompt for component and stack selection when values are missing, making it even easier to work with infrastructure components. + +## Get Involved + +- [Documentation: Developing Atmos Commands](https://atmos.tools/developing-atmos-commands) +- Share your feedback in the [Atmos Community Slack](https://cloudposse.com/slack)