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 <noreply@anthropic.com>
This commit is contained in:
parent
6f3d8341d5
commit
4ce5edbe46
3 changed files with 118 additions and 167 deletions
77
pkg/cli/prompt.go
Normal file
77
pkg/cli/prompt.go
Normal file
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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] + "..."
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue