From bc494fa6f3ae37ca922b5cdc092c2afbb92fe9a9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Feb 2026 14:09:31 +0000 Subject: [PATCH 1/2] Initial plan From 417c5f799f2d1a43fa9b9dcec303694a732a8ab1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Feb 2026 14:26:06 +0000 Subject: [PATCH 2/2] Add Huh forms helpers and refactor secret input (Priority 1 complete) Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/cli/secret_set_command.go | 41 ++++++--- pkg/console/form.go | 130 ++++++++++++++++++++++++++ pkg/console/form_test.go | 169 ++++++++++++++++++++++++++++++++++ pkg/console/input.go | 97 +++++++++++++++++++ pkg/console/input_test.go | 78 ++++++++++++++++ pkg/console/select.go | 95 +++++++++++++++++++ pkg/console/select_test.go | 87 +++++++++++++++++ 7 files changed, 682 insertions(+), 15 deletions(-) create mode 100644 pkg/console/form.go create mode 100644 pkg/console/form_test.go create mode 100644 pkg/console/input.go create mode 100644 pkg/console/input_test.go create mode 100644 pkg/console/select.go create mode 100644 pkg/console/select_test.go diff --git a/pkg/cli/secret_set_command.go b/pkg/cli/secret_set_command.go index 8463873d07..284ba28b6e 100644 --- a/pkg/cli/secret_set_command.go +++ b/pkg/cli/secret_set_command.go @@ -1,7 +1,6 @@ package cli import ( - "bufio" "crypto/rand" "encoding/base64" "encoding/json" @@ -14,6 +13,7 @@ import ( "github.com/cli/go-gh/v2/pkg/api" "github.com/github/gh-aw/pkg/console" "github.com/github/gh-aw/pkg/logger" + "github.com/github/gh-aw/pkg/tty" "github.com/spf13/cobra" "golang.org/x/crypto/nacl/box" ) @@ -139,30 +139,41 @@ func resolveSecretValueForSet(fromEnv, fromFlag string) (string, error) { return fromFlag, nil } + // Check if stdin is connected to a terminal (interactive mode) info, err := os.Stdin.Stat() if err != nil { return "", err } - if info.Mode()&os.ModeCharDevice != 0 { - fmt.Fprintln(os.Stderr, "Enter secret value, then press Ctrl+D:") - } - - reader := bufio.NewReader(os.Stdin) - var b strings.Builder + isTerminal := (info.Mode() & os.ModeCharDevice) != 0 - for { - line, err := reader.ReadString('\n') - b.WriteString(line) + // If we're in an interactive terminal, use Huh for a better UX with password masking + if isTerminal && tty.IsStderrTerminal() { + secretSetLog.Print("Using interactive password prompt with Huh") + value, err := console.PromptSecretInput( + "Enter secret value", + "The value will be encrypted and stored in the repository", + ) if err != nil { - if errors.Is(err, io.EOF) { - break - } - return "", err + secretSetLog.Printf("Interactive prompt failed: %v", err) + return "", fmt.Errorf("failed to read secret value: %w", err) } + return value, nil + } + + // Fallback to non-interactive stdin reading (piped input or non-TTY) + secretSetLog.Print("Using non-interactive stdin reading") + if isTerminal { + fmt.Fprintln(os.Stderr, "Enter secret value, then press Ctrl+D:") + } + + reader := io.Reader(os.Stdin) + data, err := io.ReadAll(reader) + if err != nil { + return "", err } - value := strings.TrimRight(b.String(), "\r\n") + value := strings.TrimRight(string(data), "\r\n") if value == "" { return "", errors.New("secret value is empty") } diff --git a/pkg/console/form.go b/pkg/console/form.go new file mode 100644 index 0000000000..2330f8b7cc --- /dev/null +++ b/pkg/console/form.go @@ -0,0 +1,130 @@ +package console + +import ( + "fmt" + + "github.com/charmbracelet/huh" + "github.com/github/gh-aw/pkg/tty" +) + +// FormField represents a generic form field configuration +type FormField struct { + Type string // "input", "password", "confirm", "select" + Title string + Description string + Placeholder string + Value any // Pointer to the value to store the result + Options []SelectOption // For select fields + Validate func(string) error // For input/password fields +} + +// RunForm executes a multi-field form with validation +// This is a higher-level helper that creates a form with multiple fields +func RunForm(fields []FormField) error { + // Validate inputs first before checking TTY + if len(fields) == 0 { + return fmt.Errorf("no form fields provided") + } + + // Validate field configurations before checking TTY + for _, field := range fields { + if field.Type == "select" && len(field.Options) == 0 { + return fmt.Errorf("select field '%s' requires options", field.Title) + } + if field.Type != "input" && field.Type != "password" && field.Type != "confirm" && field.Type != "select" { + return fmt.Errorf("unknown field type: %s", field.Type) + } + } + + // Check if stdin is a TTY - if not, we can't show interactive forms + if !tty.IsStderrTerminal() { + return fmt.Errorf("interactive forms not available (not a TTY)") + } + + // Build form fields + var huhFields []huh.Field + for _, field := range fields { + switch field.Type { + case "input": + inputField := huh.NewInput(). + Title(field.Title). + Description(field.Description). + Placeholder(field.Placeholder) + + if field.Validate != nil { + inputField.Validate(field.Validate) + } + + // Type assert to *string + if strPtr, ok := field.Value.(*string); ok { + inputField.Value(strPtr) + } else { + return fmt.Errorf("input field '%s' requires *string value", field.Title) + } + + huhFields = append(huhFields, inputField) + + case "password": + passwordField := huh.NewInput(). + Title(field.Title). + Description(field.Description). + EchoMode(huh.EchoModePassword) + + if field.Validate != nil { + passwordField.Validate(field.Validate) + } + + // Type assert to *string + if strPtr, ok := field.Value.(*string); ok { + passwordField.Value(strPtr) + } else { + return fmt.Errorf("password field '%s' requires *string value", field.Title) + } + + huhFields = append(huhFields, passwordField) + + case "confirm": + confirmField := huh.NewConfirm(). + Title(field.Title) + + // Type assert to *bool + if boolPtr, ok := field.Value.(*bool); ok { + confirmField.Value(boolPtr) + } else { + return fmt.Errorf("confirm field '%s' requires *bool value", field.Title) + } + + huhFields = append(huhFields, confirmField) + + case "select": + selectField := huh.NewSelect[string](). + Title(field.Title). + Description(field.Description) + + // Convert options to huh.Option format + huhOptions := make([]huh.Option[string], len(field.Options)) + for i, opt := range field.Options { + huhOptions[i] = huh.NewOption(opt.Label, opt.Value) + } + selectField.Options(huhOptions...) + + // Type assert to *string + if strPtr, ok := field.Value.(*string); ok { + selectField.Value(strPtr) + } else { + return fmt.Errorf("select field '%s' requires *string value", field.Title) + } + + huhFields = append(huhFields, selectField) + + default: + } + } + + // Create and run the form + form := huh.NewForm( + huh.NewGroup(huhFields...), + ).WithAccessible(IsAccessibleMode()) + + return form.Run() +} diff --git a/pkg/console/form_test.go b/pkg/console/form_test.go new file mode 100644 index 0000000000..65216de476 --- /dev/null +++ b/pkg/console/form_test.go @@ -0,0 +1,169 @@ +//go:build !integration + +package console + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRunForm(t *testing.T) { + t.Run("function signature", func(t *testing.T) { + // Verify the function exists and has the right signature + _ = RunForm + }) + + t.Run("requires fields", func(t *testing.T) { + fields := []FormField{} + + err := RunForm(fields) + require.Error(t, err, "Should error with no fields") + assert.Contains(t, err.Error(), "no form fields", "Error should mention missing fields") + }) + + t.Run("validates input field", func(t *testing.T) { + var name string + fields := []FormField{ + { + Type: "input", + Title: "Name", + Description: "Enter your name", + Value: &name, + }, + } + + err := RunForm(fields) + // Will error in test environment (no TTY), but that's expected + require.Error(t, err, "Should error when not in TTY") + assert.Contains(t, err.Error(), "not a TTY", "Error should mention TTY") + }) + + t.Run("validates password field", func(t *testing.T) { + var password string + fields := []FormField{ + { + Type: "password", + Title: "Password", + Description: "Enter password", + Value: &password, + }, + } + + err := RunForm(fields) + // Will error in test environment (no TTY), but that's expected + require.Error(t, err, "Should error when not in TTY") + assert.Contains(t, err.Error(), "not a TTY", "Error should mention TTY") + }) + + t.Run("validates confirm field", func(t *testing.T) { + var confirmed bool + fields := []FormField{ + { + Type: "confirm", + Title: "Confirm action", + Value: &confirmed, + }, + } + + err := RunForm(fields) + // Will error in test environment (no TTY), but that's expected + require.Error(t, err, "Should error when not in TTY") + assert.Contains(t, err.Error(), "not a TTY", "Error should mention TTY") + }) + + t.Run("validates select field with options", func(t *testing.T) { + var selected string + fields := []FormField{ + { + Type: "select", + Title: "Choose option", + Description: "Select one", + Value: &selected, + Options: []SelectOption{ + {Label: "Option 1", Value: "opt1"}, + {Label: "Option 2", Value: "opt2"}, + }, + }, + } + + err := RunForm(fields) + // Will error in test environment (no TTY), but that's expected + require.Error(t, err, "Should error when not in TTY") + assert.Contains(t, err.Error(), "not a TTY", "Error should mention TTY") + }) + + t.Run("rejects select field without options", func(t *testing.T) { + var selected string + fields := []FormField{ + { + Type: "select", + Title: "Choose option", + Value: &selected, + Options: []SelectOption{}, + }, + } + + err := RunForm(fields) + require.Error(t, err, "Should error with no options") + assert.Contains(t, err.Error(), "requires options", "Error should mention missing options") + }) + + t.Run("rejects unknown field type", func(t *testing.T) { + var value string + fields := []FormField{ + { + Type: "unknown", + Title: "Test", + Value: &value, + }, + } + + err := RunForm(fields) + require.Error(t, err, "Should error with unknown field type") + assert.Contains(t, err.Error(), "unknown field type", "Error should mention unknown type") + }) + + t.Run("validates input field with custom validator", func(t *testing.T) { + var name string + fields := []FormField{ + { + Type: "input", + Title: "Name", + Description: "Enter your name", + Value: &name, + Validate: func(s string) error { + if len(s) < 3 { + return fmt.Errorf("must be at least 3 characters") + } + return nil + }, + }, + } + + err := RunForm(fields) + // Will error in test environment (no TTY), but that's expected + require.Error(t, err, "Should error when not in TTY") + assert.Contains(t, err.Error(), "not a TTY", "Error should mention TTY") + }) +} + +func TestFormField(t *testing.T) { + t.Run("struct creation", func(t *testing.T) { + var value string + field := FormField{ + Type: "input", + Title: "Test Field", + Description: "Test Description", + Placeholder: "Enter value", + Value: &value, + } + + assert.Equal(t, "input", field.Type, "Type should match") + assert.Equal(t, "Test Field", field.Title, "Title should match") + assert.Equal(t, "Test Description", field.Description, "Description should match") + assert.Equal(t, "Enter value", field.Placeholder, "Placeholder should match") + }) +} diff --git a/pkg/console/input.go b/pkg/console/input.go new file mode 100644 index 0000000000..bcab4b9e7f --- /dev/null +++ b/pkg/console/input.go @@ -0,0 +1,97 @@ +package console + +import ( + "fmt" + + "github.com/charmbracelet/huh" + "github.com/github/gh-aw/pkg/tty" +) + +// PromptInput shows an interactive text input prompt using Bubble Tea (huh) +// Returns the entered text or an error +func PromptInput(title, description, placeholder string) (string, error) { + // Check if stdin is a TTY - if not, we can't show interactive forms + if !tty.IsStderrTerminal() { + return "", fmt.Errorf("interactive input not available (not a TTY)") + } + + var value string + + form := huh.NewForm( + huh.NewGroup( + huh.NewInput(). + Title(title). + Description(description). + Placeholder(placeholder). + Value(&value), + ), + ).WithAccessible(IsAccessibleMode()) + + if err := form.Run(); err != nil { + return "", err + } + + return value, nil +} + +// PromptSecretInput shows an interactive password input prompt with masking +// The input is masked for security and includes validation +// Returns the entered secret value or an error +func PromptSecretInput(title, description string) (string, error) { + // Check if stdin is a TTY - if not, we can't show interactive forms + if !tty.IsStderrTerminal() { + return "", fmt.Errorf("interactive input not available (not a TTY)") + } + + var value string + + form := huh.NewForm( + huh.NewGroup( + huh.NewInput(). + Title(title). + Description(description). + EchoMode(huh.EchoModePassword). // Masks input for security + Validate(func(s string) error { + if len(s) == 0 { + return fmt.Errorf("value cannot be empty") + } + return nil + }). + Value(&value), + ), + ).WithAccessible(IsAccessibleMode()) + + if err := form.Run(); err != nil { + return "", err + } + + return value, nil +} + +// PromptInputWithValidation shows an interactive text input with custom validation +// Returns the entered text or an error +func PromptInputWithValidation(title, description, placeholder string, validate func(string) error) (string, error) { + // Check if stdin is a TTY - if not, we can't show interactive forms + if !tty.IsStderrTerminal() { + return "", fmt.Errorf("interactive input not available (not a TTY)") + } + + var value string + + form := huh.NewForm( + huh.NewGroup( + huh.NewInput(). + Title(title). + Description(description). + Placeholder(placeholder). + Validate(validate). + Value(&value), + ), + ).WithAccessible(IsAccessibleMode()) + + if err := form.Run(); err != nil { + return "", err + } + + return value, nil +} diff --git a/pkg/console/input_test.go b/pkg/console/input_test.go new file mode 100644 index 0000000000..9039be49ba --- /dev/null +++ b/pkg/console/input_test.go @@ -0,0 +1,78 @@ +//go:build !integration + +package console + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPromptInput(t *testing.T) { + // Note: Interactive Huh forms cannot be fully tested without a mock terminal + // These tests verify function signatures and basic setup + + t.Run("function signature", func(t *testing.T) { + // Verify the function exists and has the right signature + _ = PromptInput + }) + + t.Run("validates parameters", func(t *testing.T) { + // Test that empty title and description don't cause panics + // In a real terminal, this would show a prompt with empty fields + title := "Test Title" + description := "Test Description" + placeholder := "Enter value" + + // Function exists and parameters are accepted + _, err := PromptInput(title, description, placeholder) + // Will error in test environment (no TTY), but that's expected + require.Error(t, err, "Should error when not in TTY") + assert.Contains(t, err.Error(), "not a TTY", "Error should mention TTY") + }) +} + +func TestPromptSecretInput(t *testing.T) { + t.Run("function signature", func(t *testing.T) { + // Verify the function exists and has the right signature + _ = PromptSecretInput + }) + + t.Run("validates parameters", func(t *testing.T) { + title := "Enter Secret" + description := "Secret value will be masked" + + // Function exists and parameters are accepted + _, err := PromptSecretInput(title, description) + // Will error in test environment (no TTY), but that's expected + require.Error(t, err, "Should error when not in TTY") + assert.Contains(t, err.Error(), "not a TTY", "Error should mention TTY") + }) +} + +func TestPromptInputWithValidation(t *testing.T) { + t.Run("function signature", func(t *testing.T) { + // Verify the function exists and has the right signature + _ = PromptInputWithValidation + }) + + t.Run("accepts custom validator", func(t *testing.T) { + title := "Test Title" + description := "Test Description" + placeholder := "Enter value" + validator := func(s string) error { + if len(s) < 3 { + return fmt.Errorf("must be at least 3 characters") + } + return nil + } + + // Function exists and parameters are accepted + _, err := PromptInputWithValidation(title, description, placeholder, validator) + // Will error in test environment (no TTY), but that's expected + require.Error(t, err, "Should error when not in TTY") + assert.Contains(t, err.Error(), "not a TTY", "Error should mention TTY") + }) +} diff --git a/pkg/console/select.go b/pkg/console/select.go new file mode 100644 index 0000000000..96cabebc7b --- /dev/null +++ b/pkg/console/select.go @@ -0,0 +1,95 @@ +package console + +import ( + "fmt" + + "github.com/charmbracelet/huh" + "github.com/github/gh-aw/pkg/tty" +) + +// SelectOption represents a selectable option with a label and value +type SelectOption struct { + Label string + Value string +} + +// PromptSelect shows an interactive single-select menu +// Returns the selected value or an error +func PromptSelect(title, description string, options []SelectOption) (string, error) { + // Validate inputs first + if len(options) == 0 { + return "", fmt.Errorf("no options provided") + } + + // Check if stdin is a TTY - if not, we can't show interactive forms + if !tty.IsStderrTerminal() { + return "", fmt.Errorf("interactive selection not available (not a TTY)") + } + + var selected string + + // Convert options to huh.Option format + huhOptions := make([]huh.Option[string], len(options)) + for i, opt := range options { + huhOptions[i] = huh.NewOption(opt.Label, opt.Value) + } + + form := huh.NewForm( + huh.NewGroup( + huh.NewSelect[string](). + Title(title). + Description(description). + Options(huhOptions...). + Value(&selected), + ), + ).WithAccessible(IsAccessibleMode()) + + if err := form.Run(); err != nil { + return "", err + } + + return selected, nil +} + +// PromptMultiSelect shows an interactive multi-select menu +// Returns the selected values or an error +func PromptMultiSelect(title, description string, options []SelectOption, limit int) ([]string, error) { + // Validate inputs first + if len(options) == 0 { + return nil, fmt.Errorf("no options provided") + } + + // Check if stdin is a TTY - if not, we can't show interactive forms + if !tty.IsStderrTerminal() { + return nil, fmt.Errorf("interactive selection not available (not a TTY)") + } + + var selected []string + + // Convert options to huh.Option format + huhOptions := make([]huh.Option[string], len(options)) + for i, opt := range options { + huhOptions[i] = huh.NewOption(opt.Label, opt.Value) + } + + multiSelect := huh.NewMultiSelect[string](). + Title(title). + Description(description). + Options(huhOptions...). + Value(&selected) + + // Set limit if specified (0 means no limit) + if limit > 0 { + multiSelect.Limit(limit) + } + + form := huh.NewForm( + huh.NewGroup(multiSelect), + ).WithAccessible(IsAccessibleMode()) + + if err := form.Run(); err != nil { + return nil, err + } + + return selected, nil +} diff --git a/pkg/console/select_test.go b/pkg/console/select_test.go new file mode 100644 index 0000000000..9e513e8ac3 --- /dev/null +++ b/pkg/console/select_test.go @@ -0,0 +1,87 @@ +//go:build !integration + +package console + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPromptSelect(t *testing.T) { + t.Run("function signature", func(t *testing.T) { + // Verify the function exists and has the right signature + _ = PromptSelect + }) + + t.Run("requires options", func(t *testing.T) { + title := "Select an option" + description := "Choose one" + options := []SelectOption{} + + _, err := PromptSelect(title, description, options) + require.Error(t, err, "Should error with no options") + assert.Contains(t, err.Error(), "no options", "Error should mention missing options") + }) + + t.Run("validates parameters with options", func(t *testing.T) { + title := "Select an option" + description := "Choose one" + options := []SelectOption{ + {Label: "Option 1", Value: "opt1"}, + {Label: "Option 2", Value: "opt2"}, + } + + _, err := PromptSelect(title, description, options) + // Will error in test environment (no TTY), but that's expected + require.Error(t, err, "Should error when not in TTY") + assert.Contains(t, err.Error(), "not a TTY", "Error should mention TTY") + }) +} + +func TestPromptMultiSelect(t *testing.T) { + t.Run("function signature", func(t *testing.T) { + // Verify the function exists and has the right signature + _ = PromptMultiSelect + }) + + t.Run("requires options", func(t *testing.T) { + title := "Select options" + description := "Choose multiple" + options := []SelectOption{} + limit := 0 + + _, err := PromptMultiSelect(title, description, options, limit) + require.Error(t, err, "Should error with no options") + assert.Contains(t, err.Error(), "no options", "Error should mention missing options") + }) + + t.Run("validates parameters with options", func(t *testing.T) { + title := "Select options" + description := "Choose multiple" + options := []SelectOption{ + {Label: "Option 1", Value: "opt1"}, + {Label: "Option 2", Value: "opt2"}, + {Label: "Option 3", Value: "opt3"}, + } + limit := 10 + + _, err := PromptMultiSelect(title, description, options, limit) + // Will error in test environment (no TTY), but that's expected + require.Error(t, err, "Should error when not in TTY") + assert.Contains(t, err.Error(), "not a TTY", "Error should mention TTY") + }) +} + +func TestSelectOption(t *testing.T) { + t.Run("struct creation", func(t *testing.T) { + opt := SelectOption{ + Label: "Test Label", + Value: "test-value", + } + + assert.Equal(t, "Test Label", opt.Label, "Label should match") + assert.Equal(t, "test-value", opt.Value, "Value should match") + }) +}