diff --git a/internal/iostreams/prompts.go b/internal/iostreams/prompts.go index 37ebf59d..ce10b85d 100644 --- a/internal/iostreams/prompts.go +++ b/internal/iostreams/prompts.go @@ -21,7 +21,9 @@ import ( "fmt" "strings" + lipgloss "charm.land/lipgloss/v2" "github.com/slackapi/slack-cli/internal/slackerror" + "github.com/slackapi/slack-cli/internal/style" "github.com/spf13/pflag" ) @@ -31,6 +33,23 @@ type PromptConfig interface { IsRequired() bool // IsRequired returns if a response must be provided } +// PromptOption pairs an interactive option label with the flag invocation +// that picks the same option non-interactively. When a prompt is reached in +// a non-TTY context, the resulting error renders one of these per option so +// agents and scripts can re-run with the right --flag=value. +type PromptOption struct { + Label string // The option as rendered in the interactive list + Flag *pflag.Flag // The flag substitute for this option + Value string // The value to pass, e.g. "T0123" or "A0ABCD" +} + +// PromptOptionsConfig is optionally implemented by prompt configs that can +// enumerate options as flag invocations. Configs that do not implement it +// (or return an empty slice) keep the simpler "Try --flag" remediation. +type PromptOptionsConfig interface { + GetPromptOptions() []PromptOption +} + // ConfirmPromptConfig holds additional configs for a Confirm prompt type ConfirmPromptConfig struct { Required bool // If a response is required @@ -64,7 +83,8 @@ func (cfg InputPromptConfig) IsRequired() bool { // MultiSelectPromptConfig holds additional configs for a MultiSelect prompt type MultiSelectPromptConfig struct { - Required bool // If a response is required + Options []PromptOption // Optional flag invocations parallel to the prompt's options + Required bool // If a response is required } // GetFlags returns all flags for the MultiSelect prompt @@ -72,6 +92,11 @@ func (cfg MultiSelectPromptConfig) GetFlags() []*pflag.Flag { return []*pflag.Flag{} } +// GetPromptOptions returns flag invocations for each option, when set +func (cfg MultiSelectPromptConfig) GetPromptOptions() []PromptOption { + return cfg.Options +} + // IsRequired returns if a response is required func (cfg MultiSelectPromptConfig) IsRequired() bool { return cfg.Required @@ -112,6 +137,7 @@ type SelectPromptConfig struct { Flag *pflag.Flag // The single flag substitute for this prompt Flags []*pflag.Flag // Otherwise multiple flag substitutes for this prompt Help string // Optional help text displayed below the select title + Options []PromptOption // Optional flag invocations parallel to the prompt's options PageSize int // DEPRECATED: The number of options displayed before the user needs to scroll Required bool // If a response is required Template string // DEPRECATED: Custom formatting of the selection prompt @@ -129,6 +155,11 @@ func (cfg SelectPromptConfig) GetFlags() []*pflag.Flag { } } +// GetPromptOptions returns flag invocations for each option, when set +func (cfg SelectPromptConfig) GetPromptOptions() []PromptOption { + return cfg.Options +} + // IsRequired returns if a response is required func (cfg SelectPromptConfig) IsRequired() bool { return cfg.Required @@ -163,37 +194,101 @@ func (io *IOStreams) retrieveFlagValue(flagset []*pflag.Flag) (*pflag.Flag, erro return flag, nil } -// errInteractivityFlags formats an error for when flag substitutes are needed -func errInteractivityFlags(cfg PromptConfig) error { +// errInteractivityFlags formats an error for when flag substitutes are needed. +// It re-renders the prompt question and any enumerable options (with their +// equivalent --flag=value invocations) as part of the error body so agents +// and devops scripts can read the error and re-run with the right flags. +// The Suggestion remains a short, single-line directive. +func errInteractivityFlags(cfg PromptConfig, message string, options []string) error { flags := cfg.GetFlags() - var remediation string - var helpMessage = "Learn more about this command with `--help`" - if len(flags) == 1 { - remediation = fmt.Sprintf("Try running the command with the `--%s` flag included", flags[0].Name) - helpMessage = "Learn more about this flag with `--help`" - } else if len(flags) > 1 { - var names []string + var promptOptions []PromptOption + if oc, ok := cfg.(PromptOptionsConfig); ok { + promptOptions = oc.GetPromptOptions() + } + if len(options) > 0 && len(promptOptions) != len(options) { + promptOptions = nil + } + + hasFlagOptions := false + for _, opt := range promptOptions { + if opt.Flag != nil && opt.Value != "" { + hasFlagOptions = true + break + } + } + + body := []string{"The input device is not a TTY or does not support interactivity"} + if message != "" || len(promptOptions) > 0 || len(options) > 0 { + body = append(body, "The prompt that would have been shown is below:", "") + } + if message != "" { + body = append(body, fmt.Sprintf("› %s", message)) + } + + switch { + case len(promptOptions) > 0: + labelWidth := 0 + if hasFlagOptions { + for _, opt := range promptOptions { + if opt.Flag == nil || opt.Value == "" { + continue + } + if w := lipgloss.Width(opt.Label); w > labelWidth { + labelWidth = w + } + } + } + for _, opt := range promptOptions { + if opt.Flag == nil || opt.Value == "" { + body = append(body, fmt.Sprintf(" %s", opt.Label)) + continue + } + padding := max(labelWidth-lipgloss.Width(opt.Label), 0) + flagText := style.Secondary(fmt.Sprintf("--%s=%s", opt.Flag.Name, opt.Value)) + body = append(body, fmt.Sprintf(" %s%s %s", opt.Label, strings.Repeat(" ", padding), flagText)) + } + case len(options) > 0: + for _, opt := range options { + body = append(body, fmt.Sprintf(" %s", opt)) + } + } + + var remediation string + switch { + case hasFlagOptions: + var flagName string + for _, opt := range promptOptions { + if opt.Flag != nil { + flagName = opt.Flag.Name + break + } + } + remediation = fmt.Sprintf("Re-run with one of the `--%s` values shown above", flagName) + case len(flags) == 1: + remediation = fmt.Sprintf("Try running the command with the `--%s` flag included\nLearn more about this flag with `--help`", flags[0].Name) + case len(flags) > 1: + names := make([]string, 0, len(flags)) for _, flag := range flags { names = append(names, flag.Name) } - flags := strings.Join(names, "`\n `--") - remediation = fmt.Sprintf("Consider using the following flags when running this command:\n `--%s`", flags) - helpMessage = "Learn more about these flags with `--help`" + remediation = fmt.Sprintf("Consider using the following flags when running this command:\n `--%s`\nLearn more about these flags with `--help`", strings.Join(names, "`\n `--")) + default: + remediation = "Learn more about this command with `--help`" } return slackerror.New(slackerror.ErrPrompt). WithDetails(slackerror.ErrorDetails{ - slackerror.ErrorDetail{Message: "The input device is not a TTY or does not support interactivity"}, + slackerror.ErrorDetail{Message: strings.Join(body, "\n")}, }). - WithRemediation("%s\n%s", remediation, helpMessage) + WithRemediation("%s", remediation) } // ConfirmPrompt prompts the user for a "yes" or "no" (true or false) value for // the message func (io *IOStreams) ConfirmPrompt(ctx context.Context, message string, defaultValue bool) (bool, error) { if !io.IsTTY() { - return false, errInteractivityFlags(ConfirmPromptConfig{}) + return false, errInteractivityFlags(ConfirmPromptConfig{}, message, nil) } return confirmForm(io, ctx, message, defaultValue) } @@ -203,7 +298,7 @@ func (io *IOStreams) ConfirmPrompt(ctx context.Context, message string, defaultV func (io *IOStreams) InputPrompt(ctx context.Context, message string, cfg InputPromptConfig) (string, error) { if !io.IsTTY() { if cfg.IsRequired() { - return "", errInteractivityFlags(cfg) + return "", errInteractivityFlags(cfg, message, nil) } return "", nil } @@ -214,7 +309,7 @@ func (io *IOStreams) InputPrompt(ctx context.Context, message string, cfg InputP // returns the selected values func (io *IOStreams) MultiSelectPrompt(ctx context.Context, message string, options []string) ([]string, error) { if !io.IsTTY() { - return nil, errInteractivityFlags(MultiSelectPromptConfig{}) + return nil, errInteractivityFlags(MultiSelectPromptConfig{}, message, options) } return multiSelectForm(io, ctx, message, options) } @@ -228,7 +323,7 @@ func (io *IOStreams) PasswordPrompt(ctx context.Context, message string, cfg Pas return PasswordPromptResponse{Flag: true, Value: cfg.Flag.Value.String()}, nil } if !io.IsTTY() { - return PasswordPromptResponse{}, errInteractivityFlags(cfg) + return PasswordPromptResponse{}, errInteractivityFlags(cfg, message, nil) } return passwordForm(io, ctx, message, cfg) @@ -250,7 +345,7 @@ func (io *IOStreams) SelectPrompt(ctx context.Context, msg string, options []str } if !io.IsTTY() { if cfg.IsRequired() { - return SelectPromptResponse{}, errInteractivityFlags(cfg) + return SelectPromptResponse{}, errInteractivityFlags(cfg, msg, options) } else { return SelectPromptResponse{}, nil } diff --git a/internal/iostreams/prompts_test.go b/internal/iostreams/prompts_test.go index 6b6960f9..e4f899bd 100644 --- a/internal/iostreams/prompts_test.go +++ b/internal/iostreams/prompts_test.go @@ -177,7 +177,10 @@ func TestRetrieveFlagValue(t *testing.T) { func TestErrInteractivityFlags(t *testing.T) { tests := map[string]struct { cfg PromptConfig + message string + options []string contains []string + excludes []string }{ "no flags shows generic message": { cfg: ConfirmPromptConfig{}, @@ -196,17 +199,86 @@ func TestErrInteractivityFlags(t *testing.T) { }}, contains: []string{"--app", "--team"}, }, + "renders question and per-option flag invocations when provided": { + cfg: SelectPromptConfig{ + Flag: &pflag.Flag{Name: "team"}, + Options: []PromptOption{ + {Label: "team-one", Flag: &pflag.Flag{Name: "team"}, Value: "T0001"}, + {Label: "team-two", Flag: &pflag.Flag{Name: "team"}, Value: "T0002"}, + }, + }, + message: "Choose a team", + options: []string{"team-one", "team-two"}, + contains: []string{ + "prompt that would have been shown", + "› Choose a team", + "team-one", + "--team=T0001", + "team-two", + "--team=T0002", + "Re-run with one of the `--team` values shown above", + }, + }, + "degrades to flag suggestion when option count mismatches": { + cfg: SelectPromptConfig{ + Flag: &pflag.Flag{Name: "team"}, + Options: []PromptOption{ + {Label: "team-one", Flag: &pflag.Flag{Name: "team"}, Value: "T0001"}, + }, + }, + message: "Choose a team", + options: []string{"team-one", "team-two"}, + contains: []string{ + "--team", + "› Choose a team", + "team-one", + "team-two", + }, + excludes: []string{"--team=T0001", "Re-run with one of"}, + }, + "renders question even without options": { + cfg: InputPromptConfig{Required: true}, + message: "Enter a name", + contains: []string{"› Enter a name"}, + }, } for name, tc := range tests { t.Run(name, func(t *testing.T) { - err := errInteractivityFlags(tc.cfg) + err := errInteractivityFlags(tc.cfg, tc.message, tc.options) for _, s := range tc.contains { assert.Contains(t, err.Error(), s) } + for _, s := range tc.excludes { + assert.NotContains(t, err.Error(), s) + } }) } } +func TestErrInteractivityFlags_StructuredDetails(t *testing.T) { + cfg := SelectPromptConfig{ + Flag: &pflag.Flag{Name: "team"}, + Options: []PromptOption{ + {Label: "team-one", Flag: &pflag.Flag{Name: "team"}, Value: "T0001"}, + {Label: "team-two", Flag: &pflag.Flag{Name: "team"}, Value: "T0002"}, + }, + } + err := errInteractivityFlags(cfg, "Choose a team", []string{"team-one", "team-two"}) + se := slackerror.ToSlackError(err) + + assert.Equal(t, slackerror.ErrPrompt, se.Code) + require.Len(t, se.Details, 1) + + body := se.Details[0].Message + assert.Contains(t, body, "not a TTY") + assert.Contains(t, body, "prompt that would have been shown") + assert.Contains(t, body, "› Choose a team") + assert.Contains(t, body, "--team=T0001") + assert.Contains(t, body, "--team=T0002") + + assert.Equal(t, "Re-run with one of the `--team` values shown above", se.Remediation) +} + func TestPasswordPrompt(t *testing.T) { tests := map[string]struct { flagChanged bool diff --git a/internal/prompts/app_select.go b/internal/prompts/app_select.go index 3b95ee46..270a7e81 100644 --- a/internal/prompts/app_select.go +++ b/internal/prompts/app_select.go @@ -601,9 +601,19 @@ func AppSelectPrompt( if cfg.includeNoApp { options = append(options, Selection{label: noApp}) } + appFlag := clients.Config.Flags.Lookup("app") labels := []string{} - for _, label := range options { - labels = append(labels, label.label) + appOptions := []iostreams.PromptOption{} + for _, opt := range options { + labels = append(labels, opt.label) + // Synthetic entries ("Create a new app", "No app") have no AppID and + // are emitted as label-only so the option list stays 1:1 with labels. + promptOpt := iostreams.PromptOption{Label: opt.label} + if opt.app.App.AppID != "" { + promptOpt.Flag = appFlag + promptOpt.Value = opt.app.App.AppID + } + appOptions = append(appOptions, promptOpt) } switch { case types.IsAppID(clients.Config.AppFlag): @@ -633,11 +643,13 @@ func AppSelectPrompt( labels, iostreams.SelectPromptConfig{ Required: true, + Options: appOptions, - // Flag is checked before since the value might be an app "environment" while - // an app ID is required in the return. - // - // Flag: clients.Config.Flags.Lookup("app"), + // Flag is intentionally not set: --app may be an environment value + // ("local", "deployed") which must not be matched against an app + // ID. The IsAppID and IsAppFlagEnvironment branches above handle + // the cases where --app is set; Options provides per-option + // flag-substitute hints for the non-TTY error path. }) if err != nil { return SelectedApp{}, err diff --git a/internal/prompts/team_select.go b/internal/prompts/team_select.go index 4c6240d8..3542a655 100644 --- a/internal/prompts/team_select.go +++ b/internal/prompts/team_select.go @@ -50,17 +50,23 @@ func PromptTeamSlackAuth(ctx context.Context, clients *shared.ClientFactory, pro return strings.Compare(i.TeamDomain, j.TeamDomain) }) + teamFlag := clients.Config.Flags.Lookup("team") var teamLabels []string + var teamOptions []iostreams.PromptOption for _, auth := range allAuths { - teamLabels = append( - teamLabels, - style.TeamSelectLabel(auth.TeamDomain, auth.TeamID), - ) + label := style.TeamSelectLabel(auth.TeamDomain, auth.TeamID) + teamLabels = append(teamLabels, label) + teamOptions = append(teamOptions, iostreams.PromptOption{ + Label: label, + Flag: teamFlag, + Value: auth.TeamID, + }) } selectPromptConfig := iostreams.SelectPromptConfig{ Required: true, - Flag: clients.Config.Flags.Lookup("team"), + Flag: teamFlag, + Options: teamOptions, } if promptConfig != nil && promptConfig.HelpText != "" { selectPromptConfig.Help = promptConfig.HelpText