-
Notifications
You must be signed in to change notification settings - Fork 34
feat: agent and scripting friendly non-TTY prompt errors #587
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
63dc852
4cd8f42
5c185fb
e1e48ef
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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,14 +83,20 @@ 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 | ||
| 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 { | ||
|
Comment on lines
+197
to
+202
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. note: This is the meat and potatoes of the PR 🥔 🍠 It handles rendering the error message, which now support displaying the original prompt that should have been shown. |
||
| 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 | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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) | ||
|
Comment on lines
+604
to
+616
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. note: This is the migration of the App Select prompt. We have 36 total call sites, so this gives you an idea of what a straight-forward migration will look like. I've scoped this PR to just migrate 2 call sites to allow us to focus on the design and infrastructure changes. |
||
| } | ||
| 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 | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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, | ||
| }) | ||
|
Comment on lines
+53
to
+63
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. note: This is the migration of the Team Select prompt. We have 36 total call sites, so this gives you an idea of what a straight-forward migration will look like. I've scoped this PR to just migrate 2 call sites to allow us to focus on the design and infrastructure changes. |
||
| } | ||
|
|
||
| selectPromptConfig := iostreams.SelectPromptConfig{ | ||
| Required: true, | ||
| Flag: clients.Config.Flags.Lookup("team"), | ||
| Flag: teamFlag, | ||
| Options: teamOptions, | ||
| } | ||
| if promptConfig != nil && promptConfig.HelpText != "" { | ||
| selectPromptConfig.Help = promptConfig.HelpText | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
note: New structs to allow us to pass flag names and values that correspond to each option. For now, I've kept it simple with just 1 flag. If we discover prompts that require multiple flags (e.g.
--template <url> --subdir <path>then we can refactor later. For now, I'm leaning toward setting up the essentials to keep it readable.