Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
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-gemini.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
`databricks aitools install` now supports Gemini CLI, installing Databricks agent skills into its skills directory.
1 change: 1 addition & 0 deletions .nextchanges/cli/aitools-pi.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
`databricks aitools install` now supports Pi, installing Databricks agent skills into its skills directory.
6 changes: 4 additions & 2 deletions cmd/aitools/aitools.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package aitools

import (
"strings"

"github.com/databricks/cli/libs/aitools/agents"
"github.com/spf13/cobra"
)

Expand All @@ -11,8 +14,7 @@ func NewAitoolsCmd() *cobra.Command {
Long: `Install Databricks skills and plugins into your coding agent so it can work
effectively with Databricks resources (bundles, jobs, SQL, and more).

Supported agents: Claude Code, Cursor, Codex CLI, OpenCode, GitHub
Copilot, Antigravity.
Supported agents: ` + strings.Join(agents.SupportedNames(), ", ") + `.

Skills and plugins are sourced from
https://github.com/databricks/databricks-agent-skills`,
Expand Down
6 changes: 3 additions & 3 deletions cmd/aitools/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ func NewInstallCmd() *cobra.Command {

By default this installs the databricks plugin through each agent's own CLI
(Claude Code, Codex, GitHub Copilot). Agents without a headless plugin install
(OpenCode, Antigravity, Cursor) get raw skill files.
(` + strings.Join(agents.SkillsOnlyNames(), ", ") + `) get raw skill files.

Escape hatches:
--skills-only Force raw skill files for every agent (no plugin).
Expand All @@ -95,7 +95,7 @@ Agent selection:
(unset, interactive) A picker over all known agents, detected ones pre-checked.
(unset, non-interactive) Act on every detected agent.

Supported agents: Claude Code, Cursor, Codex CLI, OpenCode, GitHub Copilot, Antigravity`,
Supported agents: ` + strings.Join(agents.SupportedNames(), ", "),
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
Expand Down Expand Up @@ -484,6 +484,6 @@ func resolveAgentNames(_ context.Context, names string) ([]*agents.Agent, error)
func printNoAgentsMessage(ctx context.Context) {
cmdio.LogString(ctx, cmdio.Yellow(ctx, "No supported coding agents found on PATH."))
cmdio.LogString(ctx, "")
cmdio.LogString(ctx, "Supported: Claude Code, Codex CLI, GitHub Copilot, Cursor, OpenCode, Antigravity.")
cmdio.LogString(ctx, "Supported: "+strings.Join(agents.SupportedNames(), ", ")+".")
cmdio.LogString(ctx, "Install one, then re-run 'databricks aitools install'.")
}
7 changes: 5 additions & 2 deletions cmd/aitools/install_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -148,14 +148,17 @@ func TestAgentChoicesOnlyOffersActionableAgents(t *testing.T) {
fakeBinsOnPath(t, "claude")
ctx := cmdio.MockDiscard(t.Context())

// Project scope: only Claude (plugin) supports it; the user-only plugin
// agents and files-only agents are not offered as choices.
// Project scope: agents that support project-scoped skills are offered (Claude
// via plugin; Pi/Gemini via skills). User-only plugin agents and global-only
// files agents are not.
choices := agentChoices(ctx, installer.ScopeProject, false)
var names []string
for _, c := range choices {
names = append(names, c.agent.Name)
}
assert.Contains(t, names, agents.NameClaudeCode)
assert.Contains(t, names, agents.NamePi)
assert.Contains(t, names, agents.NameGemini)
assert.NotContains(t, names, agents.NameCursor)
assert.NotContains(t, names, agents.NameCodex)
assert.NotContains(t, names, agents.NameOpenCode)
Expand Down
4 changes: 4 additions & 0 deletions cmd/aitools/telemetry.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,10 @@ func agentType(name string) protos.AitoolsAgentType {
return protos.AitoolsAgentTypeCopilot
case agents.NameAntigravity:
return protos.AitoolsAgentTypeAntigravity
case agents.NamePi:
return protos.AitoolsAgentTypePi
case agents.NameGemini:
return protos.AitoolsAgentTypeGemini
default:
return protos.AitoolsAgentTypeUnspecified
}
Expand Down
28 changes: 27 additions & 1 deletion cmd/aitools/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"fmt"
"maps"
"os"
"slices"

"github.com/databricks/cli/libs/aitools/agents"
Expand Down Expand Up @@ -141,7 +142,16 @@ preview what would change without downloading.`,
}
opts.Skills = skills

result, err := updateSkillsFn(ctx, src, excludePluginAgents(installed, state), opts)
targetAgents := installed
if scope == installer.ScopeProject {
cwd, err := os.Getwd()
if err != nil {
return err
}
targetAgents = mergeAgents(installed, agents.DetectProjectInstalled(cwd))
}

result, err := updateSkillsFn(ctx, src, excludePluginAgents(targetAgents, state), opts)
if err != nil {
return err
}
Expand Down Expand Up @@ -251,6 +261,22 @@ func printPluginCheckResults(ctx context.Context, state *installer.InstallState,
}
}

// mergeAgents concatenates two agent lists, skipping duplicate names.
func mergeAgents(installed, project []*agents.Agent) []*agents.Agent {
seen := make(map[string]bool, len(installed))
merged := make([]*agents.Agent, 0, len(installed)+len(project))
for _, a := range installed {
seen[a.Name] = true
merged = append(merged, a)
}
for _, a := range project {
if !seen[a.Name] {
merged = append(merged, a)
}
}
return merged
}

// excludePluginAgents drops agents that are managed as plugins in this scope, so
// the file-skills reconcile never drops duplicate skill files onto a plugin agent.
func excludePluginAgents(installed []*agents.Agent, state *installer.InstallState) []*agents.Agent {
Expand Down
45 changes: 45 additions & 0 deletions cmd/aitools/update_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package aitools
import (
"context"
"errors"
"os"
"path/filepath"
"testing"

"github.com/databricks/cli/libs/aitools/agents"
Expand Down Expand Up @@ -387,3 +389,46 @@ func TestUpdateScopeFlag(t *testing.T) {
})
}
}

func TestUpdateProjectIncludesProjectSkillAgents(t *testing.T) {
setupTestAgents(t)
t.Setenv("DATABRICKS_SKILLS_REF", "v0.2.6")
projectRoot := t.TempDir()
t.Chdir(projectRoot)
// Skills-only agents read project skills from their project config dirs; a
// home-based detection would miss these, so update must use DetectProjectInstalled.
require.NoError(t, os.MkdirAll(filepath.Join(projectRoot, ".pi", "skills", "databricks-core"), 0o755))
require.NoError(t, os.MkdirAll(filepath.Join(projectRoot, ".gemini", "skills", "databricks-core"), 0o755))

ctx := cmdio.MockDiscard(t.Context())
dir, err := installer.ProjectSkillsDir(ctx)
require.NoError(t, err)
require.NoError(t, installer.SaveState(dir, &installer.InstallState{
SchemaVersion: 2,
Release: "v0.2.5",
Scope: installer.ScopeProject,
Skills: map[string]string{"databricks-core": "0.2.5"},
}))

origUpdateSkills := updateSkillsFn
origUpdatePlugins := updatePluginsFn
t.Cleanup(func() {
updateSkillsFn = origUpdateSkills
updatePluginsFn = origUpdatePlugins
})
var names []string
updateSkillsFn = func(_ context.Context, _ installer.ManifestSource, targetAgents []*agents.Agent, _ installer.UpdateOptions) (*installer.UpdateResult, error) {
for _, agent := range targetAgents {
names = append(names, agent.Name)
}
return &installer.UpdateResult{}, nil
}
updatePluginsFn = func(context.Context, string, string) ([]installer.PluginUpdate, error) { return nil, nil }

cmd := NewUpdateCmd()
cmd.SetContext(ctx)
cmd.SetArgs([]string{"--scope", "project"})
require.NoError(t, cmd.Execute())
assert.Contains(t, names, agents.NamePi)
assert.Contains(t, names, agents.NameGemini)
}
127 changes: 124 additions & 3 deletions libs/aitools/agents/agents.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"os"
"path/filepath"
"runtime"
"strings"

"github.com/databricks/cli/libs/env"
)
Expand Down Expand Up @@ -44,6 +45,10 @@ type Agent struct {
// plugin-capability detection and as the program for the plugin probe.
// Empty for agents with no CLI binary (Antigravity is IDE-only).
Binary string
// DetectFile, when set, is a marker file under ConfigDir that must exist for the
// agent to count as installed, instead of the bare config directory (used when
// the config dir is shared with another product; see geminiDetectFile).
DetectFile string
Comment thread
lennartkats-db marked this conversation as resolved.
Outdated
// Plugin describes the databricks plugin for this agent, or nil when the
// agent has no plugin and skills files are its native delivery.
Plugin *PluginSpec
Expand All @@ -55,14 +60,25 @@ type Agent struct {
pluginVersion func(ctx context.Context, a *Agent) (string, bool)
}

// Detected returns true if the agent is installed on the system.
// Detected reports whether the agent is installed: its config directory exists,
// or its DetectFile marker or installed Databricks skills exist when one is set.
func (a *Agent) Detected(ctx context.Context) bool {
dir, err := a.ConfigDir(ctx)
if err != nil {
return false
}
_, err = os.Stat(dir)
return err == nil
target := dir
if a.DetectFile != "" {
target = filepath.Join(dir, a.DetectFile)
}
if _, err = os.Stat(target); err == nil {
return true
}
if a.DetectFile == "" {
return false
}
Comment thread
lennartkats-db marked this conversation as resolved.
Outdated
skillsDir, err := a.SkillsDir(ctx)
return err == nil && HasDatabricksSkillsIn(skillsDir)
}

// SkillsDir returns the full path to the agent's skills directory.
Expand Down Expand Up @@ -109,8 +125,16 @@ const (
NameOpenCode = "opencode"
NameCopilot = "copilot"
NameAntigravity = "antigravity"
NamePi = "pi"
NameGemini = "gemini"
)

// geminiDetectFile is the project registry Gemini CLI writes at ~/.gemini/projects.json
// on real use. Detection keys on it, not the bare ~/.gemini directory, because
// Antigravity's ~/.gemini/antigravity subtree makes ~/.gemini exist without Gemini
// CLI being installed. (installation_id is not reliably present.)
const geminiDetectFile = "projects.json"
Comment thread
lennartkats-db marked this conversation as resolved.
Outdated

// Databricks plugin identity, shared across the agents that ship a plugin.
// The verified install commands are e.g.
//
Expand Down Expand Up @@ -204,6 +228,68 @@ var Registry = []*Agent{
SkillsSubdir: "global_skills",
// Antigravity is IDE-only with no CLI binary, so it has no plugin path.
},
{
Name: NamePi,
DisplayName: "Pi",
ConfigDir: piConfigDir,
SupportsProjectScope: true,
ProjectConfigDir: ".pi",
Binary: "pi",
// Pi reads agent skills (SKILL.md) but has no databricks plugin, so it is
// skills-only (Plugin nil).
},
{
Name: NameGemini,
DisplayName: "Gemini CLI",
ConfigDir: geminiConfigDir,
SupportsProjectScope: true,
ProjectConfigDir: ".gemini",
Binary: "gemini",
// Gemini CLI reads agent skills (SKILL.md) but has no databricks plugin, so
// it is skills-only (Plugin nil).
DetectFile: geminiDetectFile,
},
}

// piConfigDir returns Pi's agent config directory: PI_CODING_AGENT_DIR when set,
// else ~/.pi/agent. Mirroring Pi's own override keeps skills where Pi reads them
// when a launcher (e.g. ucode) relocates its home.
// See getAgentDir in @earendil-works/pi-coding-agent (config.ts).
func piConfigDir(ctx context.Context) (string, error) {
if dir := env.Get(ctx, "PI_CODING_AGENT_DIR"); dir != "" {
if dir == "~" || strings.HasPrefix(dir, "~/") || (runtime.GOOS == "windows" && strings.HasPrefix(dir, `~\`)) {
home, err := env.UserHomeDir(ctx)
if err != nil {
return "", err
}
if dir == "~" {
return home, nil
}
return filepath.Join(home, dir[2:]), nil
}
return dir, nil
}
home, err := env.UserHomeDir(ctx)
if err != nil {
return "", err
}
return filepath.Join(home, ".pi", "agent"), nil
}

// geminiConfigDir returns Gemini CLI's config directory: <GEMINI_CLI_HOME>/.gemini
// when set, else ~/.gemini. Honoring Gemini's own override keeps skills where it
// reads them under a relocated home (e.g. ucode).
// https://github.com/google-gemini/gemini-cli/blob/main/docs/reference/configuration.md
func geminiConfigDir(ctx context.Context) (string, error) {
root := env.Get(ctx, "GEMINI_CLI_HOME")
if root == "" {
home, err := env.UserHomeDir(ctx)
if err != nil {
return "", err
}
root = home
}
return filepath.Join(root, ".gemini"), nil
}

// openCodeConfigDir returns OpenCode's config directory. OpenCode stores its
Expand Down Expand Up @@ -249,3 +335,38 @@ func DetectInstalled(ctx context.Context) []*Agent {
}
return installed
}

// DetectProjectInstalled returns project-scope agents that already have Databricks
// skills in the current project. Config-dir detection is home-based, so it misses
// project-local installs; update uses this to also refresh those.
func DetectProjectInstalled(cwd string) []*Agent {
var installed []*Agent
for _, a := range Registry {
if a.SupportsProjectScope && HasDatabricksSkillsIn(a.ProjectSkillsDir(cwd)) {
installed = append(installed, a)
}
}
return installed
}

// SupportedNames returns every agent's display name in registry order, so the
// "Supported agents" messages can't drift as agents are added.
func SupportedNames() []string {
names := make([]string, len(Registry))
for i, a := range Registry {
names[i] = a.DisplayName
}
return names
}

// SkillsOnlyNames returns the display names of skills-only agents (Plugin == nil)
// in registry order, so the install help can't drift as they are added.
func SkillsOnlyNames() []string {
var names []string
for _, a := range Registry {
if a.Plugin == nil {
names = append(names, a.DisplayName)
}
}
return names
}
28 changes: 28 additions & 0 deletions libs/aitools/agents/agents_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package agents

import (
"testing"

"github.com/stretchr/testify/assert"
)

func TestSupportedNamesMatchesRegistry(t *testing.T) {
names := SupportedNames()
assert.Len(t, names, len(Registry))
for i, a := range Registry {
assert.Equal(t, a.DisplayName, names[i])
}
}

func TestSkillsOnlyNamesMatchesRegistry(t *testing.T) {
names := SkillsOnlyNames()
// Skills-only agents (Plugin nil) are listed; plugin agents are not.
assert.Contains(t, names, "Pi")
assert.Contains(t, names, "Gemini CLI")
assert.NotContains(t, names, "Claude Code")
for _, a := range Registry {
if a.Plugin != nil {
assert.NotContains(t, names, a.DisplayName)
}
}
}
Loading
Loading