diff --git a/go.mod b/go.mod index aad068e71..f91f96996 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( charm.land/bubbles/v2 v2.2.1 charm.land/bubbletea/v2 v2.0.10 charm.land/lipgloss/v2 v2.0.6 - github.com/basecamp/basecamp-sdk/go v0.23.1-0.20261006222256-db402d937b74 + github.com/basecamp/basecamp-sdk/go v0.24.0 github.com/basecamp/cli v0.2.2-0.20260828230226-767413fc712d github.com/basecamp/mcp v0.0.0-20260828100356-2d6f44b51e9d github.com/basecamp/surfguard/go v0.1.0 diff --git a/go.sum b/go.sum index 0f60d40e0..04f38410f 100644 --- a/go.sum +++ b/go.sum @@ -89,8 +89,8 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/basecamp/actioncable-go v1.2.0 h1:t0MxlfMTcmaGXZ5ztgKRs7ZiY1MwXtBRwSSSAh1ofVg= github.com/basecamp/actioncable-go v1.2.0/go.mod h1:ezaV5z1GXQAsqyejqTs6wCFl2D8Wj+COLQkHc/kwoRs= -github.com/basecamp/basecamp-sdk/go v0.23.1-0.20261006222256-db402d937b74 h1:FEJli6E+/buVYRoFoFkE/r+ik6hHAyMmRT9zIavNypI= -github.com/basecamp/basecamp-sdk/go v0.23.1-0.20261006222256-db402d937b74/go.mod h1:vdplxIcZMYSAIOvVm7AOIgLBUAPoJHESStTh9mF/ww4= +github.com/basecamp/basecamp-sdk/go v0.24.0 h1:kJGxd96qgtp5IH9eAVkzoo8LiHs9Raahcb5dWUTTSEI= +github.com/basecamp/basecamp-sdk/go v0.24.0/go.mod h1:vdplxIcZMYSAIOvVm7AOIgLBUAPoJHESStTh9mF/ww4= github.com/basecamp/cli v0.2.2-0.20260828230226-767413fc712d h1:jAzDrCCzDpIwhbFT1xVVs0z2xpXoDEkomHfKB2bUUp8= github.com/basecamp/cli v0.2.2-0.20260828230226-767413fc712d/go.mod h1:iTBTaWvsPEFIcZfkxQHEfISyJ6sZ7036K6bNx0RY3EE= github.com/basecamp/mcp v0.0.0-20260828100356-2d6f44b51e9d h1:zEQVGq1x1nhKMZ2TudFAcSJ32CHT8richI1vQakIKz4= diff --git a/internal/commands/wizard_agents.go b/internal/commands/wizard_agents.go index 0cdcc649d..a5800a82c 100644 --- a/internal/commands/wizard_agents.go +++ b/internal/commands/wizard_agents.go @@ -207,11 +207,27 @@ func runClaudeSetup(cmd *cobra.Command, styles *tui.Styles) error { w := cmd.OutOrStdout() // Clean up stale plugin entries from old marketplaces before checking status. + // The pre-rename basecamp@37signals copy is different: install its replacement + // first, per scope, so a failed marketplace fetch never removes the working copy. var reinstallScopes []string if stalePlugins := harness.StalePluginKeys(); len(stalePlugins) > 0 { if claudePath := harness.FindClaudeBinary(); claudePath != "" { - removed, scopes := removeStaleClaudePlugins(cmd.Context(), claudePath, stalePlugins) - reinstallScopes = scopes + ctx := cmd.Context() + preinstalled := map[string]bool{} + if hasStaleClaudePlugin(stalePlugins, harness.ClaudeLegacyPluginKey) { + mktCmd := exec.CommandContext(ctx, claudePath, "plugin", "marketplace", "add", harness.ClaudeMarketplaceSource) //nolint:gosec // G204: claudePath from FindClaudeBinary + mktCmd.Stdout = w + mktCmd.Stderr = cmd.ErrOrStderr() + _ = mktCmd.Run() + refreshClaudeMarketplace(ctx, claudePath, w, cmd.ErrOrStderr()) + stalePlugins, preinstalled = preinstallRenamedClaudePlugin(ctx, claudePath, stalePlugins, w, cmd.ErrOrStderr()) + } + removed, scopes := removeStaleClaudePlugins(ctx, claudePath, stalePlugins) + for _, scope := range scopes { + if !preinstalled[scope] { + reinstallScopes = append(reinstallScopes, scope) + } + } for _, key := range removed { fmt.Fprintln(w, styles.RenderStatus(true, fmt.Sprintf("Removed stale plugin %s", key))) } @@ -448,10 +464,27 @@ func runClaudeSetupNonInteractive(cmd *cobra.Command) error { var errs []string // Clean up stale plugin entries from old marketplaces before checking status. + // Install the replacement for the pre-rename key before removing that key, + // so a failed install leaves the existing plugin working. var reinstallScopes []string if stalePlugins := harness.StalePluginKeys(); len(stalePlugins) > 0 { if claudePath := harness.FindClaudeBinary(); claudePath != "" { - _, reinstallScopes = removeStaleClaudePlugins(cmd.Context(), claudePath, stalePlugins) + ctx := cmd.Context() + preinstalled := map[string]bool{} + if hasStaleClaudePlugin(stalePlugins, harness.ClaudeLegacyPluginKey) { + w := cmd.ErrOrStderr() + 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) + stalePlugins, preinstalled = preinstallRenamedClaudePlugin(ctx, claudePath, stalePlugins, nil, w) + } + _, scopes := removeStaleClaudePlugins(ctx, claudePath, stalePlugins) + for _, scope := range scopes { + if !preinstalled[scope] { + reinstallScopes = append(reinstallScopes, scope) + } + } } } @@ -511,10 +544,58 @@ func runClaudeSetupNonInteractive(cmd *cobra.Command) error { return nil } +func hasStaleClaudePlugin(plugins []harness.StalePlugin, key string) bool { + for _, plugin := range plugins { + if plugin.Key == key { + return true + } + } + return false +} + +// preinstallRenamedClaudePlugin installs basecamp-cli at each known valid scope +// occupied by basecamp@37signals. It returns only entries and scopes that are +// now safe to remove. A failed install, an unknown scope, or an old file format +// with no scope leaves the working legacy copy untouched. +func preinstallRenamedClaudePlugin(ctx context.Context, claudePath string, plugins []harness.StalePlugin, stdout, stderr io.Writer) ([]harness.StalePlugin, map[string]bool) { + removable := make([]harness.StalePlugin, 0, len(plugins)) + installed := map[string]bool{} + + for _, plugin := range plugins { + if plugin.Key != harness.ClaudeLegacyPluginKey { + removable = append(removable, plugin) + continue + } + + var scopes []string + for _, scope := range plugin.Scopes { + if !validPluginScope(scope) || installed[scope] { + continue + } + args := []string{"plugin", "install", harness.ClaudeExpectedPluginKey, "--scope", scope} + installCmd := exec.CommandContext(ctx, claudePath, args...) //nolint:gosec // G204: claudePath from FindClaudeBinary + installCmd.Stdout = stdout + installCmd.Stderr = stderr + if err := installCmd.Run(); err != nil { + continue + } + installed[scope] = true + scopes = append(scopes, scope) + } + if len(scopes) > 0 { + removable = append(removable, harness.StalePlugin{Key: plugin.Key, Scopes: scopes}) + } + } + + return removable, installed +} + // removeStaleClaudePlugins uninstalls plugin entries from old/dead marketplaces. // When scope information is available, each scope is uninstalled explicitly. // Otherwise, we retry uninstall until it fails (entry gone) or a safety cap of -// 10 iterations is reached. +// 10 iterations is reached. The pre-rename basecamp@37signals entry reaches +// here only for scopes where preinstallRenamedClaudePlugin installed its +// replacement successfully. func removeStaleClaudePlugins(ctx context.Context, claudePath string, plugins []harness.StalePlugin) ([]string, []string) { var removed []string scopeSeen := map[string]bool{} diff --git a/internal/commands/wizard_agents_test.go b/internal/commands/wizard_agents_test.go index c0e28ff41..9d5960161 100644 --- a/internal/commands/wizard_agents_test.go +++ b/internal/commands/wizard_agents_test.go @@ -77,6 +77,54 @@ func readClaudeCalls(t *testing.T, claudePath string) string { return string(data) } +func stubClaudeMigration(t *testing.T, failedInstallScope string) string { + t.Helper() + dir := t.TempDir() + logFile := filepath.Join(dir, "calls.log") + script := "#!/bin/sh\n" + + "echo \"$*\" >> \"" + logFile + "\"\n" + + "if [ \"$1 $2\" = \"plugin install\" ] && [ \"$5\" = \"" + failedInstallScope + "\" ]; then exit 1; fi\n" + + "exit 0\n" + path := filepath.Join(dir, "claude") + require.NoError(t, os.WriteFile(path, []byte(script), 0o755)) //nolint:gosec // G306: test helper + return path +} + +func TestRenamedClaudePluginIsInstalledBeforeLegacyCopyIsRemoved(t *testing.T) { + claude := stubClaudeMigration(t, "") + plugins := []harness.StalePlugin{{Key: harness.ClaudeLegacyPluginKey, Scopes: []string{"user", "project"}}} + + removable, installed := preinstallRenamedClaudePlugin(context.Background(), claude, plugins, nil, nil) + removed, reinstall := removeStaleClaudePlugins(context.Background(), claude, removable) + + calls := readClaudeCalls(t, claude) + assertCallOrder(t, calls, + "plugin install basecamp-cli@37signals --scope user", + "plugin uninstall basecamp@37signals --scope user") + assertCallOrder(t, calls, + "plugin install basecamp-cli@37signals --scope project", + "plugin uninstall basecamp@37signals --scope project") + assert.Equal(t, map[string]bool{"user": true, "project": true}, installed) + assert.Equal(t, []string{harness.ClaudeLegacyPluginKey}, removed) + assert.ElementsMatch(t, []string{"user", "project"}, reinstall) +} + +func TestRenamedClaudePluginKeepsLegacyCopyWhenInstallFails(t *testing.T) { + claude := stubClaudeMigration(t, "project") + plugins := []harness.StalePlugin{{Key: harness.ClaudeLegacyPluginKey, Scopes: []string{"user", "project"}}} + + removable, installed := preinstallRenamedClaudePlugin(context.Background(), claude, plugins, nil, nil) + _, _ = removeStaleClaudePlugins(context.Background(), claude, removable) + + calls := readClaudeCalls(t, claude) + assert.Contains(t, calls, "plugin install basecamp-cli@37signals --scope project") + assert.NotContains(t, calls, "plugin uninstall basecamp@37signals --scope project", + "a failed replacement must leave the working legacy scope installed") + assert.Contains(t, calls, "plugin uninstall basecamp@37signals --scope user", + "a successfully replaced scope can be removed") + assert.Equal(t, map[string]bool{"user": true}, installed) +} + // TestRemoveStaleClaudePluginsAllScopesInvalid verifies the YL7 fix: when every // recorded scope fails validPluginScope (no scoped uninstall is attempted), we // fall back to an unscoped removal so the plugin isn't silently left installed. diff --git a/internal/mcpserver/model/PROVENANCE.json b/internal/mcpserver/model/PROVENANCE.json index 7e1273787..f81074cce 100644 --- a/internal/mcpserver/model/PROVENANCE.json +++ b/internal/mcpserver/model/PROVENANCE.json @@ -1,7 +1,7 @@ { "source": "github.com/basecamp/basecamp-sdk", - "commit": "db402d937b7482fd6894249613c713a1ca89edd6", - "ref": "go/v0.23.0-9-gdb402d93", + "commit": "490e10dcea4a933689ccaafa9214c03365fff969", + "ref": "v0.24.0", "files": ["behavior-model.json", "openapi.json"], "synced_by": "scripts/sync-mcp-model.sh", "patches": "binary-upload operations dropped because the toolkit refuses their non-JSON bodies (EXCLUDED_OPERATIONS) and the stream-ticket mint dropped by policy (POLICY_EXCLUDED_OPERATIONS); no tag patches applied (PATCHED_TAGS is empty — the export tags every operation) — see the sync script" diff --git a/internal/version/sdk-provenance.json b/internal/version/sdk-provenance.json index 6182d318d..850975797 100644 --- a/internal/version/sdk-provenance.json +++ b/internal/version/sdk-provenance.json @@ -1,9 +1,9 @@ { "sdk": { "module": "github.com/basecamp/basecamp-sdk/go", - "version": "v0.23.1-0.20261006222256-db402d937b74", - "revision": "db402d937b74", - "updated_at": "2026-10-06T22:22:56Z" + "version": "v0.24.0", + "revision": "490e10dcea4a", + "updated_at": "2026-10-07T18:54:01Z" }, "api": { "repo": "basecamp/bc3", diff --git a/nix/package.nix b/nix/package.nix index 8c3cf20bd..2cfee7d0f 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -8,7 +8,7 @@ buildGoModule.override { go = go_1_26; } (finalAttrs: { src = lib.cleanSource ./..; # To update: set to lib.fakeHash, run `nix build`, use the hash from the error. - vendorHash = "sha256-gtSrSu4ZF/IerLLgKx0zjEwRDKYjYKBOY50YU1swy6Q="; + vendorHash = "sha256-KJ3/Lr2Zdue5yidZKLvETLHmoqUYmupiUn594aImSyE="; subPackages = [ "cmd/basecamp" ];