From 239450adb7550129323c41754efdd4235728b857 Mon Sep 17 00:00:00 2001 From: dustinvannoy-db Date: Fri, 24 Jul 2026 13:25:52 -0700 Subject: [PATCH 1/2] aitools: recover from a removed built-in marketplace on plugin install The proactive `plugin marketplace update` handles a stale local copy of Claude's built-in claude-plugins-official marketplace, but a user who removed it entirely still hits "Plugin not found in marketplace" at install time. Detect that specific failure (marketplaceMissing) and, only then, offer to re-add and refresh the marketplace and retry the install once. Auth and network failures are left untouched so we never prompt to re-add a marketplace that is present. In a non-interactive session (or when the user declines or the retry fails), surface a BlockedError naming the exact repair commands, rendered from a single repairCommands() helper shared with the prompt so the two can't drift apart. Co-authored-by: Isaac --- libs/aitools/agents/agents.go | 19 +++- libs/aitools/installer/plugin.go | 121 +++++++++++++++++++- libs/aitools/installer/plugin_test.go | 155 ++++++++++++++++++++++++-- 3 files changed, 280 insertions(+), 15 deletions(-) diff --git a/libs/aitools/agents/agents.go b/libs/aitools/agents/agents.go index 572282da03b..d51da28a869 100644 --- a/libs/aitools/agents/agents.go +++ b/libs/aitools/agents/agents.go @@ -23,6 +23,13 @@ type PluginSpec struct { // (e.g. "databricks/databricks-agent-skills"). Empty marks a built-in // marketplace that must not be added or de-registered. Source string + // BuiltinAddSource is the ` plugin marketplace add` argument used only + // to repair a built-in marketplace (Source empty) that the install found + // missing or out of date (e.g. "anthropics/claude-plugins-official"). It is a + // recovery-only hint: unlike Source it never triggers a routine add or a + // de-register on uninstall, so the built-in marketplace stays shared infra we + // don't claim to own. Empty when the built-in marketplace can't be re-added. + BuiltinAddSource string } // Agent defines a supported coding agent. @@ -127,6 +134,11 @@ const ( // our own marketplace for Claude. An empty PluginSpec.Source marks a built-in // marketplace that must not be added. claudeOfficialMarketplace = "claude-plugins-official" + // claudeOfficialSource re-adds the built-in marketplace when a user has removed + // it or their local copy is out of date (the only case where the CLI touches a + // built-in marketplace). Per Claude Code's docs this is the `owner/repo` the + // `plugin marketplace add` command expects for the official marketplace. + claudeOfficialSource = "anthropics/claude-plugins-official" ) // databricksPlugin returns the shared plugin descriptor for an agent that @@ -144,9 +156,10 @@ func databricksPlugin() *PluginSpec { // the CLI doesn't register a separate databricks-agent-skills marketplace for it. func claudePlugin() *PluginSpec { return &PluginSpec{ - Marketplace: claudeOfficialMarketplace, - ID: databricksPluginID, - Source: "", + Marketplace: claudeOfficialMarketplace, + ID: databricksPluginID, + Source: "", + BuiltinAddSource: claudeOfficialSource, } } diff --git a/libs/aitools/installer/plugin.go b/libs/aitools/installer/plugin.go index 13fc9475e31..abfd7c6b156 100644 --- a/libs/aitools/installer/plugin.go +++ b/libs/aitools/installer/plugin.go @@ -10,6 +10,7 @@ import ( "time" "github.com/databricks/cli/libs/aitools/agents" + "github.com/databricks/cli/libs/cmdio" "github.com/databricks/cli/libs/log" "github.com/databricks/cli/libs/process" ) @@ -106,9 +107,10 @@ func recordedInstallTarget(agent *agents.Agent, rec PluginRecord) string { return plugin + "@" + marketplace } -// marketplaceAddArgs builds the `plugin marketplace add ` argv (sans binary). -func marketplaceAddArgs(spec *agents.PluginSpec) []string { - return []string{"plugin", "marketplace", "add", spec.Source} +// marketplaceAddArgs builds the `plugin marketplace add ` argv (sans +// binary). Routine registration passes spec.Source; recovery passes BuiltinAddSource. +func marketplaceAddArgs(source string) []string { + return []string{"plugin", "marketplace", "add", source} } // marketplaceRegistered reports whether the named marketplace is already listed @@ -253,7 +255,7 @@ func InstallPluginForAgent(ctx context.Context, agent *agents.Agent, nativeScope installedMarketplace := false if agent.Plugin.Source != "" { alreadyPresent := marketplaceRegistered(ctx, bin, agent.Plugin.Marketplace) - _, addErr := runAgentCmd(ctx, pluginCmdTimeout, prepend(bin, marketplaceAddArgs(agent.Plugin))) + _, addErr := runAgentCmd(ctx, pluginCmdTimeout, prepend(bin, marketplaceAddArgs(agent.Plugin.Source))) installedMarketplace = addErr == nil && !alreadyPresent } if args := marketplaceUpdateArgs(agent); args != nil { @@ -268,6 +270,22 @@ func InstallPluginForAgent(ctx context.Context, agent *agents.Agent, nativeScope } if _, err := runAgentCmd(ctx, pluginCmdTimeout, prepend(bin, pluginInstallArgs(agent, nativeScope))); err != nil { + // The proactive refresh above updates every registered marketplace, so a + // stale built-in copy is already handled. A user who removed the built-in + // marketplace entirely surfaces here instead, as "not found in marketplace": + // the refresh can't fail on a marketplace that is no longer registered, so + // this install step is where that case appears. Only that failure is + // repairable by re-adding the marketplace; auth or network errors are not, so + // builtinMarketplaceRepairable gates the recovery to avoid prompting to re-add + // a marketplace that is present. The repair re-adds and refreshes shared + // infrastructure; it never records ownership, so uninstall still leaves the + // built-in marketplace in place. + if builtinMarketplaceRepairable(agent, err) { + if rec, ok := recoverBuiltinMarketplace(ctx, bin, agent, nativeScope, ref, err); ok { + return rec, nil + } + return PluginRecord{}, builtinMarketplaceError(agent, err) + } // Roll back a marketplace we just added so a failed install doesn't // leave an orphaned, untracked marketplace registration behind. if installedMarketplace { @@ -287,6 +305,82 @@ func InstallPluginForAgent(ctx context.Context, agent *agents.Agent, nativeScope }, nil } +// recoverBuiltinMarketplace handles a built-in-marketplace install failure whose +// error says the marketplace is missing (a user removed it); the proactive +// refresh already covers a merely-stale copy. In an interactive session it asks +// permission, re-adds and refreshes the marketplace, and retries the install +// once. It returns (record, true) only when that retry succeeds. When the +// session is non-interactive, the user declines, or the retry still fails, it +// returns (_, false) so the caller surfaces an actionable error. +// +// The built-in marketplace is shared infrastructure: even after re-adding it we +// never set InstalledMarketplace, so uninstall never de-registers it. +func recoverBuiltinMarketplace(ctx context.Context, bin string, agent *agents.Agent, nativeScope, ref string, installErr error) (PluginRecord, bool) { + // Only prompt when there is a terminal that can answer. Code that calls + // InstallPluginForAgent without a cmdio (e.g. some tests) must not panic. + if !cmdio.HasIO(ctx) || !cmdio.IsPromptSupported(ctx) { + return PluginRecord{}, false + } + + cmdio.LogString(ctx, "") + cmdio.LogString(ctx, fmt.Sprintf("%s could not install the databricks plugin from the %q marketplace:", agent.DisplayName, agent.Plugin.Marketplace)) + cmdio.LogString(ctx, " "+stderrOf(installErr)) + cmdio.LogString(ctx, "") + cmdio.LogString(ctx, fmt.Sprintf("The %q marketplace may be missing or out of date. Add and refresh it with:", agent.Plugin.Marketplace)) + cmdio.LogString(ctx, " "+strings.Join(repairCommands(agent), "\n ")) + cmdio.LogString(ctx, "") + proceed, err := cmdio.AskYesOrNo(ctx, "Run these and retry the install?") + if err != nil || !proceed { + return PluginRecord{}, false + } + + // Re-add (fixes a removed marketplace) then refresh (fixes a stale copy). Each + // step may harmlessly fail (e.g. add when it is already present), so we only + // log at debug level and let the retried install be the real verdict. + if _, err := runAgentCmd(ctx, pluginCmdTimeout, prepend(bin, marketplaceAddArgs(agent.Plugin.BuiltinAddSource))); err != nil { + log.Debugf(ctx, "re-adding the %s marketplace failed (it may already be present): %v", agent.Plugin.Marketplace, stderrOf(err)) + } + if args := marketplaceUpdateArgs(agent); args != nil { + if _, err := runAgentCmd(ctx, pluginCmdTimeout, prepend(bin, args)); err != nil { + log.Debugf(ctx, "refreshing the %s marketplace failed: %v", agent.Plugin.Marketplace, stderrOf(err)) + } + } + if _, err := runAgentCmd(ctx, pluginCmdTimeout, prepend(bin, pluginInstallArgs(agent, nativeScope))); err != nil { + return PluginRecord{}, false + } + + return PluginRecord{ + Marketplace: agent.Plugin.Marketplace, + Plugin: agent.Plugin.ID, + Scope: nativeScope, + Version: DisplaySkillsVersion(ref), + }, true +} + +// repairCommands returns the re-add and refresh commands shared by the recovery +// prompt and the non-interactive BlockedError, so the two can't drift apart. +func repairCommands(agent *agents.Agent) []string { + return []string{ + fmt.Sprintf("%s plugin marketplace add %s", agent.Binary, agent.Plugin.BuiltinAddSource), + fmt.Sprintf("%s plugin marketplace update %s", agent.Binary, agent.Plugin.Marketplace), + } +} + +// builtinMarketplaceError wraps a built-in-marketplace install failure with the +// exact commands that repair it, so a user who wasn't prompted (non-interactive) +// or whose automatic retry failed knows the issue and the fix. +func builtinMarketplaceError(agent *agents.Agent, installErr error) error { + return &BlockedError{ + Agent: agent.Name, + Reason: ReasonInstallFailed, + Detail: fmt.Sprintf( + "%s\n\nThe %q marketplace is missing or out of date. Fix it with:\n %s\nthen re-run the install.", + stderrOf(installErr), + agent.Plugin.Marketplace, + strings.Join(repairCommands(agent), "\n ")), + } +} + // UpdatePluginForAgent updates the plugin through the agent's own CLI. The // plugin's own update handles content the release dropped, so there is no // per-skill prune for plugin agents. @@ -345,6 +439,25 @@ func claudeAlreadyDisabled(err error) bool { return strings.Contains(strings.ToLower(stderrOf(err)), "already disabled") } +// marketplaceMissing reports whether a step failed because the marketplace is +// unknown to the agent CLI (the user removed it), as opposed to an auth or +// network failure. The agent CLIs expose no error code for this, so we match the +// stderr the way claudeAlreadyDisabled does; callers use it only to decide +// whether re-adding the marketplace is worth attempting, never as the final +// verdict (the retried install is). +func marketplaceMissing(err error) bool { + s := strings.ToLower(stderrOf(err)) + return strings.Contains(s, "not found in marketplace") || strings.Contains(s, "marketplace not found") +} + +// builtinMarketplaceRepairable reports whether a failed refresh or install is the +// removed-built-in-marketplace case recoverBuiltinMarketplace can repair: the +// marketplace has no routine add (empty Source) but a known re-add source, and +// the error says the marketplace is missing rather than an unrelated failure. +func builtinMarketplaceRepairable(agent *agents.Agent, err error) bool { + return agent.Plugin.Source == "" && agent.Plugin.BuiltinAddSource != "" && marketplaceMissing(err) +} + // RecordPluginInstalls persists plugin install records into the state file for // the given CLI scope (global or project), creating state if none exists. ref // is the resolved skills release the install corresponds to. diff --git a/libs/aitools/installer/plugin_test.go b/libs/aitools/installer/plugin_test.go index 2652918c441..5884a28c7eb 100644 --- a/libs/aitools/installer/plugin_test.go +++ b/libs/aitools/installer/plugin_test.go @@ -1,9 +1,12 @@ package installer import ( + "context" "errors" + "io" "os/exec" "path/filepath" + "strings" "testing" "github.com/databricks/cli/libs/aitools/agents" @@ -41,6 +44,29 @@ func pluginAgent(name, display, binary string) *agents.Agent { func claudeAgent() *agents.Agent { return pluginAgent(agents.NameClaudeCode, "Claude Code", "claude") } func codexAgent() *agents.Agent { return pluginAgent(agents.NameCodex, "Codex CLI", "codex") } +// builtinMarketplaceAgent models Claude Code: the plugin lives in a built-in +// marketplace (empty Source) that the CLI never adds routinely, but can re-add +// via BuiltinAddSource when an install fails because it's missing or stale. +func builtinMarketplaceAgent() *agents.Agent { + return &agents.Agent{ + Name: agents.NameClaudeCode, + DisplayName: "Claude Code", + Binary: "claude", + Plugin: &agents.PluginSpec{ + Marketplace: "claude-plugins-official", + ID: "databricks", + Source: "", + BuiltinAddSource: "anthropics/claude-plugins-official", + }, + } +} + +// promptCtx returns a context whose cmdio supports prompting and answers the +// next AskYesOrNo with the given stdin line (e.g. "y\n" or "n\n"). +func promptCtx(ctx context.Context, stdin string) context.Context { + return cmdio.InContext(ctx, cmdio.NewTestIO(strings.NewReader(stdin), io.Discard, io.Discard)) +} + func noPluginAgent() *agents.Agent { return &agents.Agent{Name: agents.NameOpenCode, DisplayName: "OpenCode", Binary: "opencode"} } @@ -72,14 +98,7 @@ func TestInstallPluginForAgentBuiltinMarketplace(t *testing.T) { // An agent whose plugin lives in a built-in marketplace (empty Source) like // Claude's claude-plugins-official: install from it, never register it. - agent := &agents.Agent{ - Name: agents.NameClaudeCode, - DisplayName: "Claude Code", - Binary: "claude", - Plugin: &agents.PluginSpec{Marketplace: "claude-plugins-official", ID: "databricks", Source: ""}, - } - - rec, err := InstallPluginForAgent(ctx, agent, "user", "main") + rec, err := InstallPluginForAgent(ctx, builtinMarketplaceAgent(), "user", "main") require.NoError(t, err) assert.Equal(t, "claude-plugins-official", rec.Marketplace) assert.False(t, rec.InstalledMarketplace, "a built-in marketplace is never registered by us") @@ -92,6 +111,126 @@ func TestInstallPluginForAgentBuiltinMarketplace(t *testing.T) { assert.Contains(t, cmds, "claude plugin install databricks@claude-plugins-official --scope user") } +func TestInstallBuiltinMarketplaceRecoversAfterPrompt(t *testing.T) { + stubAgentLookPath(t, true) + ctx, stub := process.WithStub(t.Context()) + // The install fails while the marketplace is missing/stale, then succeeds after + // it's re-added and refreshed. Track the retried install via a call counter. + installs := 0 + stub.WithCallback(func(cmd *exec.Cmd) error { + if strings.Contains(strings.Join(cmd.Args, " "), "plugin install") { + installs++ + if installs == 1 { + // First install fails as it would when the marketplace is stale. + _, _ = cmd.Stderr.Write([]byte(`Plugin "databricks" not found in marketplace "claude-plugins-official"`)) + return errors.New("exit status 1") + } + } + return nil + }) + // User agrees to the repair prompt. + ctx = promptCtx(ctx, "y\n") + + rec, err := InstallPluginForAgent(ctx, builtinMarketplaceAgent(), "user", "main") + require.NoError(t, err) + assert.Equal(t, "claude-plugins-official", rec.Marketplace) + assert.False(t, rec.InstalledMarketplace, "a built-in marketplace is never recorded as ours, even after a repair") + + cmds := stub.Commands() + assert.Contains(t, cmds, "claude plugin marketplace add anthropics/claude-plugins-official") + assert.Contains(t, cmds, "claude plugin marketplace update") + assert.Equal(t, 2, installs, "the install is retried once after the repair") +} + +func TestInstallBuiltinMarketplaceUserDeclinesPrompt(t *testing.T) { + stubAgentLookPath(t, true) + ctx, stub := process.WithStub(t.Context()) + stub.WithFailureFor("plugin install", errors.New("exit status 1")) + stub.WithStderrFor("plugin install", `Plugin "databricks" not found in marketplace "claude-plugins-official"`) + stub.WithCallback(func(*exec.Cmd) error { return nil }) + // User declines the repair prompt. + ctx = promptCtx(ctx, "n\n") + + _, err := InstallPluginForAgent(ctx, builtinMarketplaceAgent(), "user", "main") + var be *BlockedError + require.ErrorAs(t, err, &be) + assert.Equal(t, ReasonInstallFailed, be.Reason) + // The actionable error names the exact repair commands. + assert.Contains(t, be.Detail, "claude plugin marketplace add anthropics/claude-plugins-official") + assert.Contains(t, be.Detail, "claude plugin marketplace update claude-plugins-official") + + // The proactive refresh (marketplace update, no name) runs before every Claude + // install; declining recovery must only skip the re-add, not that refresh. + for _, c := range stub.Commands() { + assert.NotContains(t, c, "marketplace add", "declining must not re-add the marketplace") + } +} + +func TestInstallBuiltinMarketplaceNonInteractiveActionableError(t *testing.T) { + stubAgentLookPath(t, true) + ctx, stub := process.WithStub(t.Context()) + stub.WithFailureFor("plugin install", errors.New("exit status 1")) + stub.WithStderrFor("plugin install", `Plugin "databricks" not found in marketplace "claude-plugins-official"`) + stub.WithCallback(func(*exec.Cmd) error { return nil }) + // No cmdio on the context: a non-interactive run must not prompt, must not + // touch the marketplace, and must return the actionable error. + _, err := InstallPluginForAgent(ctx, builtinMarketplaceAgent(), "user", "main") + var be *BlockedError + require.ErrorAs(t, err, &be) + assert.Equal(t, ReasonInstallFailed, be.Reason) + assert.Contains(t, be.Detail, `Plugin "databricks" not found`) + assert.Contains(t, be.Detail, "claude plugin marketplace add anthropics/claude-plugins-official") + + // The proactive refresh runs before install; a non-interactive run must only + // skip the recovery re-add, not that refresh. + for _, c := range stub.Commands() { + assert.NotContains(t, c, "marketplace add") + } +} + +func TestInstallBuiltinMarketplaceRetryStillFails(t *testing.T) { + stubAgentLookPath(t, true) + ctx, stub := process.WithStub(t.Context()) + // Every install fails, even after the repair, so the recovery gives up with the + // actionable error rather than reporting a false success. + stub.WithFailureFor("plugin install", errors.New("exit status 1")) + stub.WithStderrFor("plugin install", `Plugin "databricks" not found in marketplace "claude-plugins-official"`) + stub.WithCallback(func(*exec.Cmd) error { return nil }) + ctx = promptCtx(ctx, "y\n") + + _, err := InstallPluginForAgent(ctx, builtinMarketplaceAgent(), "user", "main") + var be *BlockedError + require.ErrorAs(t, err, &be) + assert.Equal(t, ReasonInstallFailed, be.Reason) + assert.Contains(t, be.Detail, "claude plugin marketplace add anthropics/claude-plugins-official") + + // The repair was still attempted before giving up. + cmds := stub.Commands() + assert.Contains(t, cmds, "claude plugin marketplace add anthropics/claude-plugins-official") +} + +func TestInstallBuiltinMarketplaceNonMissingErrorSkipsRecovery(t *testing.T) { + stubAgentLookPath(t, true) + ctx, stub := process.WithStub(t.Context()) + stub.WithCallback(func(*exec.Cmd) error { return nil }) + // The install fails for a reason unrelated to a missing marketplace (auth), so + // recovery must not prompt or re-add the marketplace even in an interactive + // session; the original error is surfaced verbatim. + stub.WithStderrFor("plugin install", "you must run `claude login`"). + WithFailureFor("plugin install", errors.New("exit status 1")) + ctx = promptCtx(ctx, "y\n") + + _, err := InstallPluginForAgent(ctx, builtinMarketplaceAgent(), "user", "main") + var be *BlockedError + require.ErrorAs(t, err, &be) + assert.Equal(t, ReasonInstallFailed, be.Reason) + assert.Equal(t, "you must run `claude login`", be.Detail) + + for _, c := range stub.Commands() { + assert.NotContains(t, c, "marketplace add", "a non-missing failure must not re-add the marketplace") + } +} + func TestInstallPluginForAgentCodexUsesAddNoScope(t *testing.T) { stubAgentLookPath(t, true) ctx, stub := process.WithStub(t.Context()) From f6d06849cdd711dfa60af53a2c75288c3c7458a6 Mon Sep 17 00:00:00 2001 From: dustinvannoy-db Date: Fri, 24 Jul 2026 14:33:42 -0700 Subject: [PATCH 2/2] aitools: drop unmatched marketplace-missing substring Verified against the real Claude CLI: a plugin install against a missing built-in marketplace fails with `Plugin "" not found in marketplace ""`. The `marketplace not found` phrase only comes from `plugin marketplace update` (which the recovery gate never inspects) and puts the name mid-string, so the substring never matched anything. Drop it and keep only the verified `not found in marketplace` match. Co-authored-by: Isaac --- libs/aitools/installer/plugin.go | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/libs/aitools/installer/plugin.go b/libs/aitools/installer/plugin.go index abfd7c6b156..a4d8b8b001f 100644 --- a/libs/aitools/installer/plugin.go +++ b/libs/aitools/installer/plugin.go @@ -439,15 +439,15 @@ func claudeAlreadyDisabled(err error) bool { return strings.Contains(strings.ToLower(stderrOf(err)), "already disabled") } -// marketplaceMissing reports whether a step failed because the marketplace is -// unknown to the agent CLI (the user removed it), as opposed to an auth or -// network failure. The agent CLIs expose no error code for this, so we match the -// stderr the way claudeAlreadyDisabled does; callers use it only to decide -// whether re-adding the marketplace is worth attempting, never as the final -// verdict (the retried install is). +// marketplaceMissing reports whether an install failed because the plugin's +// marketplace is missing or stale, as opposed to an auth or network failure. The +// agent CLIs expose no error code for this, so we match the stderr the way +// claudeAlreadyDisabled does. Claude reports this as `Plugin "" not found in +// marketplace ""`. Callers use it only to decide whether re-adding the +// marketplace is worth attempting, never as the final verdict (the retried +// install is). func marketplaceMissing(err error) bool { - s := strings.ToLower(stderrOf(err)) - return strings.Contains(s, "not found in marketplace") || strings.Contains(s, "marketplace not found") + return strings.Contains(strings.ToLower(stderrOf(err)), "not found in marketplace") } // builtinMarketplaceRepairable reports whether a failed refresh or install is the