From 89b655adca925f1328e6c546b8475cd3ad3634e8 Mon Sep 17 00:00:00 2001 From: Punit Shah Date: Wed, 12 Aug 2026 13:46:14 -0700 Subject: [PATCH] aitools: restore missing Claude plugin marketplace --- .../cli/aitools-claude-marketplace.md | 1 + cmd/aitools/install.go | 43 ++++++++-- cmd/aitools/install_test.go | 79 +++++++++++++++++++ libs/aitools/installer/plugin.go | 39 +++++++++ libs/aitools/installer/plugin_test.go | 44 +++++++++++ 5 files changed, 200 insertions(+), 6 deletions(-) create mode 100644 .nextchanges/cli/aitools-claude-marketplace.md diff --git a/.nextchanges/cli/aitools-claude-marketplace.md b/.nextchanges/cli/aitools-claude-marketplace.md new file mode 100644 index 00000000000..903bb225ac6 --- /dev/null +++ b/.nextchanges/cli/aitools-claude-marketplace.md @@ -0,0 +1 @@ +Fixed `databricks aitools install` to offer restoration of Claude Code's official plugin marketplace when it is missing. \ No newline at end of file diff --git a/cmd/aitools/install.go b/cmd/aitools/install.go index 3543ad4de1b..2583a8beb7f 100644 --- a/cmd/aitools/install.go +++ b/cmd/aitools/install.go @@ -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. @@ -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)) @@ -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 { diff --git a/cmd/aitools/install_test.go b/cmd/aitools/install_test.go index 7cb904c5e88..39d80551e66 100644 --- a/cmd/aitools/install_test.go +++ b/cmd/aitools/install_test.go @@ -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) { diff --git a/libs/aitools/installer/plugin.go b/libs/aitools/installer/plugin.go index 13fc9475e31..44e220524b5 100644 --- a/libs/aitools/installer/plugin.go +++ b/libs/aitools/installer/plugin.go @@ -19,6 +19,7 @@ import ( var lookPath = exec.LookPath const ( + claudeOfficialMarketplaceSource = "anthropics/claude-plugins-official" // pluginProbeTimeout bounds the ` plugin --help` capability check. pluginProbeTimeout = 5 * time.Second // pluginCmdTimeout bounds an install/update/uninstall command, which may @@ -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 { @@ -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. @@ -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) diff --git a/libs/aitools/installer/plugin_test.go b/libs/aitools/installer/plugin_test.go index 2652918c441..482c8cb45b5 100644 --- a/libs/aitools/installer/plugin_test.go +++ b/libs/aitools/installer/plugin_test.go @@ -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. @@ -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())