Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
89 changes: 85 additions & 4 deletions internal/commands/wizard_agents.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)))
}
Expand Down Expand Up @@ -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)
}
}
}
}

Expand Down Expand Up @@ -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{}
Expand Down
48 changes: 48 additions & 0 deletions internal/commands/wizard_agents_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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" +

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: stubClaudeMigration(t, "") does not mean "no scope fails": [ "$5" = "" ] is true whenever the fifth positional is empty or absent, so any plugin install invocation with fewer than five args is forced to exit 1. Today preinstallRenamedClaudePlugin always installs with --scope, so this is latent, but the condition contradicts the helper's no-failure contract and will silently fail a future unscoped install. Guard the scope comparison with a non-empty check.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At internal/commands/wizard_agents_test.go, line 86:

<comment>`stubClaudeMigration(t, "")` does not mean "no scope fails": `[ "$5" = "" ]` is true whenever the fifth positional is empty or absent, so any `plugin install` invocation with fewer than five args is forced to exit 1. Today `preinstallRenamedClaudePlugin` always installs with `--scope`, so this is latent, but the condition contradicts the helper's no-failure contract and will silently fail a future unscoped install. Guard the scope comparison with a non-empty check.</comment>

<file context>
@@ -77,6 +77,54 @@ func readClaudeCalls(t *testing.T, claudePath string) string {
+	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")
</file context>

"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.
Expand Down
4 changes: 2 additions & 2 deletions internal/mcpserver/model/PROVENANCE.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
6 changes: 3 additions & 3 deletions internal/version/sdk-provenance.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion nix/package.nix
Original file line number Diff line number Diff line change
Expand Up @@ -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" ];

Expand Down
Loading