Skip to content
Open
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
19 changes: 16 additions & 3 deletions libs/aitools/agents/agents.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<agent> 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.
Expand Down Expand Up @@ -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
Expand All @@ -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,
}
}

Expand Down
121 changes: 117 additions & 4 deletions libs/aitools/installer/plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -106,9 +107,10 @@ func recordedInstallTarget(agent *agents.Agent, rec PluginRecord) string {
return plugin + "@" + marketplace
}

// marketplaceAddArgs builds the `plugin marketplace add <source>` argv (sans binary).
func marketplaceAddArgs(spec *agents.PluginSpec) []string {
return []string{"plugin", "marketplace", "add", spec.Source}
// marketplaceAddArgs builds the `plugin marketplace add <source>` 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
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand All @@ -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.
Expand Down Expand Up @@ -345,6 +439,25 @@ func claudeAlreadyDisabled(err error) bool {
return strings.Contains(strings.ToLower(stderrOf(err)), "already disabled")
}

// 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 "<id>" not found in
// marketplace "<name>"`. 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 {
return strings.Contains(strings.ToLower(stderrOf(err)), "not found in marketplace")
}

// 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.
Expand Down
155 changes: 147 additions & 8 deletions libs/aitools/installer/plugin_test.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
package installer

import (
"context"
"errors"
"io"
"os/exec"
"path/filepath"
"strings"
"testing"

"github.com/databricks/cli/libs/aitools/agents"
Expand Down Expand Up @@ -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"}
}
Expand Down Expand Up @@ -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")
Expand All @@ -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())
Expand Down
Loading