From c992dba6f3a542d1e443d5eca53ef13c4a6f78f7 Mon Sep 17 00:00:00 2001 From: Mykematt <122421851+Mykematt@users.noreply.github.com> Date: Mon, 17 Nov 2025 17:37:08 -0700 Subject: [PATCH 1/4] Added global --yes and --no-input --- go.mod | 6 +--- go.sum | 18 ----------- internal/io/confirm.go | 44 +++++++++++++++------------ internal/io/prompt.go | 45 ++++++++++++++++++++-------- internal/pipeline/resolver/picker.go | 2 +- pkg/cmd/build/cancel.go | 5 ++-- pkg/cmd/build/new.go | 5 ++-- pkg/cmd/factory/factory.go | 22 ++++++++++++++ pkg/cmd/job/cancel.go | 9 +++--- pkg/cmd/job/cancel_test.go | 10 ++----- pkg/cmd/root/root.go | 16 ++++++++++ pkg/cmd/use/use.go | 6 ++-- pkg/cmd/use/use_test.go | 6 ++-- 13 files changed, 117 insertions(+), 77 deletions(-) diff --git a/go.mod b/go.mod index c6681f60..c5f00813 100644 --- a/go.mod +++ b/go.mod @@ -11,7 +11,6 @@ require ( github.com/buildkite/go-buildkite/v4 v4.10.0 github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 github.com/charmbracelet/bubbletea v1.3.10 - github.com/charmbracelet/huh v0.8.0 github.com/charmbracelet/huh/spinner v0.0.0-20240608175402-5b41f0b45136 github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 github.com/charmbracelet/x/exp/teatest v0.0.0-20231215171016-7ba2b450712d @@ -34,21 +33,18 @@ require ( github.com/alexflint/go-scalar v1.2.0 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/bmatcuk/doublestar/v4 v4.6.1 // indirect - github.com/catppuccin/go v0.3.0 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect github.com/charmbracelet/x/ansi v0.10.1 // indirect github.com/charmbracelet/x/cellbuf v0.0.13 // indirect github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect - github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/creack/pty v1.1.24 // indirect github.com/dlclark/regexp2 v1.11.0 // indirect - github.com/dustin/go-humanize v1.0.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/microcosm-cc/bluemonday v1.0.27 // indirect - github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect diff --git a/go.sum b/go.sum index ee3c32b2..fd1c6476 100644 --- a/go.sum +++ b/go.sum @@ -49,8 +49,6 @@ github.com/bradleyjkemp/cupaloy/v2 v2.6.0 h1:knToPYa2xtfg42U3I6punFEjaGFKWQRXJwj github.com/bradleyjkemp/cupaloy/v2 v2.6.0/go.mod h1:bm7JXdkRd4BHJk9HpwqAI8BoAY1lps46Enkdqw6aRX0= github.com/buildkite/go-buildkite/v4 v4.10.0 h1:U3mYmDNLJqe+703Ztmf23ZA6eE/CnSsWevXyV9o9N4Q= github.com/buildkite/go-buildkite/v4 v4.10.0/go.mod h1:DlebrRJqpZttXDjCW+MJ1QyW9AN++ZWt/UbPtKdbSSk= -github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= -github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4= github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM= github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws= @@ -61,8 +59,6 @@ github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4p github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= github.com/charmbracelet/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V6kXldcY= github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk= -github.com/charmbracelet/huh v0.8.0 h1:Xz/Pm2h64cXQZn/Jvele4J3r7DDiqFCNIVteYukxDvY= -github.com/charmbracelet/huh v0.8.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4= github.com/charmbracelet/huh/spinner v0.0.0-20240608175402-5b41f0b45136 h1:F1GgBu0ArS57Q9l1pJEVWWnpAWsZaeNn2/HAHzqm0eo= github.com/charmbracelet/huh/spinner v0.0.0-20240608175402-5b41f0b45136/go.mod h1:1cf8ar2//4C/JYpK4/EHprHIKQDanErotkH8enQWG6g= github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= @@ -71,24 +67,14 @@ github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7 github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= -github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U= -github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ= -github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= -github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI= github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU= -github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= -github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= github.com/charmbracelet/x/exp/teatest v0.0.0-20231215171016-7ba2b450712d h1:J6mdY8xl7YVGMSbPlqDcg64/J3m7wPuX1OWzPMWW4OA= github.com/charmbracelet/x/exp/teatest v0.0.0-20231215171016-7ba2b450712d/go.mod h1:43J0pdacLjJQtomu7vU6RFZX3bn84toqNw7hjX8bhmM= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= -github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= -github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= -github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI= -github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4= github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= @@ -104,8 +90,6 @@ github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54 h1:SG7nF6SRlWhcT7c github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA= github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= -github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= -github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o= github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= @@ -182,8 +166,6 @@ github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1f github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= -github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= -github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= diff --git a/internal/io/confirm.go b/internal/io/confirm.go index 4af53517..54189a9c 100644 --- a/internal/io/confirm.go +++ b/internal/io/confirm.go @@ -1,30 +1,36 @@ package io import ( - "github.com/charmbracelet/huh" + "fmt" + "strings" ) +// Confirm prompts the user with a yes/no question. +// +// IMPORTANT: If your command uses Confirm(), you MUST call f.SetGlobalFlags(cmd) in PreRunE: +// +// PreRunE: func(cmd *cobra.Command, args []string) error { +// f.SetGlobalFlags(cmd) // Required for --yes and --no-input support +// // ... rest of your logic +// } +// +// Then use factory flags in RunE: +// +// confirmed := f.SkipConfirm // Respects global --yes flag +// io.Confirm(&confirmed, "Do the thing?") +// +// Do NOT add individual --yes flags to commands. Use the global flag pattern above. func Confirm(confirmed *bool, title string) error { if *confirmed { return nil } - form := huh.NewForm( - huh.NewGroup( - huh.NewConfirm(). - Title(title). - Affirmative("Yes"). - Negative("No"). - Value(confirmed), - ), - ) - - err := form.Run() - - // no need to return error if ctrl-c - if err != nil && err == huh.ErrUserAborted { - return nil - } - - return err + fmt.Printf("%s [y/N]: ", title) + var response string + fmt.Scanln(&response) + + response = strings.ToLower(strings.TrimSpace(response)) + *confirmed = response == "y" || response == "yes" + + return nil } diff --git a/internal/io/prompt.go b/internal/io/prompt.go index 8f25ae22..af13bffe 100644 --- a/internal/io/prompt.go +++ b/internal/io/prompt.go @@ -1,6 +1,10 @@ package io -import "github.com/charmbracelet/huh" +import ( + "fmt" + "strconv" + "strings" +) const ( typeOrganizationMessage = "Pick an organization" @@ -9,7 +13,13 @@ const ( // PromptForOne will show the list of options to the user, allowing them to select one to return. // It's possible for them to choose none or cancel the selection, resulting in an error. -func PromptForOne(resource string, options []string) (string, error) { +// If noInput is true, it will fail instead of prompting. +// +// For global flag support requirements, see the Confirm() function documentation. +func PromptForOne(resource string, options []string, noInput bool) (string, error) { + if noInput { + return "", fmt.Errorf("interactive input required but --no-input flag is set") + } var message string switch resource { case "pipeline": @@ -19,14 +29,25 @@ func PromptForOne(resource string, options []string) (string, error) { default: message = "Please select one of the options below" } - selected := new(string) - err := huh.NewForm(huh.NewGroup( - huh.NewSelect[string](). - Title(message). - Options( - huh.NewOptions(options...)..., - ).Value(selected), - ), - ).Run() - return *selected, err + + if len(options) == 0 { + return "", fmt.Errorf("no options available") + } + + fmt.Printf("%s:\n", message) + for i, option := range options { + fmt.Printf(" %d. %s\n", i+1, option) + } + fmt.Printf("Enter number (1-%d): ", len(options)) + + var response string + fmt.Scanln(&response) + + response = strings.TrimSpace(response) + num, err := strconv.Atoi(response) + if err != nil || num < 1 || num > len(options) { + return "", fmt.Errorf("invalid selection") + } + + return options[num-1], nil } diff --git a/internal/pipeline/resolver/picker.go b/internal/pipeline/resolver/picker.go index cfcd33d4..a5939898 100644 --- a/internal/pipeline/resolver/picker.go +++ b/internal/pipeline/resolver/picker.go @@ -32,7 +32,7 @@ func PickOne(pipelines []pipeline.Pipeline) *pipeline.Pipeline { names[i] = p.Name } - chosen, err := io.PromptForOne("pipeline", names) + chosen, err := io.PromptForOne("pipeline", names, false) if err != nil { return nil } diff --git a/pkg/cmd/build/cancel.go b/pkg/cmd/build/cancel.go index 624aa834..1cfdc268 100644 --- a/pkg/cmd/build/cancel.go +++ b/pkg/cmd/build/cancel.go @@ -18,7 +18,6 @@ import ( func NewCmdBuildCancel(f *factory.Factory) *cobra.Command { var web bool var pipeline string - var confirmed bool cmd := cobra.Command{ DisableFlagsInUseLine: true, @@ -29,6 +28,8 @@ func NewCmdBuildCancel(f *factory.Factory) *cobra.Command { Cancel the given build. `), PreRunE: func(cmd *cobra.Command, args []string) error { + f.SetGlobalFlags(cmd) + // Get the command's required and optional scopes cmdScopes := scopes.GetCommandScopes(cmd) @@ -64,6 +65,7 @@ func NewCmdBuildCancel(f *factory.Factory) *cobra.Command { return err } + confirmed := f.SkipConfirm err = bk_io.Confirm(&confirmed, fmt.Sprintf("Cancel build #%d on %s", bld.BuildNumber, bld.Pipeline)) if err != nil { return err @@ -83,7 +85,6 @@ func NewCmdBuildCancel(f *factory.Factory) *cobra.Command { cmd.Flags().BoolVarP(&web, "web", "w", false, "Open the build in a web browser after it has been cancelled.") // Pipeline flag now inherited from parent command - cmd.Flags().BoolVarP(&confirmed, "yes", "y", false, "Skip the confirmation prompt. Useful if being used in automation/CI.") cmd.Flags().SortFlags = false return &cmd diff --git a/pkg/cmd/build/new.go b/pkg/cmd/build/new.go index 0c1c1b8c..b746f046 100644 --- a/pkg/cmd/build/new.go +++ b/pkg/cmd/build/new.go @@ -24,7 +24,6 @@ func NewCmdBuildNew(f *factory.Factory) *cobra.Command { var commit string var message string var pipeline string - var confirmed bool var web bool var ignoreBranchFilters bool var env []string @@ -53,6 +52,8 @@ func NewCmdBuildNew(f *factory.Factory) *cobra.Command { $ bk build new -M "key=value" -M "foo=bar" `), PreRunE: bkErrors.WrapRunE(func(cmd *cobra.Command, args []string) error { + f.SetGlobalFlags(cmd) + // Get the command's required and optional scopes cmdScopes := scopes.GetCommandScopes(cmd) @@ -102,6 +103,7 @@ func NewCmdBuildNew(f *factory.Factory) *cobra.Command { ) } + confirmed := f.SkipConfirm err = bk_io.Confirm(&confirmed, fmt.Sprintf("Create new build on %s?", resolvedPipeline.Name)) if err != nil { return bkErrors.NewUserAbortedError(err, "confirmation canceled") @@ -169,7 +171,6 @@ func NewCmdBuildNew(f *factory.Factory) *cobra.Command { cmd.Flags().StringArrayVarP(&env, "env", "e", []string{}, "Set environment variables for the build") cmd.Flags().StringArrayVarP(&metaData, "metadata", "M", []string{}, "Set metadata for the build (KEY=VALUE)") cmd.Flags().BoolVarP(&ignoreBranchFilters, "ignore-branch-filters", "i", false, "Ignore branch filters for the pipeline") - cmd.Flags().BoolVarP(&confirmed, "yes", "y", false, "Skip the confirmation prompt. Useful if being used in automation/CI") cmd.Flags().StringVarP(&envFile, "env-file", "f", "", "Set the environment variables for the build via an environment file") cmd.Flags().StringVarP(&envFile, "envFile", "", "", "Set the environment variables for the build via an environment file") _ = cmd.Flags().MarkDeprecated("envFile", "use --env-file instead") diff --git a/pkg/cmd/factory/factory.go b/pkg/cmd/factory/factory.go index ec321d34..c8565f7d 100644 --- a/pkg/cmd/factory/factory.go +++ b/pkg/cmd/factory/factory.go @@ -5,6 +5,7 @@ import ( "net/http" "github.com/Khan/genqlient/graphql" + "github.com/spf13/cobra" "github.com/buildkite/cli/v3/internal/config" "github.com/buildkite/cli/v3/internal/version" buildkite "github.com/buildkite/go-buildkite/v4" @@ -19,6 +20,27 @@ type Factory struct { GraphQLClient graphql.Client RestAPIClient *buildkite.Client Version string + SkipConfirm bool + NoInput bool +} + +// SetGlobalFlags reads the global persistent flags and sets them on the factory. +// This should be called in PreRunE of commands that need to use global flags. +// It's safe to call multiple times and will only set flags if they're present. +// +// NOTE: Due to Cobra limitations, global flags must be positioned AFTER at least one subcommand. +// Valid: bk job --yes cancel or bk job cancel --yes +// Invalid: bk --yes job cancel +// +// Once the CLI is fully migrated to Kong (currently in progress), the limitation will be removed +// and global flags will work in any position, including: bk --yes job cancel +func (f *Factory) SetGlobalFlags(cmd *cobra.Command) { + if yes, err := cmd.Flags().GetBool("yes"); err == nil && yes { + f.SkipConfirm = yes + } + if noInput, err := cmd.Flags().GetBool("no-input"); err == nil && noInput { + f.NoInput = noInput + } } type gqlHTTPClient struct { diff --git a/pkg/cmd/job/cancel.go b/pkg/cmd/job/cancel.go index 29b71bf6..16e64ccc 100644 --- a/pkg/cmd/job/cancel.go +++ b/pkg/cmd/job/cancel.go @@ -15,7 +15,6 @@ import ( func NewCmdJobCancel(f *factory.Factory) *cobra.Command { var web bool - var confirmed bool cmd := cobra.Command{ DisableFlagsInUseLine: true, @@ -30,12 +29,14 @@ func NewCmdJobCancel(f *factory.Factory) *cobra.Command { $ bk job cancel 0190046e-e199-453b-a302-a21a4d649d31 # Cancel a job without confirmation (useful for automation) - $ bk job cancel 0190046e-e199-453b-a302-a21a4d649d31 --yes + $ bk job --yes cancel 0190046e-e199-453b-a302-a21a4d649d31 # Cancel a job and open it in browser - $ bk job cancel 0190046e-e199-453b-a302-a21a4d649d31 --yes --web + $ bk job --yes cancel 0190046e-e199-453b-a302-a21a4d649d31 --web `), PreRunE: func(cmd *cobra.Command, args []string) error { + f.SetGlobalFlags(cmd) + cmdScopes := scopes.GetCommandScopes(cmd) tokenScopes := f.Config.GetTokenScopes() if len(tokenScopes) == 0 { @@ -52,6 +53,7 @@ func NewCmdJobCancel(f *factory.Factory) *cobra.Command { jobID := args[0] graphqlID := util.GenerateGraphQLID("JobTypeCommand---", jobID) + confirmed := f.SkipConfirm err := bk_io.Confirm(&confirmed, fmt.Sprintf("Cancel job %s", jobID)) if err != nil { return err @@ -70,7 +72,6 @@ func NewCmdJobCancel(f *factory.Factory) *cobra.Command { } cmd.Flags().BoolVarP(&web, "web", "w", false, "Open the job in a web browser after it has been cancelled.") - cmd.Flags().BoolVarP(&confirmed, "yes", "y", false, "Skip the confirmation prompt. Useful if being used in automation/CI.") cmd.Flags().SortFlags = false return &cmd diff --git a/pkg/cmd/job/cancel_test.go b/pkg/cmd/job/cancel_test.go index ad433b83..e080dee9 100644 --- a/pkg/cmd/job/cancel_test.go +++ b/pkg/cmd/job/cancel_test.go @@ -17,14 +17,8 @@ func TestNewCmdJobCancel(t *testing.T) { t.Errorf("got %s, want Cancel a job.", cmd.Short) } - yesFlag := cmd.Flags().Lookup("yes") - if yesFlag == nil { - t.Error("--yes flag should exist") - } else { - if yesFlag.Shorthand != "y" { - t.Errorf("--yes flag shorthand should be 'y', got '%s'", yesFlag.Shorthand) - } - } + // --yes flag is now a global persistent flag, not a local flag + // so we don't test for it here anymore webFlag := cmd.Flags().Lookup("web") if webFlag == nil { diff --git a/pkg/cmd/root/root.go b/pkg/cmd/root/root.go index 5a4b8f99..6ec8f93e 100644 --- a/pkg/cmd/root/root.go +++ b/pkg/cmd/root/root.go @@ -25,12 +25,15 @@ import ( func NewCmdRoot(f *factory.Factory) (*cobra.Command, error) { var verbose bool + var skipConfirm bool + var noInput bool cmd := &cobra.Command{ Use: "bk [flags]", Short: "Buildkite CLI", Long: "Work with Buildkite from the command line.", SilenceUsage: true, + TraverseChildren: true, Example: heredoc.Doc(` $ bk build view `), @@ -71,6 +74,19 @@ func NewCmdRoot(f *factory.Factory) (*cobra.Command, error) { cmd.Flags().BoolP("version", "v", false, "Print the version number") cmd.PersistentFlags().BoolVarP(&verbose, "verbose", "V", false, "Enable verbose error output") + + // Global flags for automation and scripting + // NOTE: Due to Cobra, these must come AFTER a subcommand (e.g., 'bk job --yes cancel') + // Once migrated to Kong, they'll work anywhere (e.g., 'bk --yes job cancel') + cmd.PersistentFlags().BoolVarP(&skipConfirm, "yes", "y", false, "Skip all confirmation prompts (useful for automation)") + cmd.PersistentFlags().BoolVar(&noInput, "no-input", false, "Disable all interactive prompts (fail if input is required)") + + // Set factory flags before any command runs + cmd.PersistentPreRunE = func(c *cobra.Command, args []string) error { + f.SkipConfirm = skipConfirm + f.NoInput = noInput + return nil + } return cmd, nil } diff --git a/pkg/cmd/use/use.go b/pkg/cmd/use/use.go index 23fb281e..c86cb44f 100644 --- a/pkg/cmd/use/use.go +++ b/pkg/cmd/use/use.go @@ -21,20 +21,20 @@ func NewCmdUse(f *factory.Factory) *cobra.Command { if len(args) > 0 { org = &args[0] } - return useRun(org, f.Config, f.GitRepository != nil) + return useRun(org, f.Config, f.GitRepository != nil, f.NoInput) }, } return cmd } -func useRun(org *string, conf *config.Config, inGitRepo bool) error { +func useRun(org *string, conf *config.Config, inGitRepo bool, noInput bool) error { var selected string // prompt to choose from configured orgs if one is not already selected if org == nil { var err error - selected, err = io.PromptForOne("organization", conf.ConfiguredOrganizations()) + selected, err = io.PromptForOne("organization", conf.ConfiguredOrganizations(), noInput) if err != nil { return err } diff --git a/pkg/cmd/use/use_test.go b/pkg/cmd/use/use_test.go index 28c32ee6..52e93bf4 100644 --- a/pkg/cmd/use/use_test.go +++ b/pkg/cmd/use/use_test.go @@ -15,7 +15,7 @@ func TestCmdUse(t *testing.T) { conf := config.New(afero.NewMemMapFs(), nil) conf.SelectOrganization("testing", true) selected := "testing" - err := useRun(&selected, conf, true) + err := useRun(&selected, conf, true, false) if err != nil { t.Error("expected no error") } @@ -36,7 +36,7 @@ func TestCmdUse(t *testing.T) { // now get a new empty config conf = config.New(fs, nil) selected := "testing" - err := useRun(&selected, conf, true) + err := useRun(&selected, conf, true, false) if err != nil { t.Errorf("expected no error: %s", err) } @@ -49,7 +49,7 @@ func TestCmdUse(t *testing.T) { t.Parallel() selected := "testing" conf := config.New(afero.NewMemMapFs(), nil) - err := useRun(&selected, conf, true) + err := useRun(&selected, conf, true, false) if err == nil { t.Error("expected an error") } From 0c377ef7262a4118813dcb30f3e115971e6d3818 Mon Sep 17 00:00:00 2001 From: Mykematt <122421851+Mykematt@users.noreply.github.com> Date: Mon, 17 Nov 2025 17:52:24 -0700 Subject: [PATCH 2/4] Fix lint errors --- internal/io/confirm.go | 6 +++--- internal/io/prompt.go | 2 +- pkg/cmd/factory/factory.go | 2 +- pkg/cmd/root/root.go | 10 +++++----- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/internal/io/confirm.go b/internal/io/confirm.go index 54189a9c..304c0691 100644 --- a/internal/io/confirm.go +++ b/internal/io/confirm.go @@ -27,10 +27,10 @@ func Confirm(confirmed *bool, title string) error { fmt.Printf("%s [y/N]: ", title) var response string - fmt.Scanln(&response) - + _, _ = fmt.Scanln(&response) + response = strings.ToLower(strings.TrimSpace(response)) *confirmed = response == "y" || response == "yes" - + return nil } diff --git a/internal/io/prompt.go b/internal/io/prompt.go index af13bffe..413e9c2f 100644 --- a/internal/io/prompt.go +++ b/internal/io/prompt.go @@ -41,7 +41,7 @@ func PromptForOne(resource string, options []string, noInput bool) (string, erro fmt.Printf("Enter number (1-%d): ", len(options)) var response string - fmt.Scanln(&response) + _, _ = fmt.Scanln(&response) response = strings.TrimSpace(response) num, err := strconv.Atoi(response) diff --git a/pkg/cmd/factory/factory.go b/pkg/cmd/factory/factory.go index c8565f7d..cbcdd811 100644 --- a/pkg/cmd/factory/factory.go +++ b/pkg/cmd/factory/factory.go @@ -5,11 +5,11 @@ import ( "net/http" "github.com/Khan/genqlient/graphql" - "github.com/spf13/cobra" "github.com/buildkite/cli/v3/internal/config" "github.com/buildkite/cli/v3/internal/version" buildkite "github.com/buildkite/go-buildkite/v4" git "github.com/go-git/go-git/v5" + "github.com/spf13/cobra" ) var userAgent string diff --git a/pkg/cmd/root/root.go b/pkg/cmd/root/root.go index 6ec8f93e..662ac2a6 100644 --- a/pkg/cmd/root/root.go +++ b/pkg/cmd/root/root.go @@ -29,10 +29,10 @@ func NewCmdRoot(f *factory.Factory) (*cobra.Command, error) { var noInput bool cmd := &cobra.Command{ - Use: "bk [flags]", - Short: "Buildkite CLI", - Long: "Work with Buildkite from the command line.", - SilenceUsage: true, + Use: "bk [flags]", + Short: "Buildkite CLI", + Long: "Work with Buildkite from the command line.", + SilenceUsage: true, TraverseChildren: true, Example: heredoc.Doc(` $ bk build view @@ -74,7 +74,7 @@ func NewCmdRoot(f *factory.Factory) (*cobra.Command, error) { cmd.Flags().BoolP("version", "v", false, "Print the version number") cmd.PersistentFlags().BoolVarP(&verbose, "verbose", "V", false, "Enable verbose error output") - + // Global flags for automation and scripting // NOTE: Due to Cobra, these must come AFTER a subcommand (e.g., 'bk job --yes cancel') // Once migrated to Kong, they'll work anywhere (e.g., 'bk --yes job cancel') From 1213cde48c2fe003301d52d70f2e3ff121b4ab04 Mon Sep 17 00:00:00 2001 From: Mykematt <122421851+Mykematt@users.noreply.github.com> Date: Mon, 17 Nov 2025 18:19:36 -0700 Subject: [PATCH 3/4] Replaced fmt.Scanln with x/term; functionality remains the same --- internal/io/confirm.go | 9 ++++--- internal/io/prompt.go | 9 +++---- internal/io/readline.go | 60 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 70 insertions(+), 8 deletions(-) create mode 100644 internal/io/readline.go diff --git a/internal/io/confirm.go b/internal/io/confirm.go index 304c0691..378ef9c8 100644 --- a/internal/io/confirm.go +++ b/internal/io/confirm.go @@ -26,10 +26,13 @@ func Confirm(confirmed *bool, title string) error { } fmt.Printf("%s [y/N]: ", title) - var response string - _, _ = fmt.Scanln(&response) + + response, err := ReadLine() + if err != nil { + return err + } - response = strings.ToLower(strings.TrimSpace(response)) + response = strings.ToLower(response) *confirmed = response == "y" || response == "yes" return nil diff --git a/internal/io/prompt.go b/internal/io/prompt.go index 413e9c2f..7ead92af 100644 --- a/internal/io/prompt.go +++ b/internal/io/prompt.go @@ -3,7 +3,6 @@ package io import ( "fmt" "strconv" - "strings" ) const ( @@ -40,10 +39,10 @@ func PromptForOne(resource string, options []string, noInput bool) (string, erro } fmt.Printf("Enter number (1-%d): ", len(options)) - var response string - _, _ = fmt.Scanln(&response) - - response = strings.TrimSpace(response) + response, err := ReadLine() + if err != nil { + return "", err + } num, err := strconv.Atoi(response) if err != nil || num < 1 || num > len(options) { return "", fmt.Errorf("invalid selection") diff --git a/internal/io/readline.go b/internal/io/readline.go new file mode 100644 index 00000000..8b504676 --- /dev/null +++ b/internal/io/readline.go @@ -0,0 +1,60 @@ +package io + +import ( + "bufio" + "os" + "strings" + "syscall" + + "golang.org/x/term" +) + +// ReadLine reads a line of input from stdin with terminal support. +// If running in a TTY, it uses x/term for better line editing (backspace, arrows, etc.). +// If not in a TTY (e.g., piped input), it falls back to bufio. +func ReadLine() (string, error) { + fd := int(os.Stdin.Fd()) + + // Check if we're in a TTY + if !term.IsTerminal(fd) { + // Not a TTY, use simple bufio + reader := bufio.NewReader(os.Stdin) + line, err := reader.ReadString('\n') + if err != nil { + return "", err + } + return strings.TrimSpace(line), nil + } + + // TTY - use x/term for better editing + oldState, err := term.MakeRaw(fd) + if err != nil { + // Fallback to bufio if raw mode fails + reader := bufio.NewReader(os.Stdin) + line, err := reader.ReadString('\n') + if err != nil { + return "", err + } + return strings.TrimSpace(line), nil + } + + terminal := term.NewTerminal(os.Stdin, "") + line, err := terminal.ReadLine() + _ = term.Restore(fd, oldState) + + if err != nil { + return "", err + } + + return strings.TrimSpace(line), nil +} + +// ReadPassword reads a password from stdin without echoing. +// This is a convenience wrapper around term.ReadPassword. +func ReadPassword() (string, error) { + passwordBytes, err := term.ReadPassword(int(syscall.Stdin)) + if err != nil { + return "", err + } + return string(passwordBytes), nil +} From 97362a3abf7ca176b74d4adffa251c72537c0842 Mon Sep 17 00:00:00 2001 From: Mykematt <122421851+Mykematt@users.noreply.github.com> Date: Mon, 17 Nov 2025 18:33:34 -0700 Subject: [PATCH 4/4] Address review: improve Confirm signature ergonomics --- internal/io/confirm.go | 46 +++++++++++++++++++++----------------- pkg/cmd/build/cancel.go | 7 +++--- pkg/cmd/build/list.go | 4 ++-- pkg/cmd/build/list_test.go | 13 +++++------ pkg/cmd/build/new.go | 3 +-- pkg/cmd/job/cancel.go | 7 +++--- 6 files changed, 40 insertions(+), 40 deletions(-) diff --git a/internal/io/confirm.go b/internal/io/confirm.go index 378ef9c8..8f492c87 100644 --- a/internal/io/confirm.go +++ b/internal/io/confirm.go @@ -3,37 +3,43 @@ package io import ( "fmt" "strings" + + "github.com/buildkite/cli/v3/pkg/cmd/factory" ) // Confirm prompts the user with a yes/no question. +// Returns true if the user confirmed, false otherwise. // -// IMPORTANT: If your command uses Confirm(), you MUST call f.SetGlobalFlags(cmd) in PreRunE: -// -// PreRunE: func(cmd *cobra.Command, args []string) error { -// f.SetGlobalFlags(cmd) // Required for --yes and --no-input support -// // ... rest of your logic -// } +// IMPORTANT: Commands using Confirm() must call f.SetGlobalFlags(cmd) in PreRunE. +// See factory.SetGlobalFlags() documentation for details. // -// Then use factory flags in RunE: +// Usage: // -// confirmed := f.SkipConfirm // Respects global --yes flag -// io.Confirm(&confirmed, "Do the thing?") -// -// Do NOT add individual --yes flags to commands. Use the global flag pattern above. -func Confirm(confirmed *bool, title string) error { - if *confirmed { - return nil +// confirmed, err := io.Confirm(f, "Do the thing?") +// if err != nil { +// return err +// } +// if confirmed { +// // do the thing +// } +func Confirm(f *factory.Factory, prompt string) (bool, error) { + // Check if --yes flag is set + if f.SkipConfirm { + return true, nil + } + + // Check if --no-input flag is set + if f.NoInput { + return false, fmt.Errorf("interactive input required but --no-input is set") } - fmt.Printf("%s [y/N]: ", title) - + fmt.Printf("%s [y/N]: ", prompt) + response, err := ReadLine() if err != nil { - return err + return false, err } response = strings.ToLower(response) - *confirmed = response == "y" || response == "yes" - - return nil + return response == "y" || response == "yes", nil } diff --git a/pkg/cmd/build/cancel.go b/pkg/cmd/build/cancel.go index 1cfdc268..51836779 100644 --- a/pkg/cmd/build/cancel.go +++ b/pkg/cmd/build/cancel.go @@ -65,17 +65,16 @@ func NewCmdBuildCancel(f *factory.Factory) *cobra.Command { return err } - confirmed := f.SkipConfirm - err = bk_io.Confirm(&confirmed, fmt.Sprintf("Cancel build #%d on %s", bld.BuildNumber, bld.Pipeline)) + confirmed, err := bk_io.Confirm(f, fmt.Sprintf("Cancel build #%d on %s", bld.BuildNumber, bld.Pipeline)) if err != nil { return err } if confirmed { return cancelBuild(cmd.Context(), bld.Organization, bld.Pipeline, fmt.Sprint(bld.BuildNumber), web, f) - } else { - return nil } + + return nil }, } diff --git a/pkg/cmd/build/list.go b/pkg/cmd/build/list.go index dd86290b..8afd87ec 100644 --- a/pkg/cmd/build/list.go +++ b/pkg/cmd/build/list.go @@ -298,7 +298,6 @@ func fetchBuilds(cmd *cobra.Command, f *factory.Factory, org string, opts buildL spinnerMsg += ")" if format == output.FormatText && rawSinceConfirm >= maxBuildLimit { - var confirmed bool prompt := fmt.Sprintf("Fetched %d more builds (%d total). Continue?", rawSinceConfirm, rawTotalFetched) if filtersActive { prompt = fmt.Sprintf( @@ -307,7 +306,8 @@ func fetchBuilds(cmd *cobra.Command, f *factory.Factory, org string, opts buildL ) } - if err := ConfirmFunc(&confirmed, prompt); err != nil { + confirmed, err := ConfirmFunc(f, prompt) + if err != nil { return nil, err } diff --git a/pkg/cmd/build/list_test.go b/pkg/cmd/build/list_test.go index 89ed4df5..39bcc99b 100644 --- a/pkg/cmd/build/list_test.go +++ b/pkg/cmd/build/list_test.go @@ -225,10 +225,9 @@ func TestFetchBuildsConfirmationDeclineFirst(t *testing.T) { confirmCalls := 0 origConfirm := ConfirmFunc - ConfirmFunc = func(confirmed *bool, title string) error { + ConfirmFunc = func(f *factory.Factory, prompt string) (bool, error) { confirmCalls++ - *confirmed = false - return nil + return false, nil } t.Cleanup(func() { ConfirmFunc = origConfirm }) @@ -257,14 +256,12 @@ func TestFetchBuildsConfirmationAcceptThenDecline(t *testing.T) { confirmCalls := 0 origConfirm := ConfirmFunc - ConfirmFunc = func(confirmed *bool, title string) error { + ConfirmFunc = func(f *factory.Factory, prompt string) (bool, error) { confirmCalls++ if confirmCalls == 1 { - *confirmed = true - } else { - *confirmed = false + return true, nil } - return nil + return false, nil } t.Cleanup(func() { ConfirmFunc = origConfirm }) diff --git a/pkg/cmd/build/new.go b/pkg/cmd/build/new.go index b746f046..289a51db 100644 --- a/pkg/cmd/build/new.go +++ b/pkg/cmd/build/new.go @@ -103,8 +103,7 @@ func NewCmdBuildNew(f *factory.Factory) *cobra.Command { ) } - confirmed := f.SkipConfirm - err = bk_io.Confirm(&confirmed, fmt.Sprintf("Create new build on %s?", resolvedPipeline.Name)) + confirmed, err := bk_io.Confirm(f, fmt.Sprintf("Create new build on %s?", resolvedPipeline.Name)) if err != nil { return bkErrors.NewUserAbortedError(err, "confirmation canceled") } diff --git a/pkg/cmd/job/cancel.go b/pkg/cmd/job/cancel.go index 16e64ccc..2e67a8af 100644 --- a/pkg/cmd/job/cancel.go +++ b/pkg/cmd/job/cancel.go @@ -53,17 +53,16 @@ func NewCmdJobCancel(f *factory.Factory) *cobra.Command { jobID := args[0] graphqlID := util.GenerateGraphQLID("JobTypeCommand---", jobID) - confirmed := f.SkipConfirm - err := bk_io.Confirm(&confirmed, fmt.Sprintf("Cancel job %s", jobID)) + confirmed, err := bk_io.Confirm(f, fmt.Sprintf("Cancel job %s", jobID)) if err != nil { return err } if confirmed { return cancelJob(cmd.Context(), graphqlID, web, f) - } else { - return nil } + + return nil }, }