Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
55b2a64
feat: add interactive prompts for positional arguments
osterman Nov 18, 2025
a7afb13
fix: Add interactive mode check to PromptForPositionalArg
osterman Nov 18, 2025
d49c737
docs: Fix documentation path and improve flag example
osterman Nov 18, 2025
3e73168
docs: Add blog post for interactive flag prompts feature
osterman Nov 18, 2025
91cc7c0
docs: Fix punctuation in blog post list items
osterman Nov 20, 2025
08c286b
docs: Update --interactive flag description to cover all prompt scena…
osterman Nov 20, 2025
4eccbee
test: Regenerate golden snapshots after merging main
osterman Nov 24, 2025
3688cc5
test: Regenerate snapshots with --interactive flag in help output
osterman Dec 5, 2025
824eea7
Fix comment formatting to comply with godot linter
osterman Dec 9, 2025
285a735
Merge branch 'main' into osterman/interactive-flag-prompts
osterman Dec 13, 2025
1d6f168
test: Improve pkg/flags test coverage to 75.8%
osterman Dec 13, 2025
7e0c8b0
Merge branch 'main' into osterman/interactive-flag-prompts
aknysh Dec 14, 2025
8c5f7fa
refactor: Convert ValidateSingleFlag tests to table-driven style
aknysh Dec 14, 2025
18fda45
test: Add comprehensive test coverage for flag parsing and theme comm…
aknysh Dec 14, 2025
e945d92
add tests
aknysh Dec 14, 2025
8c3ff49
test: Add comprehensive test coverage for flag parsing and theme comm…
aknysh Dec 14, 2025
d06a10b
address comments, add tests
aknysh Dec 14, 2025
d18dd1d
Merge remote-tracking branch 'origin/osterman/interactive-flag-prompt…
aknysh Dec 14, 2025
fdd4b36
address comments, add tests
aknysh Dec 14, 2025
5733738
address comments, add tests
aknysh Dec 14, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
161 changes: 161 additions & 0 deletions .claude/agents/flag-handler.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <theme-name>"
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:
Expand All @@ -398,6 +558,7 @@ type global.Flags struct {
ForceColor bool
ForceTTY bool
Mask bool
Interactive bool // Enable interactive prompts (default: true)
Pager string
}
```
Expand Down
69 changes: 61 additions & 8 deletions cmd/theme/show.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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).
Expand All @@ -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{
Expand Down
84 changes: 79 additions & 5 deletions cmd/theme/theme_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <theme-name>" {
hasShowCmd = true
break
}
Expand Down Expand Up @@ -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 <theme-name>", 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")
Expand Down Expand Up @@ -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()
Expand Down
Loading
Loading