From 4ce5edbe4622d266331576bc24bf0d818de224b5 Mon Sep 17 00:00:00 2001 From: Snider Date: Sat, 31 Jan 2026 23:02:52 +0000 Subject: [PATCH] refactor(setup): replace huh with simple stdin prompts Removes transitive dependencies on charmbracelet/huh. Previous wizard at 96eaed5 if needed. Co-Authored-By: Claude Opus 4.5 --- pkg/cli/prompt.go | 77 +++++++++++++++ pkg/setup/cmd_setup.go | 2 +- pkg/setup/cmd_wizard.go | 206 ++++++++-------------------------------- 3 files changed, 118 insertions(+), 167 deletions(-) create mode 100644 pkg/cli/prompt.go diff --git a/pkg/cli/prompt.go b/pkg/cli/prompt.go new file mode 100644 index 0000000..26a0b63 --- /dev/null +++ b/pkg/cli/prompt.go @@ -0,0 +1,77 @@ +package cli + +import ( + "bufio" + "fmt" + "os" + "strconv" + "strings" +) + +var stdin = bufio.NewReader(os.Stdin) + +// Prompt asks for text input with a default value. +func Prompt(label, defaultVal string) (string, error) { + if defaultVal != "" { + fmt.Printf("%s [%s]: ", label, defaultVal) + } else { + fmt.Printf("%s: ", label) + } + + input, err := stdin.ReadString('\n') + if err != nil { + return "", err + } + + input = strings.TrimSpace(input) + if input == "" { + return defaultVal, nil + } + return input, nil +} + + + +// Select presents numbered options and returns the selected value. +func Select(label string, options []string) (string, error) { + fmt.Println(label) + for i, opt := range options { + fmt.Printf(" %d. %s\n", i+1, opt) + } + fmt.Printf("Choose [1-%d]: ", len(options)) + + input, err := stdin.ReadString('\n') + if err != nil { + return "", err + } + + n, err := strconv.Atoi(strings.TrimSpace(input)) + if err != nil || n < 1 || n > len(options) { + return "", fmt.Errorf("invalid selection") + } + return options[n-1], nil +} + +// MultiSelect presents checkboxes (space-separated numbers). +func MultiSelect(label string, options []string) ([]string, error) { + fmt.Println(label) + for i, opt := range options { + fmt.Printf(" %d. %s\n", i+1, opt) + } + fmt.Printf("Choose (space-separated) [1-%d]: ", len(options)) + + input, err := stdin.ReadString('\n') + if err != nil { + return nil, err + } + + var selected []string + for _, s := range strings.Fields(input) { + n, err := strconv.Atoi(s) + if err != nil || n < 1 || n > len(options) { + continue + } + selected = append(selected, options[n-1]) + } + return selected, nil +} diff --git a/pkg/setup/cmd_setup.go b/pkg/setup/cmd_setup.go index 142450f..fb6766b 100644 --- a/pkg/setup/cmd_setup.go +++ b/pkg/setup/cmd_setup.go @@ -9,7 +9,7 @@ import ( // Style aliases from shared package var ( - repoNameStyle = cli.RepoNameStyle + repoNameStyle = cli.RepoStyle successStyle = cli.SuccessStyle errorStyle = cli.ErrorStyle dimStyle = cli.DimStyle diff --git a/pkg/setup/cmd_wizard.go b/pkg/setup/cmd_wizard.go index c89348c..d141faf 100644 --- a/pkg/setup/cmd_wizard.go +++ b/pkg/setup/cmd_wizard.go @@ -1,29 +1,17 @@ // cmd_wizard.go implements the interactive package selection wizard. -// -// Uses charmbracelet/huh for a rich terminal UI with multi-select checkboxes. -// Falls back to non-interactive mode when not in a TTY or --all is specified. - package setup import ( "fmt" "os" "sort" - "strings" - "github.com/charmbracelet/huh" "github.com/host-uk/core/pkg/cli" "github.com/host-uk/core/pkg/i18n" "github.com/host-uk/core/pkg/repos" "golang.org/x/term" ) -// wizardTheme returns a Dracula-inspired theme matching our CLI styling. -func wizardTheme() *huh.Theme { - t := huh.ThemeDracula() - return t -} - // isTerminal returns true if stdin is a terminal. func isTerminal() bool { return term.IsTerminal(int(os.Stdin.Fd())) @@ -31,189 +19,75 @@ func isTerminal() bool { // promptSetupChoice asks the user whether to setup the working directory or create a package. func promptSetupChoice() (string, error) { - var choice string + fmt.Println(cli.TitleStyle.Render(i18n.T("cmd.setup.wizard.git_repo_title"))) + fmt.Println(i18n.T("cmd.setup.wizard.what_to_do")) - form := huh.NewForm( - huh.NewGroup( - huh.NewSelect[string](). - Title(i18n.T("cmd.setup.wizard.git_repo_title")). - Description(i18n.T("cmd.setup.wizard.what_to_do")). - Options( - huh.NewOption(i18n.T("cmd.setup.wizard.option_setup"), "setup").Selected(true), - huh.NewOption(i18n.T("cmd.setup.wizard.option_package"), "package"), - ). - Value(&choice), - ), - ).WithTheme(wizardTheme()) - - if err := form.Run(); err != nil { + choice, err := cli.Select("Choose action", []string{"setup", "package"}) + if err != nil { return "", err } - return choice, nil } // promptProjectName asks the user for a project directory name. func promptProjectName(defaultName string) (string, error) { - var name string - - form := huh.NewForm( - huh.NewGroup( - huh.NewInput(). - Title(i18n.T("cmd.setup.wizard.project_name_title")). - Description(i18n.T("cmd.setup.wizard.project_name_desc")). - Placeholder(defaultName). - Value(&name), - ), - ).WithTheme(wizardTheme()) - - if err := form.Run(); err != nil { - return "", err - } - - if name == "" { - return defaultName, nil - } - return name, nil -} - -// groupPackagesByType organizes repos by their type for display. -func groupPackagesByType(reposList []*repos.Repo) map[string][]*repos.Repo { - groups := make(map[string][]*repos.Repo) - - for _, repo := range reposList { - repoType := repo.Type - if repoType == "" { - repoType = "other" - } - groups[repoType] = append(groups[repoType], repo) - } - - // Sort within each group - for _, group := range groups { - sort.Slice(group, func(i, j int) bool { - return group[i].Name < group[j].Name - }) - } - - return groups -} - -// packageOption represents a selectable package in the wizard. -type packageOption struct { - repo *repos.Repo - selected bool + fmt.Println(cli.TitleStyle.Render(i18n.T("cmd.setup.wizard.project_name_title"))) + return cli.Prompt(i18n.T("cmd.setup.wizard.project_name_desc"), defaultName) } // runPackageWizard presents an interactive multi-select UI for package selection. -// Returns the list of selected repo names. func runPackageWizard(reg *repos.Registry, preselectedTypes []string) ([]string, error) { allRepos := reg.List() - // Build preselection set - preselect := make(map[string]bool) - for _, t := range preselectedTypes { - preselect[strings.TrimSpace(t)] = true - } + // Build options + var options []string + + // Sort by name + sort.Slice(allRepos, func(i, j int) bool { + return allRepos[i].Name < allRepos[j].Name + }) - // Group repos by type for organized display - groups := groupPackagesByType(allRepos) - - // Build options with preselection - var options []huh.Option[string] - typeOrder := []string{"foundation", "module", "product", "template", "other"} - - for _, typeKey := range typeOrder { - group, ok := groups[typeKey] - if !ok || len(group) == 0 { + for _, repo := range allRepos { + if repo.Clone != nil && !*repo.Clone { continue } - - // Add type header as a visual separator (empty option) - typeLabel := strings.ToUpper(typeKey) - options = append(options, huh.NewOption[string]( - fmt.Sprintf("── %s ──", typeLabel), - "", - ).Selected(false)) - - for _, repo := range group { - // Skip if clone: false - if repo.Clone != nil && !*repo.Clone { - continue - } - - label := repo.Name - if repo.Description != "" { - label = fmt.Sprintf("%s - %s", repo.Name, truncateDesc(repo.Description, 40)) - } - - // Preselect based on type filter or select all if no filter - selected := len(preselect) == 0 || preselect[repo.Type] - - options = append(options, huh.NewOption[string](label, repo.Name).Selected(selected)) - } + // Format: name (type) + label := fmt.Sprintf("%s (%s)", repo.Name, repo.Type) + options = append(options, label) } - var selected []string - - // Header styling - headerStyle := cli.TitleStyle.MarginBottom(1) - - fmt.Println(headerStyle.Render(i18n.T("cmd.setup.wizard.package_selection"))) + fmt.Println(cli.TitleStyle.Render(i18n.T("cmd.setup.wizard.package_selection"))) fmt.Println(i18n.T("cmd.setup.wizard.selection_hint")) - fmt.Println() - form := huh.NewForm( - huh.NewGroup( - huh.NewMultiSelect[string](). - Title(i18n.T("cmd.setup.wizard.select_packages")). - Options(options...). - Value(&selected). - Filterable(true). - Height(20), - ), - ).WithTheme(wizardTheme()) - - if err := form.Run(); err != nil { + selectedLabels, err := cli.MultiSelect(i18n.T("cmd.setup.wizard.select_packages"), options) + if err != nil { return nil, err } - // Filter out empty values (type headers) - var result []string - for _, name := range selected { + // Extract names from labels + var selected []string + for _, label := range selectedLabels { + // Basic parsing assuming "name (type)" format + // Find last space + var name string + // Since we constructed it, we know it ends with (type) + // but repo name might have spaces? Repos usually don't. + // Let's iterate repos to find match + for _, repo := range allRepos { + if label == fmt.Sprintf("%s (%s)", repo.Name, repo.Type) { + name = repo.Name + break + } + } if name != "" { - result = append(result, name) + selected = append(selected, name) } } - - return result, nil + return selected, nil } // confirmClone asks for confirmation before cloning. func confirmClone(count int, target string) (bool, error) { - var confirmed bool - - form := huh.NewForm( - huh.NewGroup( - huh.NewConfirm(). - Title(i18n.T("cmd.setup.wizard.confirm_clone", map[string]interface{}{"Count": count, "Target": target})). - Affirmative(i18n.T("cmd.setup.wizard.confirm_yes")). - Negative(i18n.T("cmd.setup.wizard.confirm_cancel")). - Value(&confirmed), - ), - ).WithTheme(wizardTheme()) - - if err := form.Run(); err != nil { - return false, err - } - + confirmed := cli.Confirm(i18n.T("cmd.setup.wizard.confirm_clone", map[string]interface{}{"Count": count, "Target": target})) return confirmed, nil -} - -// truncateDesc truncates a description to max length with ellipsis. -func truncateDesc(s string, max int) string { - if len(s) <= max { - return s - } - return s[:max-3] + "..." -} +} \ No newline at end of file