From 63dc85218b895c5b43e38cfd2bb96361ffb662af Mon Sep 17 00:00:00 2001 From: Michael Brooks Date: Thu, 11 Jun 2026 09:29:07 -0700 Subject: [PATCH 1/3] feat: render prompt + flag values in non-TTY error Re-render the prompt question and annotate each option with the equivalent --flag=value invocation when a required prompt is reached without a TTY. Lets agents and devops scripts read the error and re-run with the right flag. Adds an optional Options []PromptOption field on SelectPromptConfig and MultiSelectPromptConfig; configs that don't set it keep the prior "Try --flag" remediation. Migrates the team_select and app_select reference sites; other prompt sites are unchanged. --- internal/iostreams/prompts.go | 114 +++++++++++++++++++++++------ internal/iostreams/prompts_test.go | 66 ++++++++++++++++- internal/prompts/app_select.go | 23 ++++-- internal/prompts/team_select.go | 13 +++- 4 files changed, 182 insertions(+), 34 deletions(-) diff --git a/internal/iostreams/prompts.go b/internal/iostreams/prompts.go index 37ebf59d..f03dd8d5 100644 --- a/internal/iostreams/prompts.go +++ b/internal/iostreams/prompts.go @@ -22,6 +22,7 @@ import ( "strings" "github.com/slackapi/slack-cli/internal/slackerror" + "github.com/slackapi/slack-cli/internal/style" "github.com/spf13/pflag" ) @@ -31,6 +32,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 string // The pflag name, e.g. "team", "app" + 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 +82,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 +91,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 +136,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 +154,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 +193,75 @@ 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) so agents and devops scripts can read +// the error and re-run with the right flags. Configs that don't expose +// per-option flag invocations fall back to a flag-name-only suggestion. +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 + + details := slackerror.ErrorDetails{ + slackerror.ErrorDetail{Message: "The input device is not a TTY or does not support interactivity"}, + } + + var promptOptions []PromptOption + if oc, ok := cfg.(PromptOptionsConfig); ok { + promptOptions = oc.GetPromptOptions() + } + // Only honor per-option flag invocations when they line up with the + // options actually shown to the user; mismatches indicate a stale config. + if len(options) > 0 && len(promptOptions) != len(options) { + promptOptions = nil + } + + var lines []string + if message != "" { + lines = append(lines, fmt.Sprintf("? %s", message)) + } + + hasFlagOptions := false + for _, opt := range promptOptions { + if opt.Flag != "" && opt.Value != "" { + hasFlagOptions = true + break + } + } + + switch { + case hasFlagOptions: + for _, opt := range promptOptions { + if opt.Flag == "" || opt.Value == "" { + lines = append(lines, fmt.Sprintf(" %s", opt.Label)) + continue + } + lines = append(lines, fmt.Sprintf(" %s %s", opt.Label, style.Secondary(fmt.Sprintf("--%s=%s", opt.Flag, opt.Value)))) + } + lines = append(lines, "Re-run with one of the values above") + case len(flags) == 1: + lines = append(lines, fmt.Sprintf("Try running the command with the `--%s` flag included", flags[0].Name)) + lines = append(lines, "Learn more about this flag with `--help`") + 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`" + lines = append(lines, fmt.Sprintf("Consider using the following flags when running this command:\n `--%s`", strings.Join(names, "`\n `--"))) + lines = append(lines, "Learn more about these flags with `--help`") + default: + lines = append(lines, "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"}, - }). - WithRemediation("%s\n%s", remediation, helpMessage) + WithDetails(details). + WithRemediation("%s", strings.Join(lines, "\n")) } // 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 +271,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 +282,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 +296,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 +318,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..1dd8fb97 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,78 @@ 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: "team", Value: "T0001"}, + {Label: "team-two", Flag: "team", Value: "T0002"}, + }, + }, + message: "Choose a team", + options: []string{"team-one", "team-two"}, + contains: []string{ + "Choose a team", + "--team=T0001", + "--team=T0002", + "Re-run with one of the values above", + }, + }, + "degrades to flag suggestion when option count mismatches": { + cfg: SelectPromptConfig{ + Flag: &pflag.Flag{Name: "team"}, + Options: []PromptOption{ + {Label: "team-one", Flag: "team", Value: "T0001"}, + }, + }, + message: "Choose a team", + options: []string{"team-one", "team-two"}, + contains: []string{"--team", "Choose a team"}, + excludes: []string{"--team=T0001", "Re-run with one of the values above"}, + }, + "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: "team", Value: "T0001"}, + {Label: "team-two", Flag: "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) + assert.Contains(t, se.Details[0].Message, "not a TTY") + + rendered := err.Error() + assert.Contains(t, rendered, "? Choose a team") + assert.Contains(t, rendered, "team-one") + assert.Contains(t, rendered, "--team=T0001") + assert.Contains(t, rendered, "team-two") + assert.Contains(t, rendered, "--team=T0002") + assert.Contains(t, rendered, "Re-run with one of the values above") +} + 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..a3ba7c2f 100644 --- a/internal/prompts/app_select.go +++ b/internal/prompts/app_select.go @@ -602,8 +602,17 @@ func AppSelectPrompt( options = append(options, Selection{label: noApp}) } 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 = "app" + promptOpt.Value = opt.app.App.AppID + } + appOptions = append(appOptions, promptOpt) } switch { case types.IsAppID(clients.Config.AppFlag): @@ -633,11 +642,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..e025781a 100644 --- a/internal/prompts/team_select.go +++ b/internal/prompts/team_select.go @@ -51,16 +51,21 @@ func PromptTeamSlackAuth(ctx context.Context, clients *shared.ClientFactory, pro }) 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: "team", + Value: auth.TeamID, + }) } selectPromptConfig := iostreams.SelectPromptConfig{ Required: true, Flag: clients.Config.Flags.Lookup("team"), + Options: teamOptions, } if promptConfig != nil && promptConfig.HelpText != "" { selectPromptConfig.Help = promptConfig.HelpText From 4cd8f4295df4ddd7ce3d4b3b993b48b626c27496 Mon Sep 17 00:00:00 2001 From: Michael Brooks Date: Thu, 11 Jun 2026 11:13:48 -0700 Subject: [PATCH 2/3] refactor: render prompt visualization in error body, not Suggestion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the prompt-mirror (question + per-option `--flag=value`) out of the Remediation block and into the error body via Details, so the Suggestion header stops framing the visualization as advice. The Suggestion now carries a short, single-line directive. UX tweaks: replace the leading `?` with `›`, prepend a context line ("The prompt that would have been shown is below:"), and align the `--flag=value` column using lipgloss.Width so labels with embedded ANSI escapes line up. Non-migrated prompts still render the question (and any options), just without per-option flag values, preserving the existing `Try --flag` Suggestion. --- internal/iostreams/prompts.go | 77 ++++++++++++++++++++---------- internal/iostreams/prompts_test.go | 40 +++++++++------- 2 files changed, 76 insertions(+), 41 deletions(-) diff --git a/internal/iostreams/prompts.go b/internal/iostreams/prompts.go index f03dd8d5..9d4d98f5 100644 --- a/internal/iostreams/prompts.go +++ b/internal/iostreams/prompts.go @@ -21,6 +21,7 @@ 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" @@ -195,31 +196,20 @@ func (io *IOStreams) retrieveFlagValue(flagset []*pflag.Flag) (*pflag.Flag, erro // 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) so agents and devops scripts can read -// the error and re-run with the right flags. Configs that don't expose -// per-option flag invocations fall back to a flag-name-only suggestion. +// 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() - details := slackerror.ErrorDetails{ - slackerror.ErrorDetail{Message: "The input device is not a TTY or does not support interactivity"}, - } - var promptOptions []PromptOption if oc, ok := cfg.(PromptOptionsConfig); ok { promptOptions = oc.GetPromptOptions() } - // Only honor per-option flag invocations when they line up with the - // options actually shown to the user; mismatches indicate a stale config. if len(options) > 0 && len(promptOptions) != len(options) { promptOptions = nil } - var lines []string - if message != "" { - lines = append(lines, fmt.Sprintf("? %s", message)) - } - hasFlagOptions := false for _, opt := range promptOptions { if opt.Flag != "" && opt.Value != "" { @@ -228,33 +218,70 @@ func errInteractivityFlags(cfg PromptConfig, message string, options []string) e } } + 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 hasFlagOptions: + case len(promptOptions) > 0: + labelWidth := 0 + if hasFlagOptions { + for _, opt := range promptOptions { + if opt.Flag == "" || opt.Value == "" { + continue + } + if w := lipgloss.Width(opt.Label); w > labelWidth { + labelWidth = w + } + } + } for _, opt := range promptOptions { if opt.Flag == "" || opt.Value == "" { - lines = append(lines, fmt.Sprintf(" %s", opt.Label)) + body = append(body, fmt.Sprintf(" %s", opt.Label)) continue } - lines = append(lines, fmt.Sprintf(" %s %s", opt.Label, style.Secondary(fmt.Sprintf("--%s=%s", opt.Flag, opt.Value)))) + padding := max(labelWidth-lipgloss.Width(opt.Label), 0) + flagText := style.Secondary(fmt.Sprintf("--%s=%s", opt.Flag, 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: + flagName := promptOptions[0].Flag + for _, opt := range promptOptions { + if opt.Flag != "" { + flagName = opt.Flag + break + } } - lines = append(lines, "Re-run with one of the values above") + remediation = fmt.Sprintf("Re-run with one of the `--%s` values shown above", flagName) case len(flags) == 1: - lines = append(lines, fmt.Sprintf("Try running the command with the `--%s` flag included", flags[0].Name)) - lines = append(lines, "Learn more about this flag with `--help`") + 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) } - lines = append(lines, fmt.Sprintf("Consider using the following flags when running this command:\n `--%s`", strings.Join(names, "`\n `--"))) - lines = append(lines, "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: - lines = append(lines, "Learn more about this command with `--help`") + remediation = "Learn more about this command with `--help`" } return slackerror.New(slackerror.ErrPrompt). - WithDetails(details). - WithRemediation("%s", strings.Join(lines, "\n")) + WithDetails(slackerror.ErrorDetails{ + slackerror.ErrorDetail{Message: strings.Join(body, "\n")}, + }). + WithRemediation("%s", remediation) } // ConfirmPrompt prompts the user for a "yes" or "no" (true or false) value for diff --git a/internal/iostreams/prompts_test.go b/internal/iostreams/prompts_test.go index 1dd8fb97..f39cefa8 100644 --- a/internal/iostreams/prompts_test.go +++ b/internal/iostreams/prompts_test.go @@ -210,10 +210,13 @@ func TestErrInteractivityFlags(t *testing.T) { message: "Choose a team", options: []string{"team-one", "team-two"}, contains: []string{ - "Choose a team", + "prompt that would have been shown", + "› Choose a team", + "team-one", "--team=T0001", + "team-two", "--team=T0002", - "Re-run with one of the values above", + "Re-run with one of the `--team` values shown above", }, }, "degrades to flag suggestion when option count mismatches": { @@ -223,15 +226,20 @@ func TestErrInteractivityFlags(t *testing.T) { {Label: "team-one", Flag: "team", Value: "T0001"}, }, }, - message: "Choose a team", - options: []string{"team-one", "team-two"}, - contains: []string{"--team", "Choose a team"}, - excludes: []string{"--team=T0001", "Re-run with one of the values above"}, + 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"}, + contains: []string{"› Enter a name"}, }, } for name, tc := range tests { @@ -260,15 +268,15 @@ func TestErrInteractivityFlags_StructuredDetails(t *testing.T) { assert.Equal(t, slackerror.ErrPrompt, se.Code) require.Len(t, se.Details, 1) - assert.Contains(t, se.Details[0].Message, "not a TTY") - - rendered := err.Error() - assert.Contains(t, rendered, "? Choose a team") - assert.Contains(t, rendered, "team-one") - assert.Contains(t, rendered, "--team=T0001") - assert.Contains(t, rendered, "team-two") - assert.Contains(t, rendered, "--team=T0002") - assert.Contains(t, rendered, "Re-run with one of the values above") + + 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) { From 5c185fb06ba45a12a802ae466ba09e742e53a97d Mon Sep 17 00:00:00 2001 From: Michael Brooks Date: Thu, 11 Jun 2026 11:24:19 -0700 Subject: [PATCH 3/3] refactor: type PromptOption.Flag as *pflag.Flag Drop the hand-typed flag-name string in favor of the registered flag pointer. Call sites now mention the flag name once (via Flags.Lookup) instead of duplicating it across the prompt config and each PromptOption, so a flag rename is a one-line change. --- internal/iostreams/prompts.go | 20 ++++++++++---------- internal/iostreams/prompts_test.go | 10 +++++----- internal/prompts/app_select.go | 3 ++- internal/prompts/team_select.go | 5 +++-- 4 files changed, 20 insertions(+), 18 deletions(-) diff --git a/internal/iostreams/prompts.go b/internal/iostreams/prompts.go index 9d4d98f5..ce10b85d 100644 --- a/internal/iostreams/prompts.go +++ b/internal/iostreams/prompts.go @@ -38,9 +38,9 @@ type PromptConfig interface { // 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 string // The pflag name, e.g. "team", "app" - Value string // The value to pass, e.g. "T0123" or "A0ABCD" + 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 @@ -212,7 +212,7 @@ func errInteractivityFlags(cfg PromptConfig, message string, options []string) e hasFlagOptions := false for _, opt := range promptOptions { - if opt.Flag != "" && opt.Value != "" { + if opt.Flag != nil && opt.Value != "" { hasFlagOptions = true break } @@ -231,7 +231,7 @@ func errInteractivityFlags(cfg PromptConfig, message string, options []string) e labelWidth := 0 if hasFlagOptions { for _, opt := range promptOptions { - if opt.Flag == "" || opt.Value == "" { + if opt.Flag == nil || opt.Value == "" { continue } if w := lipgloss.Width(opt.Label); w > labelWidth { @@ -240,12 +240,12 @@ func errInteractivityFlags(cfg PromptConfig, message string, options []string) e } } for _, opt := range promptOptions { - if opt.Flag == "" || opt.Value == "" { + 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, opt.Value)) + 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: @@ -257,10 +257,10 @@ func errInteractivityFlags(cfg PromptConfig, message string, options []string) e var remediation string switch { case hasFlagOptions: - flagName := promptOptions[0].Flag + var flagName string for _, opt := range promptOptions { - if opt.Flag != "" { - flagName = opt.Flag + if opt.Flag != nil { + flagName = opt.Flag.Name break } } diff --git a/internal/iostreams/prompts_test.go b/internal/iostreams/prompts_test.go index f39cefa8..e4f899bd 100644 --- a/internal/iostreams/prompts_test.go +++ b/internal/iostreams/prompts_test.go @@ -203,8 +203,8 @@ func TestErrInteractivityFlags(t *testing.T) { cfg: SelectPromptConfig{ Flag: &pflag.Flag{Name: "team"}, Options: []PromptOption{ - {Label: "team-one", Flag: "team", Value: "T0001"}, - {Label: "team-two", Flag: "team", Value: "T0002"}, + {Label: "team-one", Flag: &pflag.Flag{Name: "team"}, Value: "T0001"}, + {Label: "team-two", Flag: &pflag.Flag{Name: "team"}, Value: "T0002"}, }, }, message: "Choose a team", @@ -223,7 +223,7 @@ func TestErrInteractivityFlags(t *testing.T) { cfg: SelectPromptConfig{ Flag: &pflag.Flag{Name: "team"}, Options: []PromptOption{ - {Label: "team-one", Flag: "team", Value: "T0001"}, + {Label: "team-one", Flag: &pflag.Flag{Name: "team"}, Value: "T0001"}, }, }, message: "Choose a team", @@ -259,8 +259,8 @@ func TestErrInteractivityFlags_StructuredDetails(t *testing.T) { cfg := SelectPromptConfig{ Flag: &pflag.Flag{Name: "team"}, Options: []PromptOption{ - {Label: "team-one", Flag: "team", Value: "T0001"}, - {Label: "team-two", Flag: "team", Value: "T0002"}, + {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"}) diff --git a/internal/prompts/app_select.go b/internal/prompts/app_select.go index a3ba7c2f..270a7e81 100644 --- a/internal/prompts/app_select.go +++ b/internal/prompts/app_select.go @@ -601,6 +601,7 @@ func AppSelectPrompt( if cfg.includeNoApp { options = append(options, Selection{label: noApp}) } + appFlag := clients.Config.Flags.Lookup("app") labels := []string{} appOptions := []iostreams.PromptOption{} for _, opt := range options { @@ -609,7 +610,7 @@ func AppSelectPrompt( // 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 = "app" + promptOpt.Flag = appFlag promptOpt.Value = opt.app.App.AppID } appOptions = append(appOptions, promptOpt) diff --git a/internal/prompts/team_select.go b/internal/prompts/team_select.go index e025781a..3542a655 100644 --- a/internal/prompts/team_select.go +++ b/internal/prompts/team_select.go @@ -50,6 +50,7 @@ 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 { @@ -57,14 +58,14 @@ func PromptTeamSlackAuth(ctx context.Context, clients *shared.ClientFactory, pro teamLabels = append(teamLabels, label) teamOptions = append(teamOptions, iostreams.PromptOption{ Label: label, - Flag: "team", + 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 != "" {