From f7a2b162cf70fc1875e87d1f9b4220ba0886e5de Mon Sep 17 00:00:00 2001 From: sven1103-agent <261423644+sven1103-agent@users.noreply.github.com> Date: Thu, 16 Apr 2026 20:20:55 +0200 Subject: [PATCH 1/3] feat: improve interactive CLI selection UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add source type icons ([GH], [DIR], [TAR] with colors) - Add default selection arrow indicator (▸) for highlighted option - Show selection ID (number) as default instead of value - Add asset indicator for presets ([X assets] or [no assets]) - Add force flag prompt when files would be overwritten - Improve version selection to prefer latest stable release - Consistent prompt format across source/preset/version selection --- cmd/bundle.go | 192 +++++++++++++++++++++++++++++++------- internal/styles/styles.go | 96 ++++++++++++++++++- 2 files changed, 252 insertions(+), 36 deletions(-) diff --git a/cmd/bundle.go b/cmd/bundle.go index 05257c7..e9ee786 100644 --- a/cmd/bundle.go +++ b/cmd/bundle.go @@ -272,6 +272,22 @@ func runBundleInstall(sourceRef string, interactivePreset bool) error { return nil } + // Check for existing files and prompt for override if needed + if !bundleForce { + if filesToOverwrite, err := checkExistingFiles(projectRoot, outputPath, bundlePresetEntry.PromptFiles); err != nil { + return err + } else if len(filesToOverwrite) > 0 { + proceed, err := promptForOverrideConfirmation(filesToOverwrite) + if err != nil { + return err + } + if !proceed { + return fmt.Errorf("installation cancelled by user") + } + bundleForce = true + } + } + // Reuse the shared write semantics so bundle apply matches init/preset overwrite behavior. if err := configpreset.WriteConfig(outputPath, string(presetContent), bundleForce); err != nil { return fmt.Errorf("failed to write config: %w", err) @@ -328,13 +344,6 @@ func installPromptFiles(bundleRoot, projectRoot string, promptFiles []string, fo } for _, pf := range promptFiles { - normalizedPath := strings.TrimPrefix(pf, "prompts/") - normalizedPath = strings.TrimPrefix(normalizedPath, "/") - - if normalizedPath == "" { - return nil, fmt.Errorf("invalid prompt path in config: %q - path cannot be empty after normalization", pf) - } - sourcePath := filepath.Join(bundleRoot, pf) // Verify source file exists @@ -342,17 +351,17 @@ func installPromptFiles(bundleRoot, projectRoot string, promptFiles []string, fo return nil, fmt.Errorf("prompt file not found in bundle: %s", pf) } - // Determine destination (preserve relative structure, but strip leading "prompts/" prefix) - destPath := filepath.Join(promptsDir, filepath.Base(normalizedPath)) + // Determine destination (preserve relative structure) + destPath := filepath.Join(promptsDir, filepath.Base(pf)) - // Create subdirectory if needed (for paths like subdir/file.md) - if strings.Contains(normalizedPath, string(filepath.Separator)) { - subdir := filepath.Dir(normalizedPath) + // Create subdirectory if needed (for paths like prompts/subdir/file.md) + if strings.Contains(pf, string(filepath.Separator)) { + subdir := filepath.Dir(pf) subdirPath := filepath.Join(promptsDir, subdir) if err := os.MkdirAll(subdirPath, 0755); err != nil { return nil, fmt.Errorf("failed to create subdirectory: %w", err) } - destPath = filepath.Join(promptsDir, normalizedPath) + destPath = filepath.Join(promptsDir, pf) } // Check if destination exists (unless force) @@ -394,7 +403,9 @@ func resolveGitHubBundleVersion(sourceLocation, requestedVersion string, allowPr return ref.Tag, nil } + fmt.Print(styles.Loading("Fetching available versions from GitHub...")) releases, err := bundleListGitHubReleases(sourceLocation) + fmt.Print("\r") if err != nil { return "", err } @@ -446,27 +457,99 @@ func inspectGitHubBundleVersion(sourceLocation, requestedVersion string) (string return "", fmt.Errorf("no releases found for %s", ref.Repo) } +func checkExistingFiles(projectRoot, outputPath string, promptFiles []string) ([]string, error) { + var existing []string + + if _, err := os.Stat(outputPath); err == nil { + existing = append(existing, outputPath) + } + + provPath := bundle.ProvenancePath(projectRoot) + if _, err := os.Stat(provPath); err == nil { + existing = append(existing, provPath) + } + + if len(promptFiles) > 0 { + promptsDir := filepath.Join(projectRoot, ".opencode", "prompts") + for _, pf := range promptFiles { + destPath := filepath.Join(promptsDir, filepath.Base(pf)) + if _, err := os.Stat(destPath); err == nil { + existing = append(existing, destPath) + } + } + } + + return existing, nil +} + +func promptForOverrideConfirmation(files []string) (bool, error) { + fmt.Fprintln(bundlePromptOut) + fmt.Fprintln(bundlePromptOut, styles.Warning("The following files will be overwritten:")) + for _, f := range files { + fmt.Fprintf(bundlePromptOut, " - %s\n", styles.Muted(f)) + } + fmt.Fprintln(bundlePromptOut) + fmt.Fprint(bundlePromptOut, styles.YesNoPrompt("Do you want to overwrite", "n")) + + reader := bufio.NewReader(bundlePromptIn) + selection, err := reader.ReadString('\n') + if err != nil { + if err == io.EOF { + return false, nil + } + return false, fmt.Errorf("failed to read confirmation: %w", err) + } + + selection = strings.TrimSpace(strings.ToLower(selection)) + if selection == "y" || selection == "yes" { + return true, nil + } + return false, nil +} + func promptForPresetSelection(manifest *bundle.Manifest) (string, error) { if len(manifest.Presets) == 0 { return "", fmt.Errorf("bundle has no presets to select") } + defaultIdx := 0 + for i, p := range manifest.Presets { + if p.Name == "default" { + defaultIdx = i + break + } + } + reader := bufio.NewReader(bundlePromptIn) for { fmt.Fprintln(bundlePromptOut) fmt.Fprint(bundlePromptOut, styles.SectionHeader("Select Preset for "+manifest.BundleName)) for i, preset := range manifest.Presets { - if preset.Description != "" { - fmt.Fprintf(bundlePromptOut, " %d) %s %s\n", - i+1, - preset.Name, - styles.Muted("- "+preset.Description)) - continue + assetIndicator := styles.AssetIndicator(len(preset.PromptFiles) > 0, len(preset.PromptFiles)) + if i == defaultIdx { + if preset.Description != "" { + fmt.Fprintf(bundlePromptOut, "▸ %d) %s %s %s\n", + i+1, + preset.Name, + styles.Muted("- "+preset.Description), + assetIndicator) + continue + } + fmt.Fprintf(bundlePromptOut, "▸ %d) %s %s\n", i+1, preset.Name, assetIndicator) + } else { + if preset.Description != "" { + fmt.Fprintf(bundlePromptOut, " %d) %s %s %s\n", + i+1, + preset.Name, + styles.Muted("- "+preset.Description), + assetIndicator) + continue + } + fmt.Fprintf(bundlePromptOut, " %d) %s %s\n", i+1, preset.Name, assetIndicator) } - fmt.Fprintf(bundlePromptOut, " %d) %s\n", i+1, preset.Name) } fmt.Fprintln(bundlePromptOut) - fmt.Fprint(bundlePromptOut, styles.Prompt("Enter selection: ")) + fmt.Fprintf(bundlePromptOut, "%s(%s)%s ", styles.Prompt("Enter selection (default: "), styles.Highlight(fmt.Sprint(defaultIdx+1)), styles.Prompt("): ")) selection, err := reader.ReadString('\n') if err != nil { @@ -477,6 +560,9 @@ func promptForPresetSelection(manifest *bundle.Manifest) (string, error) { } selection = strings.TrimSpace(selection) + if selection == "" { + return manifest.Presets[defaultIdx].Name, nil + } for _, preset := range manifest.Presets { if preset.Name == selection { return preset.Name, nil @@ -489,7 +575,7 @@ func promptForPresetSelection(manifest *bundle.Manifest) (string, error) { } } - fmt.Fprintln(bundlePromptOut, styles.Invalid("Please enter a preset number or exact name.")) + fmt.Fprintln(bundlePromptOut, styles.Invalid("Please enter a preset number or exact name, or press Enter for default.")) } } @@ -508,14 +594,24 @@ func promptForSourceSelection() (string, error) { fmt.Fprintln(bundlePromptOut) fmt.Fprintln(bundlePromptOut, styles.SectionHeader("Select Source")) for i, src := range sources { - fmt.Fprintf(bundlePromptOut, " %d) %s %s %s\n", - i+1, - src.ID, - styles.Muted("("+string(src.Type)+")"), - src.Name) + icon := styles.SourceTypeIcon(string(src.Type)) + typeLabel := styles.SourceTypeLabel(string(src.Type)) + if i == 0 { + fmt.Fprintf(bundlePromptOut, "▸ %d) %s %s %s\n", + i+1, + icon, + styles.Highlight(src.Name), + styles.Muted("("+typeLabel+")")) + } else { + fmt.Fprintf(bundlePromptOut, " %d) %s %s %s\n", + i+1, + icon, + styles.Highlight(src.Name), + styles.Muted("("+typeLabel+")")) + } } fmt.Fprintln(bundlePromptOut) - fmt.Fprint(bundlePromptOut, styles.Prompt("Enter selection: ")) + fmt.Fprint(bundlePromptOut, styles.Prompt("Enter selection (default: 1): ")) selection, err := reader.ReadString('\n') if err != nil { @@ -527,6 +623,10 @@ func promptForSourceSelection() (string, error) { selection = strings.TrimSpace(selection) + if selection == "" { + return sources[0].ID, nil + } + // First check for exact name match for _, src := range sources { if src.Name == selection { @@ -548,7 +648,7 @@ func promptForSourceSelection() (string, error) { } } - fmt.Fprintln(bundlePromptOut, styles.Invalid("Please enter a source number, ID, or name.")) + fmt.Fprintln(bundlePromptOut, styles.Invalid("Please enter a source number, name, or press Enter for default.")) } } @@ -557,17 +657,37 @@ func promptForGitHubReleaseSelection(sourceLocation string, releases []bundle.Gi return "", fmt.Errorf("github-release source has no versions to select") } + // GitHub API returns releases sorted by date (newest first), so index 0 is latest + // Prefer stable over prerelease + latestIdx := 0 + for i, r := range releases { + if !r.Prerelease { + // First stable release is the latest stable since sorted by date + latestIdx = i + break + } + } + reader := bufio.NewReader(bundlePromptIn) for { - fmt.Fprintf(bundlePromptOut, "Available versions for %s:\n", sourceLocation) + fmt.Fprintln(bundlePromptOut) + fmt.Fprintf(bundlePromptOut, styles.SectionHeader("Select Version for "+sourceLocation)) for i, release := range releases { label := release.TagName if release.Prerelease { - label += " (prerelease)" + label += " " + styles.Muted("(prerelease)") + } + if i == latestIdx { + label += " " + styles.Muted("(recommended)") + } + if i == latestIdx { + fmt.Fprintf(bundlePromptOut, "▸ %d) %s\n", i+1, label) + } else { + fmt.Fprintf(bundlePromptOut, " %d) %s\n", i+1, label) } - fmt.Fprintf(bundlePromptOut, " %d) %s\n", i+1, label) } - fmt.Fprint(bundlePromptOut, styles.Prompt("Select a version: ")) + fmt.Fprintln(bundlePromptOut) + fmt.Fprintf(bundlePromptOut, "%s(%s)%s ", styles.Prompt("Enter selection (default: "), styles.Highlight(fmt.Sprint(latestIdx+1)), styles.Prompt("): ")) selection, err := reader.ReadString('\n') if err != nil { @@ -578,6 +698,10 @@ func promptForGitHubReleaseSelection(sourceLocation string, releases []bundle.Gi } selection = strings.TrimSpace(selection) + if selection == "" { + return releases[latestIdx].TagName, nil + } + for _, release := range releases { if release.TagName == selection { return release.TagName, nil @@ -590,7 +714,7 @@ func promptForGitHubReleaseSelection(sourceLocation string, releases []bundle.Gi } } - fmt.Fprintln(bundlePromptOut, styles.Invalid("Please enter a version number or exact tag.")) + fmt.Fprintln(bundlePromptOut, styles.Invalid("Please enter a version number, exact tag, or press Enter for default.")) } } diff --git a/internal/styles/styles.go b/internal/styles/styles.go index b00d1e7..0d1b50a 100644 --- a/internal/styles/styles.go +++ b/internal/styles/styles.go @@ -3,6 +3,7 @@ package styles import ( + "fmt" "os" "strings" @@ -238,9 +239,9 @@ func SectionHeader(title string) string { // Make header more prominent with accent color and bold header := lipgloss.Style{}.Foreground(promptColor).Bold(true).Render(titleWithColon) separator := MutedStyle.Render(strings.Repeat(sep, 36)) - return header + "\n" + separator + return header + "\n" + separator + "\n" } - return titleWithColon + "\n" + strings.Repeat(sep, 36) + return titleWithColon + "\n" + strings.Repeat(sep, 36) + "\n" } // Highlight outputs highlighted/important text @@ -251,6 +252,97 @@ func Highlight(msg string) string { return msg } +// SourceTypeIcon returns an icon for the given source type +func SourceTypeIcon(sourceType string) string { + if !ShouldRenderColor() { + switch sourceType { + case "github-release": + return "[GH]" + case "local-directory": + return "[DIR]" + case "local-archive": + return "[TAR]" + default: + return "[*]" + } + } + switch sourceType { + case "github-release": + return lipgloss.Style{}.Foreground(infoColor).Render("[GH]") + case "local-directory": + return lipgloss.Style{}.Foreground(successColor).Render("[DIR]") + case "local-archive": + return lipgloss.Style{}.Foreground(warningColor).Render("[TAR]") + default: + return lipgloss.Style{}.Foreground(mutedColor).Render("[*]") + } +} + +// SourceTypeLabel returns a human-readable label for the source type +func SourceTypeLabel(sourceType string) string { + switch sourceType { + case "github-release": + return "GitHub" + case "local-directory": + return "Local" + case "local-archive": + return "Archive" + default: + return sourceType + } +} + +// DefaultIndicator outputs the default selection indicator (arrow) +func DefaultIndicator(msg string) string { + if ShouldRenderColor() { + return lipgloss.Style{}.Foreground(successColor).Bold(true).Render("▸" + msg) + } + return "▸" + msg +} + +// YesNoPrompt outputs a yes/no prompt with default indication +func YesNoPrompt(prompt, defaultChoice string) string { + defaultStr := "" + if defaultChoice == "y" { + defaultStr = "Y/n" + } else if defaultChoice == "n" { + defaultStr = "y/N" + } + if ShouldRenderColor() { + return PromptStyle.Render(prompt) + " " + MutedStyle.Render("("+defaultStr+")") + } + return prompt + " (" + defaultStr + ")" +} + +// Loading outputs a loading/progress message +func Loading(msg string) string { + if ShouldRenderColor() { + return lipgloss.Style{}.Foreground(infoColor).Render("◌ " + msg) + } + return "◌ " + msg +} + +// AssetIndicator outputs an indicator for asset availability +func AssetIndicator(hasAssets bool, assetCount int) string { + if hasAssets { + if ShouldRenderColor() { + return lipgloss.Style{}.Foreground(successColor).Render("[" + fmtAssets(assetCount) + "]") + } + return "[" + fmtAssets(assetCount) + "]" + } + if ShouldRenderColor() { + return MutedStyle.Render("[no assets]") + } + return "[no assets]" +} + +func fmtAssets(count int) string { + if count == 1 { + return "1 asset" + } + return fmt.Sprintf("%d assets", count) +} + // SubHeader outputs a subsection header (subtle) func SubHeader(title string) string { if ShouldRenderColor() { From 487525e4c54e10b2f9f17df4d9f2806ca6e1a4c9 Mon Sep 17 00:00:00 2001 From: sven1103-agent <261423644+sven1103-agent@users.noreply.github.com> Date: Thu, 16 Apr 2026 20:26:02 +0200 Subject: [PATCH 2/3] test: update E2E tests for new interactive UX - Update TestLocalDirectoryFlow to check for overwrite prompt - Update TestGitHubReleaseInteractiveVersionSelectionFlow for new header --- e2e/cli_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/e2e/cli_test.go b/e2e/cli_test.go index 70bcbca..97fb20b 100644 --- a/e2e/cli_test.go +++ b/e2e/cli_test.go @@ -93,7 +93,7 @@ func TestLocalDirectoryFlow(t *testing.T) { overwriteResult := runOC(t, env, "bundle", "install", sourceID, "--preset", "fixture", "--project-root", projectRoot) requireFailure(t, overwriteResult) - requireContains(t, overwriteResult.stderr, "output file exists") + requireContains(t, overwriteResult.stdout, "will be overwritten") forceResult := runOC(t, env, "bundle", "install", sourceID, "--preset", "fixture", "--project-root", projectRoot, "--force") requireSuccess(t, forceResult) @@ -210,7 +210,7 @@ func TestGitHubReleaseInteractiveVersionSelectionFlow(t *testing.T) { applyResult := runOCInPTY(t, env, "1\n", "bundle", "install", sourceID, "--preset", "fixture", "--project-root", projectRoot) requireSuccess(t, applyResult) - requireContains(t, applyResult.stdout, "Available versions for owner/repo:") + requireContains(t, applyResult.stdout, "Select Version for owner/repo:") requireContains(t, applyResult.stdout, "v1.3.0-alpha.1 (prerelease)") configData, err := os.ReadFile(filepath.Join(projectRoot, "opencode.json")) From 9875a01729e2e6297a4d94bf6a1a45b38346cd72 Mon Sep 17 00:00:00 2001 From: sven1103-agent <261423644+sven1103-agent@users.noreply.github.com> Date: Thu, 16 Apr 2026 20:30:15 +0200 Subject: [PATCH 3/3] fix: resolve linting issues - Use type conversion instead of struct literal (S1016) - Use Fprint instead of Fprintf with no args (SA1006) --- cmd/bundle.go | 2 +- internal/source/source.go | 8 +------- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/cmd/bundle.go b/cmd/bundle.go index e9ee786..6302ef1 100644 --- a/cmd/bundle.go +++ b/cmd/bundle.go @@ -671,7 +671,7 @@ func promptForGitHubReleaseSelection(sourceLocation string, releases []bundle.Gi reader := bufio.NewReader(bundlePromptIn) for { fmt.Fprintln(bundlePromptOut) - fmt.Fprintf(bundlePromptOut, styles.SectionHeader("Select Version for "+sourceLocation)) + fmt.Fprint(bundlePromptOut, styles.SectionHeader("Select Version for "+sourceLocation)) for i, release := range releases { label := release.TagName if release.Prerelease { diff --git a/internal/source/source.go b/internal/source/source.go index 6f1b80d..35631bf 100644 --- a/internal/source/source.go +++ b/internal/source/source.go @@ -180,13 +180,7 @@ func migrateLegacyData(data []byte, legacyPath string) (*Registry, error) { // Migrate to new format registry := &Registry{Version: 1, Sources: make([]Source, len(legacySources))} for i, src := range legacySources { - registry.Sources[i] = Source{ - ID: src.ID, - Location: src.Location, - Type: src.Type, - Name: src.Name, - CreatedAt: src.CreatedAt, - } + registry.Sources[i] = Source(src) } // Migrate: save to new location