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
1 change: 1 addition & 0 deletions .nextchanges/cli/aitools-claude-marketplace.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fixed `databricks aitools install` to offer restoration of Claude Code's official plugin marketplace when it is missing.
43 changes: 37 additions & 6 deletions cmd/aitools/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,14 @@ import (
// Package-level seams for testability. Tests override these via helpers in
// install_test.go.
var (
promptAgentSelection = defaultPromptAgentSelection
promptProceed = defaultPromptProceed
installSkillsForAgentsFn = installer.InstallSkillsForAgents
installPluginForAgentFn = installer.InstallPluginForAgent
recordPluginInstallsFn = installer.RecordPluginInstalls
cleanupLegacyFn = installer.RemoveLegacyRawSkills
promptAgentSelection = defaultPromptAgentSelection
promptProceed = defaultPromptProceed
installSkillsForAgentsFn = installer.InstallSkillsForAgents
installPluginForAgentFn = installer.InstallPluginForAgent
recordPluginInstallsFn = installer.RecordPluginInstalls
cleanupLegacyFn = installer.RemoveLegacyRawSkills
promptMarketplaceRestore = defaultPromptMarketplaceRestore
restoreMarketplaceForAgentFn = installer.RestoreMarketplaceForAgent
)

// delivery is how the databricks tools are delivered to one agent.
Expand Down Expand Up @@ -282,6 +284,18 @@ func defaultPromptProceed() (bool, error) {
return proceed, nil
}

func defaultPromptMarketplaceRestore(agent *agents.Agent) (bool, error) {
restore := true
err := huh.NewConfirm().
Title(agent.DisplayName + "'s required plugin marketplace is not configured. Add it now?").
Value(&restore).
Run()
if err != nil {
return false, err
}
return restore, nil
}

func defaultPromptAgentSelection(_ context.Context, choices []agentChoice) ([]*agents.Agent, error) {
options := make([]huh.Option[string], 0, len(choices))
byName := make(map[string]*agents.Agent, len(choices))
Expand Down Expand Up @@ -405,6 +419,23 @@ func executePlan(ctx context.Context, src installer.ManifestSource, plan []agent
for _, it := range pluginItems {
cmdio.LogString(ctx, fmt.Sprintf("Installing databricks plugin for %s...", it.agent.DisplayName))
rec, err := installPluginForAgentFn(ctx, it.agent, it.scope, ref)
if err != nil {
if blockedErr, ok := errors.AsType[*installer.BlockedError](err); ok &&
blockedErr.Reason == installer.ReasonMarketplaceNotConfigured &&
cmdio.IsPromptSupported(ctx) {
restore, promptErr := promptMarketplaceRestore(it.agent)
if promptErr != nil {
return promptErr
}
if restore {
if restoreErr := restoreMarketplaceForAgentFn(ctx, it.agent); restoreErr != nil {
err = restoreErr
} else {
rec, err = installPluginForAgentFn(ctx, it.agent, it.scope, ref)
}
}
}
}
if err != nil {
cmdio.LogString(ctx, cmdio.Yellow(ctx, fmt.Sprintf("Skipped %s: %v", it.agent.DisplayName, err)))
if it.explicit {
Expand Down
79 changes: 79 additions & 0 deletions cmd/aitools/install_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,85 @@ func TestExecutePlanSkipBlockedPluginExit0(t *testing.T) {
require.Error(t, executePlan(ctx, nil, planExplicit, installer.InstallOptions{Scope: installer.ScopeGlobal}))
}

func TestExecutePlanRestoresMissingMarketplace(t *testing.T) {
t.Setenv("DATABRICKS_SKILLS_REF", "v0.2.6")

origInstall := installPluginForAgentFn
origRestore := restoreMarketplaceForAgentFn
origPrompt := promptMarketplaceRestore
origRecord := recordPluginInstallsFn
origCleanup := cleanupLegacyFn
t.Cleanup(func() {
installPluginForAgentFn = origInstall
restoreMarketplaceForAgentFn = origRestore
promptMarketplaceRestore = origPrompt
recordPluginInstallsFn = origRecord
cleanupLegacyFn = origCleanup
})

installCalls := 0
installPluginForAgentFn = func(
_ context.Context,
a *agents.Agent,
scope string,
_ string,
) (installer.PluginRecord, error) {
installCalls++
if installCalls == 1 {
return installer.PluginRecord{}, &installer.BlockedError{
Agent: a.Name,
Reason: installer.ReasonMarketplaceNotConfigured,
}
}
return installer.PluginRecord{
Marketplace: "claude-plugins-official",
Plugin: "databricks",
Scope: scope,
Version: "0.2.6",
}, nil
}

promptCalled := false
promptMarketplaceRestore = func(*agents.Agent) (bool, error) {
promptCalled = true
return true, nil
}

restoreCalled := false
restoreMarketplaceForAgentFn = func(context.Context, *agents.Agent) error {
restoreCalled = true
return nil
}

recordPluginInstallsFn = func(
context.Context,
string,
map[string]installer.PluginRecord,
string,
) error {
return nil
}
cleanupLegacyFn = func(context.Context, *agents.Agent, string) error {
return nil
}

claude := testPluginAgent(agents.NameClaudeCode, "Claude Code", "claude")
plan := buildPlan([]*agents.Agent{claude}, installer.ScopeGlobal, false, true)

ctx, test := cmdio.SetupTest(t.Context(), cmdio.TestOptions{PromptSupported: true})
defer test.Done()
go drainReader(test.Stdout)
go drainReader(test.Stderr)

require.NoError(
t,
executePlan(ctx, nil, plan, installer.InstallOptions{Scope: installer.ScopeGlobal}),
)
assert.True(t, promptCalled)
assert.True(t, restoreCalled)
assert.Equal(t, 2, installCalls)
}

// --- RunE: skills-only path (config-dir detection, no plugin) ---

func TestInstallSkillsOnlyAllAgents(t *testing.T) {
Expand Down
39 changes: 39 additions & 0 deletions libs/aitools/installer/plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
var lookPath = exec.LookPath

const (
claudeOfficialMarketplaceSource = "anthropics/claude-plugins-official"
// pluginProbeTimeout bounds the `<agent> plugin --help` capability check.
pluginProbeTimeout = 5 * time.Second
// pluginCmdTimeout bounds an install/update/uninstall command, which may
Expand Down Expand Up @@ -46,6 +47,8 @@ const (
// ReasonNoPlugin: the agent has no installable plugin. Callers filter these
// out; it is guarded here to avoid a nil dereference.
ReasonNoPlugin = "no-plugin"
// ReasonMarketplaceNotConfigured: the agent's built-in marketplace is missing.
ReasonMarketplaceNotConfigured = "marketplace-not-configured"
)

func (e *BlockedError) Error() string {
Expand Down Expand Up @@ -228,6 +231,32 @@ func probePluginCLI(ctx context.Context, agent *agents.Agent) (string, error) {
return bin, nil
}

// RestoreMarketplaceForAgent restores Claude Code's official marketplace when
// it has been removed from the local configuration.
func RestoreMarketplaceForAgent(ctx context.Context, agent *agents.Agent) error {
if agent.Name != agents.NameClaudeCode {
return fmt.Errorf("marketplace restoration is not supported for %s", agent.DisplayName)
}

bin, err := probePluginCLI(ctx, agent)
if err != nil {
return err
}

if _, err := runAgentCmd(
ctx,
pluginCmdTimeout,
[]string{bin, "plugin", "marketplace", "add", claudeOfficialMarketplaceSource},
); err != nil {
return &BlockedError{
Agent: agent.Name,
Reason: ReasonInstallFailed,
Detail: stderrOf(err),
}
}
return nil
}

// InstallPluginForAgent registers the databricks marketplace and installs the
// plugin through the agent's own CLI, returning the record to persist in state.
// It never falls back to skills: a blocked install returns a *BlockedError.
Expand All @@ -250,6 +279,16 @@ func InstallPluginForAgent(ctx context.Context, agent *agents.Agent, nativeScope
// An empty Source marks a built-in marketplace (e.g. Claude's
// claude-plugins-official): it is already registered, so we never add or
// de-register it.
//
// Claude Code allows users to remove its built-in marketplace, so verify that
// it is still registered before attempting the plugin installation.
if agent.Plugin.Source == "" && !marketplaceRegistered(ctx, bin, agent.Plugin.Marketplace) {
return PluginRecord{}, &BlockedError{
Agent: agent.Name,
Reason: ReasonMarketplaceNotConfigured,
Detail: fmt.Sprintf("marketplace %q is not configured", agent.Plugin.Marketplace),
}
}
installedMarketplace := false
if agent.Plugin.Source != "" {
alreadyPresent := marketplaceRegistered(ctx, bin, agent.Plugin.Marketplace)
Expand Down
44 changes: 44 additions & 0 deletions libs/aitools/installer/plugin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ func TestInstallPluginForAgentBuiltinMarketplace(t *testing.T) {
stubAgentLookPath(t, true)
ctx, stub := process.WithStub(t.Context())
stub.WithCallback(func(*exec.Cmd) error { return nil })
stub.WithStdoutFor("plugin marketplace list", "claude-plugins-official\n")

// An agent whose plugin lives in a built-in marketplace (empty Source) like
// Claude's claude-plugins-official: install from it, never register it.
Expand All @@ -92,6 +93,49 @@ func TestInstallPluginForAgentBuiltinMarketplace(t *testing.T) {
assert.Contains(t, cmds, "claude plugin install databricks@claude-plugins-official --scope user")
}

func TestInstallPluginForAgentBuiltinMarketplaceMissing(t *testing.T) {
stubAgentLookPath(t, true)
ctx, stub := process.WithStub(t.Context())
stub.WithCallback(func(*exec.Cmd) error { return nil })

agent := &agents.Agent{
Name: agents.NameClaudeCode,
DisplayName: "Claude Code",
Binary: "claude",
Plugin: &agents.PluginSpec{Marketplace: "claude-plugins-official", ID: "databricks", Source: ""},
}

_, err := InstallPluginForAgent(ctx, agent, "user", "main")
var be *BlockedError
require.ErrorAs(t, err, &be)
assert.Equal(t, ReasonMarketplaceNotConfigured, be.Reason)

cmds := stub.Commands()
assert.Contains(t, cmds, "claude plugin marketplace list")
assert.NotContains(t, cmds, "claude plugin marketplace update")
assert.NotContains(t, cmds, "claude plugin install databricks@claude-plugins-official --scope user")
}

func TestRestoreMarketplaceForAgentClaude(t *testing.T) {
stubAgentLookPath(t, true)
ctx, stub := process.WithStub(t.Context())
stub.WithCallback(func(*exec.Cmd) error { return nil })

agent := &agents.Agent{
Name: agents.NameClaudeCode,
DisplayName: "Claude Code",
Binary: "claude",
Plugin: &agents.PluginSpec{Marketplace: "claude-plugins-official", ID: "databricks", Source: ""},
}

require.NoError(t, RestoreMarketplaceForAgent(ctx, agent))
assert.Contains(
t,
stub.Commands(),
"claude plugin marketplace add anthropics/claude-plugins-official",
)
}

func TestInstallPluginForAgentCodexUsesAddNoScope(t *testing.T) {
stubAgentLookPath(t, true)
ctx, stub := process.WithStub(t.Context())
Expand Down