From 3d3ce1b5d307f2e2cbda81baa67f9a5246441253 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Wed, 22 Jul 2026 13:11:06 -0700 Subject: [PATCH 1/3] Refresh Claude marketplace cache and report setup failures honestly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #417. Root cause: `basecamp setup` runs `claude plugin marketplace add` before installing the plugin, but `add` no-ops on an already-registered marketplace. Users who added the 37signals marketplace before its source metadata switched from the SSH `github` shorthand to an HTTPS `url` source (basecamp/claude-plugins#7) are left with a stale cache that makes `claude plugin install` clone via `git@github.com:` — which fails on machines without a GitHub SSH key. Separately, the wizard printed "Setup complete!" even when the plugin step failed. Refresh the cache with `claude plugin marketplace update ` before each install (mirroring the Codex path), so a stale `source: github` entry is replaced with the current HTTPS `source: url` first. Claude's own marketplace clone already falls back SSH→HTTPS, so the refresh works without a key. Report honestly: wizardAgents now returns an explicit outcome distinguishing a deliberate skip from a real failure. When agent checks fail after setup, the summary drops "Setup complete!" for an honest headline, lists each failing step with its own agent-specific remediation hint, and points to `basecamp doctor`. A user who declines agent setup still reads as complete. --- internal/commands/wizard.go | 56 ++++++++++++++-- internal/commands/wizard_agents.go | 83 ++++++++++++++++++++++-- internal/commands/wizard_test.go | 100 +++++++++++++++++++++++++++++ 3 files changed, 227 insertions(+), 12 deletions(-) diff --git a/internal/commands/wizard.go b/internal/commands/wizard.go index ed821be4c..fd037c45e 100644 --- a/internal/commands/wizard.go +++ b/internal/commands/wizard.go @@ -103,9 +103,11 @@ func runWizard(cmd *cobra.Command, app *appctx.App) error { result.ConfigScope = configScope // Step 6: Coding agent integration - if err := wizardAgents(cmd, styles); err != nil { + agentOutcome, err := wizardAgents(cmd, styles) + if err != nil { return err } + result.Status = statusFromOutcome(agentOutcome) // Persist onboarded flag (always global so it applies everywhere) if err := resolve.PersistValue("onboarded", "true", "global"); err != nil { @@ -116,7 +118,7 @@ func runWizard(cmd *cobra.Command, app *appctx.App) error { // Interactive mode shows the rich checklist directly; non-interactive // or machine-output mode delegates to app.OK which renders the structured envelope. if app.IsInteractive() && !app.IsMachineOutput() { - showSuccess(cmd.OutOrStdout(), styles, result) + showSuccess(cmd.OutOrStdout(), styles, result, agentOutcome.Issues) return nil } @@ -309,12 +311,30 @@ func wizardSaveConfig(w io.Writer, styles *tui.Styles, accountID, projectID stri return scope } +// successHeadline returns the completion banner. When the agent-setup step left +// unresolved issues, the banner is honest about it rather than claiming +// "Setup complete!". +func successHeadline(status string, issueCount int) string { + if status != "incomplete" { + return "Setup complete!" + } + if issueCount == 1 { + return "Setup finished — 1 step needs attention" + } + return fmt.Sprintf("Setup finished — %d steps need attention", issueCount) +} + // showSuccess displays the completion summary with example commands. -func showSuccess(w io.Writer, styles *tui.Styles, result WizardResult) { +func showSuccess(w io.Writer, styles *tui.Styles, result WizardResult, issues []agentIssue) { divider := styles.Muted.Render("─────────────────────────────────") + headlineStyle := styles.Success + if result.Status == "incomplete" { + headlineStyle = styles.Warning + } + fmt.Fprintln(w, divider) - fmt.Fprintln(w, styles.Success.Render(" Setup complete!")) + fmt.Fprintln(w, headlineStyle.Render(" "+successHeadline(result.Status, len(issues)))) fmt.Fprintln(w, divider) fmt.Fprintln(w) @@ -343,6 +363,26 @@ func showSuccess(w io.Writer, styles *tui.Styles, result WizardResult) { } fmt.Fprintln(w) + // Remediation for anything that did not complete. Each issue carries its own + // check's hint, so guidance stays specific to the failing agent instead of + // hardcoding one agent's commands. + if len(issues) > 0 { + fmt.Fprintln(w, styles.Body.Render(" Some steps need attention:")) + for _, issue := range issues { + label := issue.Check + if issue.Agent != "" { + label = issue.Agent + " — " + issue.Check + } + line := " " + label + if issue.Hint != "" { + line += ": " + issue.Hint + } + fmt.Fprintln(w, styles.Warning.Render(line)) + } + fmt.Fprintln(w, styles.Muted.Render(" Then verify with: basecamp doctor")) + fmt.Fprintln(w) + } + // Example commands fmt.Fprintln(w, styles.Body.Render(" Try these commands:")) fmt.Fprintln(w) @@ -400,10 +440,14 @@ func fetchProjectName(cmd *cobra.Command, app *appctx.App, projectID string) str // wizardSummaryLine builds a concise summary for the output envelope. func wizardSummaryLine(result WizardResult) string { + headline := "Setup complete" + if result.Status == "incomplete" { + headline = "Setup finished with issues" + } if result.AccountName != "" { - return fmt.Sprintf("Setup complete - %s", result.AccountName) + return fmt.Sprintf("%s - %s", headline, result.AccountName) } - return "Setup complete" + return headline } // wizardBreadcrumbs returns next-step breadcrumbs based on wizard outcome. diff --git a/internal/commands/wizard_agents.go b/internal/commands/wizard_agents.go index fc3fe8bf8..ea7cb6f11 100644 --- a/internal/commands/wizard_agents.go +++ b/internal/commands/wizard_agents.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "io" "os" "os/exec" "path/filepath" @@ -33,6 +34,62 @@ type agentSetupError struct { func (e *agentSetupError) Error() string { return e.Summary } +// agentIssue is a single unresolved problem after agent setup ran. Hint carries +// the failing check's own remediation so reporting stays agent-specific. +type agentIssue struct { + Agent string + Check string + Hint string +} + +// agentSetupOutcome reports what happened during the agent-setup step so the +// wizard can tell a deliberate skip (still "complete") apart from a real +// failure ("incomplete"). +type agentSetupOutcome struct { + Skipped bool + Issues []agentIssue +} + +// collectAgentIssues returns one issue per non-passing check across the given +// agents. It takes agents as a parameter (rather than reading the global +// registry) so it stays pure and testable without RegisterAgent side effects. +func collectAgentIssues(agents []harness.AgentInfo) []agentIssue { + var issues []agentIssue + for _, a := range agents { + if a.Checks == nil { + continue + } + for _, c := range a.Checks() { + if c.Status != "pass" { + issues = append(issues, agentIssue{Agent: a.Name, Check: c.Name, Hint: c.Hint}) + } + } + } + return issues +} + +// statusFromOutcome maps an agent-setup outcome to a WizardResult status. A +// deliberate skip is not a failure, so it stays "complete". +func statusFromOutcome(o agentSetupOutcome) string { + if !o.Skipped && len(o.Issues) > 0 { + return "incomplete" + } + return "complete" +} + +// refreshClaudeMarketplace refreshes the 37signals marketplace cache from source +// before an install. `claude plugin marketplace add` no-ops on an +// already-registered marketplace, so a pre-existing entry that still declares the +// SSH `source: github` shorthand would otherwise strand `claude plugin install` +// on an SSH clone. `marketplace update` re-clones and picks up the current HTTPS +// `source: url`. Best-effort: install still proceeds if the refresh fails. +func refreshClaudeMarketplace(ctx context.Context, claudePath string, stdout, stderr io.Writer) { + cmd := exec.CommandContext(ctx, claudePath, "plugin", "marketplace", "update", harness.ClaudeMarketplaceName) //nolint:gosec // G204: claudePath from FindClaudeBinary + cmd.Stdout = stdout + cmd.Stderr = stderr + _ = cmd.Run() +} + // agentSetupHandlers maps agent ID → setup handler. var agentSetupHandlers = map[string]agentSetupHandler{ "claude": { @@ -78,6 +135,7 @@ func runClaudeSetup(cmd *cobra.Command, styles *tui.Styles) error { mktCmd.Stdout = w mktCmd.Stderr = cmd.ErrOrStderr() _ = mktCmd.Run() + refreshClaudeMarketplace(ctx, claudePath, w, cmd.ErrOrStderr()) var scopeErrors []string for _, scope := range reinstallScopes { @@ -120,6 +178,10 @@ func runClaudeSetup(cmd *cobra.Command, styles *tui.Styles) error { fmt.Fprintln(w, styles.RenderStatus(true, "Marketplace registered")) } + // Refresh the cache so a stale SSH-shorthand entry is replaced with + // the current HTTPS source before installing. + refreshClaudeMarketplace(ctx, claudePath, w, cmd.ErrOrStderr()) + // Install the plugin installCmd := exec.CommandContext(ctx, claudePath, "plugin", "install", harness.ClaudeExpectedPluginKey) //nolint:gosec // G204: claudePath from exec.LookPath installCmd.Stdout = w @@ -159,10 +221,10 @@ func runClaudeSetup(cmd *cobra.Command, styles *tui.Styles) error { // wizardAgents offers to set up detected coding agents. // Replaces the old wizardClaude() — works for any registered agent. -func wizardAgents(cmd *cobra.Command, styles *tui.Styles) error { +func wizardAgents(cmd *cobra.Command, styles *tui.Styles) (agentSetupOutcome, error) { agents := harness.DetectedAgents() if len(agents) == 0 { - return nil + return agentSetupOutcome{}, nil } w := cmd.OutOrStdout() @@ -192,7 +254,7 @@ func wizardAgents(cmd *cobra.Command, styles *tui.Styles) error { fmt.Fprintln(w, styles.RenderStatus(true, a.Name+" plugin installed")) } fmt.Fprintln(w) - return nil + return agentSetupOutcome{}, nil } fmt.Fprintln(w, styles.Heading.Render(" Step 5: Coding Agent Setup")) @@ -233,14 +295,17 @@ func wizardAgents(cmd *cobra.Command, styles *tui.Styles) error { } } fmt.Fprintln(w) - return nil //nolint:nilerr // Treat confirm error as skip (user canceled) + return agentSetupOutcome{Skipped: true}, nil //nolint:nilerr // Treat confirm error as skip (user canceled) } fmt.Fprintln(w) + var issues []agentIssue + // Install baseline skill (always, for any agent) if _, err := installSkillFiles(); err != nil { fmt.Fprintln(w, styles.Warning.Render(fmt.Sprintf(" Skill install failed: %s", err))) + issues = append(issues, agentIssue{Check: "Agent skill", Hint: "Run: basecamp setup"}) } else { fmt.Fprintln(w, styles.RenderStatus(true, "Agent skill installed")) } @@ -252,12 +317,16 @@ func wizardAgents(cmd *cobra.Command, styles *tui.Styles) error { continue } if err := handler.Run(cmd, styles); err != nil { - return err + return agentSetupOutcome{}, err } } + // Re-check the agents after setup ran so failed installs (e.g. a plugin that + // could not be cloned) surface as issues rather than a silent "complete". + issues = append(issues, collectAgentIssues(agents)...) + fmt.Fprintln(w) - return nil + return agentSetupOutcome{Issues: issues}, nil } // runClaudeSetupNonInteractive attempts plugin install without prompts (for --json/--agent mode). @@ -280,6 +349,7 @@ func runClaudeSetupNonInteractive(cmd *cobra.Command) error { mktCmd := exec.CommandContext(ctx, claudePath, "plugin", "marketplace", "add", harness.ClaudeMarketplaceSource) //nolint:gosec // G204: claudePath from FindClaudeBinary mktCmd.Stderr = w _ = mktCmd.Run() + refreshClaudeMarketplace(ctx, claudePath, nil, w) for _, scope := range reinstallScopes { args := []string{"plugin", "install", harness.ClaudeExpectedPluginKey, "--scope", scope} @@ -305,6 +375,7 @@ func runClaudeSetupNonInteractive(cmd *cobra.Command) error { marketplaceCmd := exec.CommandContext(ctx, claudePath, "plugin", "marketplace", "add", harness.ClaudeMarketplaceSource) //nolint:gosec // G204: claudePath from exec.LookPath marketplaceCmd.Stderr = w _ = marketplaceCmd.Run() + refreshClaudeMarketplace(ctx, claudePath, nil, w) // Install the plugin installCmd := exec.CommandContext(ctx, claudePath, "plugin", "install", harness.ClaudeExpectedPluginKey) //nolint:gosec // G204: claudePath from exec.LookPath diff --git a/internal/commands/wizard_test.go b/internal/commands/wizard_test.go index 070f6b78f..0fd23d524 100644 --- a/internal/commands/wizard_test.go +++ b/internal/commands/wizard_test.go @@ -13,6 +13,7 @@ import ( "github.com/stretchr/testify/require" "github.com/basecamp/basecamp-cli/internal/appctx" + "github.com/basecamp/basecamp-cli/internal/harness" "github.com/basecamp/basecamp-cli/internal/output" "github.com/basecamp/basecamp-cli/internal/tui" "github.com/basecamp/basecamp-cli/skills" @@ -94,6 +95,63 @@ func TestWizardSummaryLine(t *testing.T) { } } +// TestWizardSummaryLineIncomplete verifies the summary reflects an unhealthy +// agent-setup outcome instead of claiming completion. +func TestWizardSummaryLineIncomplete(t *testing.T) { + assert.Equal(t, "Setup finished with issues", + wizardSummaryLine(WizardResult{Status: "incomplete"})) + assert.Equal(t, "Setup finished with issues - Test Co", + wizardSummaryLine(WizardResult{Status: "incomplete", AccountName: "Test Co"})) +} + +// TestSuccessHeadline verifies the completion banner is honest when the +// agent-setup step left unresolved issues. +func TestSuccessHeadline(t *testing.T) { + assert.Equal(t, "Setup complete!", successHeadline("complete", 0)) + assert.Equal(t, "Setup complete!", successHeadline("", 0)) + assert.Equal(t, "Setup finished — 1 step needs attention", successHeadline("incomplete", 1)) + assert.Equal(t, "Setup finished — 2 steps need attention", successHeadline("incomplete", 2)) +} + +// TestStatusFromOutcome verifies a deliberate skip stays complete while real +// issues mark the run incomplete. +func TestStatusFromOutcome(t *testing.T) { + assert.Equal(t, "complete", statusFromOutcome(agentSetupOutcome{})) + assert.Equal(t, "complete", statusFromOutcome(agentSetupOutcome{Skipped: true})) + assert.Equal(t, "complete", + statusFromOutcome(agentSetupOutcome{Skipped: true, Issues: []agentIssue{{Check: "x"}}})) + assert.Equal(t, "incomplete", + statusFromOutcome(agentSetupOutcome{Issues: []agentIssue{{Check: "Claude Code Plugin"}}})) +} + +// TestCollectAgentIssues verifies non-passing checks become issues carrying the +// agent name and the check's own hint, and passing checks are ignored. Agents +// are supplied directly so the test never mutates the global registry. +func TestCollectAgentIssues(t *testing.T) { + agents := []harness.AgentInfo{ + { + Name: "Claude Code", + Checks: func() []*harness.StatusCheck { + return []*harness.StatusCheck{ + {Name: "Claude Code Skill", Status: "pass"}, + {Name: "Claude Code Plugin", Status: "fail", Hint: "Run: basecamp setup claude"}, + } + }, + }, + { + Name: "Codex", + Checks: func() []*harness.StatusCheck { return []*harness.StatusCheck{{Name: "Codex Plugin", Status: "pass"}} }, + }, + {Name: "NoChecks", Checks: nil}, + } + + issues := collectAgentIssues(agents) + require.Len(t, issues, 1) + assert.Equal(t, "Claude Code", issues[0].Agent) + assert.Equal(t, "Claude Code Plugin", issues[0].Check) + assert.Equal(t, "Run: basecamp setup claude", issues[0].Hint) +} + // TestWizardBreadcrumbs verifies breadcrumb generation based on wizard outcome. func TestWizardBreadcrumbs(t *testing.T) { t.Run("with project", func(t *testing.T) { @@ -471,6 +529,48 @@ func TestSetupClaudeNonInteractiveScopeAwareReinstall(t *testing.T) { require.NoError(t, readErr) assert.Contains(t, string(calls), "plugin install basecamp@37signals --scope user") assert.Contains(t, string(calls), "plugin install basecamp@37signals --scope project") + // Refresh the marketplace cache before reinstalling so a stale SSH-shorthand + // entry is replaced with the current HTTPS source (issue #417). + assert.Contains(t, string(calls), "plugin marketplace update 37signals") +} + +// TestSetupClaudeNonInteractiveRefreshesMarketplace verifies the fresh-install +// path refreshes the marketplace cache before `plugin install`, so a stale +// `source: github` entry (which would clone over SSH) is replaced with the +// current HTTPS `source: url` first (issue #417). +func TestSetupClaudeNonInteractiveRefreshesMarketplace(t *testing.T) { + t.Setenv("BASECAMP_NO_KEYRING", "1") + home := t.TempDir() + t.Setenv("HOME", home) + + // ~/.claude exists (Claude detected) but no plugin installed → fresh install. + require.NoError(t, os.MkdirAll(filepath.Join(home, ".claude"), 0o755)) + + binDir := filepath.Join(home, "bin") + require.NoError(t, os.MkdirAll(binDir, 0o755)) + logFile := filepath.Join(home, "claude-calls.log") + stubScript := "#!/bin/sh\n" + + "echo \"$*\" >> \"" + logFile + "\"\n" + + "exit 0\n" + require.NoError(t, os.WriteFile(filepath.Join(binDir, "claude"), []byte(stubScript), 0o755)) //nolint:gosec // G306: test helper + t.Setenv("PATH", binDir) + + app, _ := setupQuickstartTestApp(t, "", "") + app.Flags.JSON = true + + cmd := NewSetupCmd() + cmd.SetArgs([]string{"claude"}) + cmd.SetContext(appctx.WithApp(context.Background(), app)) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + require.NoError(t, cmd.Execute()) + + calls, readErr := os.ReadFile(logFile) + require.NoError(t, readErr) + assert.Contains(t, string(calls), "plugin marketplace update 37signals", + "fresh install should refresh the marketplace cache before installing") + assert.Contains(t, string(calls), "plugin install basecamp@37signals") } // TestJoinNames verifies name joining with commas and "and". From 7aa458881856cc77292c3042feef7948e2aeb8db Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Wed, 22 Jul 2026 13:53:02 -0700 Subject: [PATCH 2/3] Harden setup reporting: unified check snapshot, issue-authoritative status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review follow-ups on the #417 fix: - Manual recovery hint now includes `claude plugin marketplace update`, so the copy-paste path also refreshes a stale cache instead of reproducing the SSH clone failure (marketplace add alone no-ops on an existing marketplace). - Completion status and the rendered checklist now derive from a single post-setup snapshot (snapshotAgentChecks) carried through the outcome, so a transient check can no longer make the headline and checklist disagree. - Status is issue-authoritative: any observed failure marks the run incomplete and Skipped no longer suppresses one. A deliberate skip records no issues, so it stays complete. - Tests: rendered showSuccess assertions (no "Setup complete!" on failure, agent-specific hint present, doctor pointer present); exact add → update → install ordering on both the non-interactive and interactive install paths; snapshot/issue helpers covered with supplied agents (no global registry mutation). --- internal/commands/wizard.go | 18 ++--- internal/commands/wizard_agents.go | 117 +++++++++++++++++------------ internal/commands/wizard_test.go | 104 ++++++++++++++++++++++--- 3 files changed, 168 insertions(+), 71 deletions(-) diff --git a/internal/commands/wizard.go b/internal/commands/wizard.go index fd037c45e..5b2372eb0 100644 --- a/internal/commands/wizard.go +++ b/internal/commands/wizard.go @@ -13,7 +13,6 @@ import ( "github.com/basecamp/basecamp-cli/internal/appctx" "github.com/basecamp/basecamp-cli/internal/auth" - "github.com/basecamp/basecamp-cli/internal/harness" "github.com/basecamp/basecamp-cli/internal/output" "github.com/basecamp/basecamp-cli/internal/tui" "github.com/basecamp/basecamp-cli/internal/tui/resolve" @@ -118,7 +117,7 @@ func runWizard(cmd *cobra.Command, app *appctx.App) error { // Interactive mode shows the rich checklist directly; non-interactive // or machine-output mode delegates to app.OK which renders the structured envelope. if app.IsInteractive() && !app.IsMachineOutput() { - showSuccess(cmd.OutOrStdout(), styles, result, agentOutcome.Issues) + showSuccess(cmd.OutOrStdout(), styles, result, agentOutcome.Checks, agentOutcome.Issues) return nil } @@ -324,8 +323,10 @@ func successHeadline(status string, issueCount int) string { return fmt.Sprintf("Setup finished — %d steps need attention", issueCount) } -// showSuccess displays the completion summary with example commands. -func showSuccess(w io.Writer, styles *tui.Styles, result WizardResult, issues []agentIssue) { +// showSuccess displays the completion summary with example commands. checks and +// issues come from one post-setup snapshot so the headline, checklist, and +// remediation are always consistent. +func showSuccess(w io.Writer, styles *tui.Styles, result WizardResult, checks []agentCheck, issues []agentIssue) { divider := styles.Muted.Render("─────────────────────────────────") headlineStyle := styles.Success @@ -353,13 +354,8 @@ func showSuccess(w io.Writer, styles *tui.Styles, result WizardResult, issues [] if result.ConfigScope != "" { fmt.Fprintln(w, styles.RenderStatus(true, fmt.Sprintf("Config saved (%s)", result.ConfigScope))) } - for _, agent := range harness.DetectedAgents() { - if agent.Checks == nil { - continue - } - for _, check := range agent.Checks() { - fmt.Fprintln(w, styles.RenderStatus(check.Status == "pass", check.Name)) - } + for _, check := range checks { + fmt.Fprintln(w, styles.RenderStatus(check.Status == "pass", check.Name)) } fmt.Fprintln(w) diff --git a/internal/commands/wizard_agents.go b/internal/commands/wizard_agents.go index ea7cb6f11..88b0525cc 100644 --- a/internal/commands/wizard_agents.go +++ b/internal/commands/wizard_agents.go @@ -34,6 +34,17 @@ type agentSetupError struct { func (e *agentSetupError) Error() string { return e.Summary } +// agentCheck is one agent health check captured in a single post-setup snapshot. +// Both the completion status and the rendered checklist derive from the same +// snapshot so they can never disagree (e.g. a transient check flipping between +// two independent scans). +type agentCheck struct { + Agent string + Name string + Status string + Hint string +} + // agentIssue is a single unresolved problem after agent setup ran. Hint carries // the failing check's own remediation so reporting stays agent-specific. type agentIssue struct { @@ -42,36 +53,47 @@ type agentIssue struct { Hint string } -// agentSetupOutcome reports what happened during the agent-setup step so the -// wizard can tell a deliberate skip (still "complete") apart from a real -// failure ("incomplete"). +// agentSetupOutcome reports what happened during the agent-setup step. Issues are +// authoritative for the wizard's completion status; Skipped is metadata only (a +// deliberate skip records no issues, so it stays "complete"). type agentSetupOutcome struct { Skipped bool + Checks []agentCheck Issues []agentIssue } -// collectAgentIssues returns one issue per non-passing check across the given -// agents. It takes agents as a parameter (rather than reading the global -// registry) so it stays pure and testable without RegisterAgent side effects. -func collectAgentIssues(agents []harness.AgentInfo) []agentIssue { - var issues []agentIssue +// snapshotAgentChecks captures every check across the given agents in one pass. +// It takes agents as a parameter (rather than reading the global registry) so it +// stays pure and testable without RegisterAgent side effects. +func snapshotAgentChecks(agents []harness.AgentInfo) []agentCheck { + var out []agentCheck for _, a := range agents { if a.Checks == nil { continue } for _, c := range a.Checks() { - if c.Status != "pass" { - issues = append(issues, agentIssue{Agent: a.Name, Check: c.Name, Hint: c.Hint}) - } + out = append(out, agentCheck{Agent: a.Name, Name: c.Name, Status: c.Status, Hint: c.Hint}) + } + } + return out +} + +// issuesFromChecks returns one issue per non-passing check in the snapshot. +func issuesFromChecks(checks []agentCheck) []agentIssue { + var issues []agentIssue + for _, c := range checks { + if c.Status != "pass" { + issues = append(issues, agentIssue{Agent: c.Agent, Check: c.Name, Hint: c.Hint}) } } return issues } -// statusFromOutcome maps an agent-setup outcome to a WizardResult status. A -// deliberate skip is not a failure, so it stays "complete". +// statusFromOutcome maps an agent-setup outcome to a WizardResult status. Issues +// are authoritative: any observed failure means "incomplete", and Skipped never +// suppresses one. A deliberate skip records no issues, so it stays "complete". func statusFromOutcome(o agentSetupOutcome) string { - if !o.Skipped && len(o.Issues) > 0 { + if len(o.Issues) > 0 { return "incomplete" } return "complete" @@ -162,9 +184,9 @@ func runClaudeSetup(cmd *cobra.Command, styles *tui.Styles) error { if claudePath == "" { fmt.Fprintln(w, styles.Muted.Render(" Claude Code detected but binary not found in PATH.")) fmt.Fprintln(w, styles.Muted.Render(" Install the plugin manually:")) - line1, line2 := claudeManualInstallHint(styles) - fmt.Fprintln(w, line1) - fmt.Fprintln(w, line2) + for _, line := range claudeManualInstallHint(styles) { + fmt.Fprintln(w, line) + } } else { ctx := cmd.Context() @@ -189,9 +211,9 @@ func runClaudeSetup(cmd *cobra.Command, styles *tui.Styles) error { if err := installCmd.Run(); err != nil { fmt.Fprintln(w, styles.Warning.Render(fmt.Sprintf(" Plugin install failed: %s", err))) fmt.Fprintln(w, styles.Muted.Render(" Try manually:")) - line1, line2 := claudeManualInstallHint(styles) - fmt.Fprintln(w, line1) - fmt.Fprintln(w, line2) + for _, line := range claudeManualInstallHint(styles) { + fmt.Fprintln(w, line) + } } else { verify := harness.CheckClaudePlugin() if verify.Status == "pass" { @@ -229,32 +251,19 @@ func wizardAgents(cmd *cobra.Command, styles *tui.Styles) (agentSetupOutcome, er w := cmd.OutOrStdout() + // One pre-setup snapshot drives both the all-good gate below and the checklist + // rendered in the summary for the paths that do not run setup. + preChecks := snapshotAgentChecks(agents) + // Check if all detected agents are already fully set up // (agent checks pass AND baseline skill is installed) - allGood := baselineSkillInstalled() && len(harness.StalePluginKeys()) == 0 - if allGood { - for _, a := range agents { - if a.Checks == nil { - continue - } - for _, c := range a.Checks() { - if c.Status != "pass" { - allGood = false - break - } - } - if !allGood { - break - } - } - } - + allGood := baselineSkillInstalled() && len(harness.StalePluginKeys()) == 0 && len(issuesFromChecks(preChecks)) == 0 if allGood { for _, a := range agents { fmt.Fprintln(w, styles.RenderStatus(true, a.Name+" plugin installed")) } fmt.Fprintln(w) - return agentSetupOutcome{}, nil + return agentSetupOutcome{Checks: preChecks}, nil } fmt.Fprintln(w, styles.Heading.Render(" Step 5: Coding Agent Setup")) @@ -295,7 +304,9 @@ func wizardAgents(cmd *cobra.Command, styles *tui.Styles) (agentSetupOutcome, er } } fmt.Fprintln(w) - return agentSetupOutcome{Skipped: true}, nil //nolint:nilerr // Treat confirm error as skip (user canceled) + // Skipped carries the current snapshot for the checklist but records no + // issues, so a deliberate skip stays "complete". + return agentSetupOutcome{Skipped: true, Checks: preChecks}, nil //nolint:nilerr // Treat confirm error as skip (user canceled) } fmt.Fprintln(w) @@ -321,12 +332,15 @@ func wizardAgents(cmd *cobra.Command, styles *tui.Styles) (agentSetupOutcome, er } } - // Re-check the agents after setup ran so failed installs (e.g. a plugin that - // could not be cloned) surface as issues rather than a silent "complete". - issues = append(issues, collectAgentIssues(agents)...) + // Re-snapshot the agents after setup ran so failed installs (e.g. a plugin + // that could not be cloned) surface as issues rather than a silent "complete". + // The same snapshot renders the summary checklist, so status and checklist + // can never disagree. + postChecks := snapshotAgentChecks(agents) + issues = append(issues, issuesFromChecks(postChecks)...) fmt.Fprintln(w) - return agentSetupOutcome{Issues: issues}, nil + return agentSetupOutcome{Checks: postChecks, Issues: issues}, nil } // runClaudeSetupNonInteractive attempts plugin install without prompts (for --json/--agent mode). @@ -495,10 +509,17 @@ func validPluginScope(scope string) bool { } } -// claudeManualInstallHint returns the two-line manual install instructions. -func claudeManualInstallHint(styles *tui.Styles) (string, string) { - return styles.Bold.Render(fmt.Sprintf(" claude plugin marketplace add %s", harness.ClaudeMarketplaceSource)), - styles.Bold.Render(fmt.Sprintf(" claude plugin install %s", harness.ClaudeExpectedPluginKey)) +// claudeManualInstallHint returns the manual install instructions. The +// `marketplace update` step is essential: `marketplace add` no-ops on an +// already-registered marketplace, so without the update a stale SSH-shorthand +// entry would survive and the manual install would clone over SSH and fail the +// same way (issue #417). +func claudeManualInstallHint(styles *tui.Styles) []string { + return []string{ + styles.Bold.Render(fmt.Sprintf(" claude plugin marketplace add %s", harness.ClaudeMarketplaceSource)), + styles.Bold.Render(fmt.Sprintf(" claude plugin marketplace update %s", harness.ClaudeMarketplaceName)), + styles.Bold.Render(fmt.Sprintf(" claude plugin install %s", harness.ClaudeExpectedPluginKey)), + } } // newSetupAgentCmds generates `setup ` subcommands from the registry. diff --git a/internal/commands/wizard_test.go b/internal/commands/wizard_test.go index 0fd23d524..f170f5772 100644 --- a/internal/commands/wizard_test.go +++ b/internal/commands/wizard_test.go @@ -113,22 +113,23 @@ func TestSuccessHeadline(t *testing.T) { assert.Equal(t, "Setup finished — 2 steps need attention", successHeadline("incomplete", 2)) } -// TestStatusFromOutcome verifies a deliberate skip stays complete while real -// issues mark the run incomplete. +// TestStatusFromOutcome verifies issues are authoritative: any observed failure +// marks the run incomplete, and Skipped never suppresses one. A deliberate skip +// records no issues, so it stays complete. func TestStatusFromOutcome(t *testing.T) { assert.Equal(t, "complete", statusFromOutcome(agentSetupOutcome{})) assert.Equal(t, "complete", statusFromOutcome(agentSetupOutcome{Skipped: true})) - assert.Equal(t, "complete", + // Skipped must not suppress a real failure. + assert.Equal(t, "incomplete", statusFromOutcome(agentSetupOutcome{Skipped: true, Issues: []agentIssue{{Check: "x"}}})) assert.Equal(t, "incomplete", statusFromOutcome(agentSetupOutcome{Issues: []agentIssue{{Check: "Claude Code Plugin"}}})) } -// TestCollectAgentIssues verifies non-passing checks become issues carrying the -// agent name and the check's own hint, and passing checks are ignored. Agents -// are supplied directly so the test never mutates the global registry. -func TestCollectAgentIssues(t *testing.T) { - agents := []harness.AgentInfo{ +// fakeAgents builds a detected-agent set with one failing Claude plugin check, +// supplied directly so tests never mutate the global registry. +func fakeAgents() []harness.AgentInfo { + return []harness.AgentInfo{ { Name: "Claude Code", Checks: func() []*harness.StatusCheck { @@ -144,14 +145,53 @@ func TestCollectAgentIssues(t *testing.T) { }, {Name: "NoChecks", Checks: nil}, } +} + +// TestSnapshotAndIssues verifies one snapshot captures every check, and +// issuesFromChecks keeps only the non-passing ones with their agent + hint. +func TestSnapshotAndIssues(t *testing.T) { + checks := snapshotAgentChecks(fakeAgents()) + require.Len(t, checks, 3) // skill(pass) + plugin(fail) + codex(pass); NoChecks contributes none - issues := collectAgentIssues(agents) + issues := issuesFromChecks(checks) require.Len(t, issues, 1) assert.Equal(t, "Claude Code", issues[0].Agent) assert.Equal(t, "Claude Code Plugin", issues[0].Check) assert.Equal(t, "Run: basecamp setup claude", issues[0].Hint) } +// TestShowSuccessIncomplete renders the real summary and proves it does not claim +// completion, surfaces the failing check's own hint, and points to doctor. +func TestShowSuccessIncomplete(t *testing.T) { + styles := tui.NewStylesWithTheme(tui.ResolveTheme(false)) + checks := snapshotAgentChecks(fakeAgents()) + issues := issuesFromChecks(checks) + + var buf bytes.Buffer + showSuccess(&buf, styles, WizardResult{Status: "incomplete", AccountID: "123"}, checks, issues) + out := buf.String() + + assert.NotContains(t, out, "Setup complete!") + assert.Contains(t, out, "Setup finished") + assert.Contains(t, out, "Run: basecamp setup claude") // agent-specific hint + assert.Contains(t, out, "basecamp doctor") +} + +// TestShowSuccessComplete verifies the healthy path keeps the completion banner +// and prints no remediation block. +func TestShowSuccessComplete(t *testing.T) { + styles := tui.NewStylesWithTheme(tui.ResolveTheme(false)) + checks := []agentCheck{{Agent: "Claude Code", Name: "Claude Code Plugin", Status: "pass"}} + + var buf bytes.Buffer + showSuccess(&buf, styles, WizardResult{Status: "complete", AccountID: "123"}, checks, nil) + out := buf.String() + + assert.Contains(t, out, "Setup complete!") + assert.NotContains(t, out, "Some steps need attention") + assert.NotContains(t, out, "basecamp doctor") +} + // TestWizardBreadcrumbs verifies breadcrumb generation based on wizard outcome. func TestWizardBreadcrumbs(t *testing.T) { t.Run("with project", func(t *testing.T) { @@ -568,9 +608,49 @@ func TestSetupClaudeNonInteractiveRefreshesMarketplace(t *testing.T) { calls, readErr := os.ReadFile(logFile) require.NoError(t, readErr) - assert.Contains(t, string(calls), "plugin marketplace update 37signals", - "fresh install should refresh the marketplace cache before installing") - assert.Contains(t, string(calls), "plugin install basecamp@37signals") + // The refresh must land between registering and installing: add → update → + // install. `add` no-ops on a stale cache, so only the update refreshes it to + // the HTTPS source before install (issue #417). + assertCallOrder(t, string(calls), + "plugin marketplace add basecamp/claude-plugins", + "plugin marketplace update 37signals", + "plugin install basecamp@37signals") +} + +// TestRunClaudeSetupInteractiveRefreshOrder covers the interactive install path +// (runClaudeSetup) — a separate set of call sites from the non-interactive path — +// and asserts the same add → update → install ordering (issue #417). +func TestRunClaudeSetupInteractiveRefreshOrder(t *testing.T) { + t.Setenv("BASECAMP_NO_KEYRING", "1") + home := t.TempDir() + t.Setenv("HOME", home) + + // ~/.claude exists (Claude detected), no plugin installed → fresh install. + require.NoError(t, os.MkdirAll(filepath.Join(home, ".claude"), 0o755)) + + binDir := filepath.Join(home, "bin") + require.NoError(t, os.MkdirAll(binDir, 0o755)) + logFile := filepath.Join(home, "claude-calls.log") + stubScript := "#!/bin/sh\n" + + "echo \"$*\" >> \"" + logFile + "\"\n" + + "exit 0\n" + require.NoError(t, os.WriteFile(filepath.Join(binDir, "claude"), []byte(stubScript), 0o755)) //nolint:gosec // G306: test helper + t.Setenv("PATH", binDir) + + styles := tui.NewStylesWithTheme(tui.ResolveTheme(false)) + cmd := &cobra.Command{} + cmd.SetContext(context.Background()) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + require.NoError(t, runClaudeSetup(cmd, styles)) + + calls, readErr := os.ReadFile(logFile) + require.NoError(t, readErr) + assertCallOrder(t, string(calls), + "plugin marketplace add basecamp/claude-plugins", + "plugin marketplace update 37signals", + "plugin install basecamp@37signals") } // TestJoinNames verifies name joining with commas and "and". From cfd575fb34bacd0bf8e97f33c24e389cd30a00d2 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Wed, 22 Jul 2026 14:23:04 -0700 Subject: [PATCH 3/3] Bound marketplace refresh, catch surviving stale plugins, coherent skip UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the next review round: - Bound the marketplace refresh with a 60s child context so a stalled clone can't hang setup; install still proceeds on timeout. - Re-check stale plugin keys after handlers (claudeStaleIssues): a current- plugin health check can pass while stale entries survive removal, which previously still reported complete. Leftover stale keys now mark the run incomplete. - Skip renders coherently: a declined agent setup shows "Coding agent setup skipped" instead of red failing checks under a "complete" headline. - Remediation no longer double-names agents whose check already carries the name ("Claude Code Plugin", not "Claude Code — Claude Code Plugin"). - Doc/comment accuracy: describe issues as snapshot failures plus standalone setup failures; drop a stale step number. - Tests: rendered skip-path summary; stale post-condition → incomplete; exact add → update → install ordering on the reinstall path too. --- internal/commands/wizard.go | 30 ++++++++++++----- internal/commands/wizard_agents.go | 24 ++++++++++++- internal/commands/wizard_test.go | 54 ++++++++++++++++++++++++++---- 3 files changed, 92 insertions(+), 16 deletions(-) diff --git a/internal/commands/wizard.go b/internal/commands/wizard.go index 5b2372eb0..827dd6272 100644 --- a/internal/commands/wizard.go +++ b/internal/commands/wizard.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "os" + "strings" "charm.land/lipgloss/v2" "github.com/spf13/cobra" @@ -101,7 +102,7 @@ func runWizard(cmd *cobra.Command, app *appctx.App) error { configScope := wizardSaveConfig(cmd.OutOrStdout(), styles, accountID, projectID) result.ConfigScope = configScope - // Step 6: Coding agent integration + // Coding agent integration agentOutcome, err := wizardAgents(cmd, styles) if err != nil { return err @@ -117,7 +118,7 @@ func runWizard(cmd *cobra.Command, app *appctx.App) error { // Interactive mode shows the rich checklist directly; non-interactive // or machine-output mode delegates to app.OK which renders the structured envelope. if app.IsInteractive() && !app.IsMachineOutput() { - showSuccess(cmd.OutOrStdout(), styles, result, agentOutcome.Checks, agentOutcome.Issues) + showSuccess(cmd.OutOrStdout(), styles, result, agentOutcome.Checks, agentOutcome.Issues, agentOutcome.Skipped) return nil } @@ -323,10 +324,14 @@ func successHeadline(status string, issueCount int) string { return fmt.Sprintf("Setup finished — %d steps need attention", issueCount) } -// showSuccess displays the completion summary with example commands. checks and -// issues come from one post-setup snapshot so the headline, checklist, and -// remediation are always consistent. -func showSuccess(w io.Writer, styles *tui.Styles, result WizardResult, checks []agentCheck, issues []agentIssue) { +// showSuccess displays the completion summary with example commands. checks is +// the agent-health snapshot rendered as the checklist; issues holds every +// unresolved problem — snapshot failures plus standalone setup failures such as +// the baseline skill install or surviving stale entries — and drives the +// headline and remediation. When the user skipped agent setup the checks are +// reported as skipped rather than as failures, so the summary never shows red +// checks under a "complete" headline. +func showSuccess(w io.Writer, styles *tui.Styles, result WizardResult, checks []agentCheck, issues []agentIssue, skipped bool) { divider := styles.Muted.Render("─────────────────────────────────") headlineStyle := styles.Success @@ -354,8 +359,12 @@ func showSuccess(w io.Writer, styles *tui.Styles, result WizardResult, checks [] if result.ConfigScope != "" { fmt.Fprintln(w, styles.RenderStatus(true, fmt.Sprintf("Config saved (%s)", result.ConfigScope))) } - for _, check := range checks { - fmt.Fprintln(w, styles.RenderStatus(check.Status == "pass", check.Name)) + if skipped { + fmt.Fprintln(w, styles.Muted.Render(" Coding agent setup skipped — run: basecamp setup")) + } else { + for _, check := range checks { + fmt.Fprintln(w, styles.RenderStatus(check.Status == "pass", check.Name)) + } } fmt.Fprintln(w) @@ -365,8 +374,11 @@ func showSuccess(w io.Writer, styles *tui.Styles, result WizardResult, checks [] if len(issues) > 0 { fmt.Fprintln(w, styles.Body.Render(" Some steps need attention:")) for _, issue := range issues { + // Check names usually already carry the agent (e.g. "Claude Code + // Plugin"); only prefix when they don't, to avoid "Claude Code — + // Claude Code Plugin". label := issue.Check - if issue.Agent != "" { + if issue.Agent != "" && !strings.HasPrefix(issue.Check, issue.Agent) { label = issue.Agent + " — " + issue.Check } line := " " + label diff --git a/internal/commands/wizard_agents.go b/internal/commands/wizard_agents.go index 88b0525cc..f51b2e558 100644 --- a/internal/commands/wizard_agents.go +++ b/internal/commands/wizard_agents.go @@ -9,6 +9,7 @@ import ( "os/exec" "path/filepath" "strings" + "time" "github.com/spf13/cobra" @@ -99,13 +100,19 @@ func statusFromOutcome(o agentSetupOutcome) string { return "complete" } +// claudeMarketplaceTimeout bounds the marketplace refresh so a hung clone can't +// stall setup indefinitely. +const claudeMarketplaceTimeout = 60 * time.Second + // refreshClaudeMarketplace refreshes the 37signals marketplace cache from source // before an install. `claude plugin marketplace add` no-ops on an // already-registered marketplace, so a pre-existing entry that still declares the // SSH `source: github` shorthand would otherwise strand `claude plugin install` // on an SSH clone. `marketplace update` re-clones and picks up the current HTTPS // `source: url`. Best-effort: install still proceeds if the refresh fails. -func refreshClaudeMarketplace(ctx context.Context, claudePath string, stdout, stderr io.Writer) { +func refreshClaudeMarketplace(parent context.Context, claudePath string, stdout, stderr io.Writer) { + ctx, cancel := context.WithTimeout(parent, claudeMarketplaceTimeout) + defer cancel() cmd := exec.CommandContext(ctx, claudePath, "plugin", "marketplace", "update", harness.ClaudeMarketplaceName) //nolint:gosec // G204: claudePath from FindClaudeBinary cmd.Stdout = stdout cmd.Stderr = stderr @@ -339,10 +346,25 @@ func wizardAgents(cmd *cobra.Command, styles *tui.Styles) (agentSetupOutcome, er postChecks := snapshotAgentChecks(agents) issues = append(issues, issuesFromChecks(postChecks)...) + // Post-condition: the all-good gate also requires stale-plugin cleanup, but a + // current-plugin health check can pass while stale entries survive (removal + // failed). Re-check so leftover stale keys mark the run incomplete instead of + // reporting "complete". + issues = append(issues, claudeStaleIssues()...) + fmt.Fprintln(w) return agentSetupOutcome{Checks: postChecks, Issues: issues}, nil } +// claudeStaleIssues reports leftover stale plugin entries as an issue so the +// wizard marks the run incomplete when cleanup could not remove them. +func claudeStaleIssues() []agentIssue { + if len(harness.StalePluginKeys()) == 0 { + return nil + } + return []agentIssue{{Agent: "Claude Code", Check: "Stale plugin entries", Hint: "Run: basecamp doctor"}} +} + // runClaudeSetupNonInteractive attempts plugin install without prompts (for --json/--agent mode). func runClaudeSetupNonInteractive(cmd *cobra.Command) error { var errs []string diff --git a/internal/commands/wizard_test.go b/internal/commands/wizard_test.go index f170f5772..dc14c5669 100644 --- a/internal/commands/wizard_test.go +++ b/internal/commands/wizard_test.go @@ -168,7 +168,7 @@ func TestShowSuccessIncomplete(t *testing.T) { issues := issuesFromChecks(checks) var buf bytes.Buffer - showSuccess(&buf, styles, WizardResult{Status: "incomplete", AccountID: "123"}, checks, issues) + showSuccess(&buf, styles, WizardResult{Status: "incomplete", AccountID: "123"}, checks, issues, false) out := buf.String() assert.NotContains(t, out, "Setup complete!") @@ -177,6 +177,45 @@ func TestShowSuccessIncomplete(t *testing.T) { assert.Contains(t, out, "basecamp doctor") } +// TestClaudeStaleIssues verifies surviving stale plugin entries become an issue +// (so status goes incomplete) and a clean home yields none. +func TestClaudeStaleIssues(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + assert.Empty(t, claudeStaleIssues(), "no stale file → no issues") + + pluginDir := filepath.Join(home, ".claude", "plugins") + require.NoError(t, os.MkdirAll(pluginDir, 0o755)) + require.NoError(t, os.WriteFile( + filepath.Join(pluginDir, "installed_plugins.json"), + []byte(`{"version":2,"plugins":{"basecamp@basecamp":[{"scope":"user","version":"0.1.0"}]}}`), + 0o644)) + + issues := claudeStaleIssues() + require.Len(t, issues, 1) + assert.Equal(t, "Claude Code", issues[0].Agent) + assert.Equal(t, "incomplete", statusFromOutcome(agentSetupOutcome{Issues: issues})) +} + +// TestShowSuccessSkipped verifies a deliberate skip renders coherently: the +// completion banner stays, agent setup shows as skipped (not red failing +// checks), and there is no remediation block. +func TestShowSuccessSkipped(t *testing.T) { + styles := tui.NewStylesWithTheme(tui.ResolveTheme(false)) + // preChecks on a skip can include a failing plugin check (agent never set up). + checks := snapshotAgentChecks(fakeAgents()) + + var buf bytes.Buffer + showSuccess(&buf, styles, WizardResult{Status: "complete", AccountID: "123"}, checks, nil, true) + out := buf.String() + + assert.Contains(t, out, "Setup complete!") + assert.Contains(t, out, "skipped") + assert.NotContains(t, out, "Claude Code Plugin") // no red failing check rendered + assert.NotContains(t, out, "basecamp doctor") // no remediation on a deliberate skip +} + // TestShowSuccessComplete verifies the healthy path keeps the completion banner // and prints no remediation block. func TestShowSuccessComplete(t *testing.T) { @@ -184,7 +223,7 @@ func TestShowSuccessComplete(t *testing.T) { checks := []agentCheck{{Agent: "Claude Code", Name: "Claude Code Plugin", Status: "pass"}} var buf bytes.Buffer - showSuccess(&buf, styles, WizardResult{Status: "complete", AccountID: "123"}, checks, nil) + showSuccess(&buf, styles, WizardResult{Status: "complete", AccountID: "123"}, checks, nil, false) out := buf.String() assert.Contains(t, out, "Setup complete!") @@ -567,11 +606,14 @@ func TestSetupClaudeNonInteractiveScopeAwareReinstall(t *testing.T) { // Verify install calls preserve scopes from stale entries calls, readErr := os.ReadFile(logFile) require.NoError(t, readErr) - assert.Contains(t, string(calls), "plugin install basecamp@37signals --scope user") assert.Contains(t, string(calls), "plugin install basecamp@37signals --scope project") - // Refresh the marketplace cache before reinstalling so a stale SSH-shorthand - // entry is replaced with the current HTTPS source (issue #417). - assert.Contains(t, string(calls), "plugin marketplace update 37signals") + // The reinstall path must also refresh before installing: add → update → + // scoped install, so a stale SSH-shorthand entry is replaced with the current + // HTTPS source first (issue #417). + assertCallOrder(t, string(calls), + "plugin marketplace add basecamp/claude-plugins", + "plugin marketplace update 37signals", + "plugin install basecamp@37signals --scope user") } // TestSetupClaudeNonInteractiveRefreshesMarketplace verifies the fresh-install