Skip to content
Closed
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-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'.")
}
6 changes: 4 additions & 2 deletions cmd/aitools/install_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -148,14 +148,16 @@ 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 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.NotContains(t, names, agents.NameCursor)
assert.NotContains(t, names, agents.NameCodex)
assert.NotContains(t, names, agents.NameOpenCode)
Expand Down
2 changes: 2 additions & 0 deletions cmd/aitools/telemetry.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ func agentType(name string) protos.AitoolsAgentType {
return protos.AitoolsAgentTypeCopilot
case agents.NameAntigravity:
return protos.AitoolsAgentTypeAntigravity
case agents.NamePi:
return protos.AitoolsAgentTypePi
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
43 changes: 43 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,44 @@ 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)
// Pi reads project skills from .pi/skills; a home-based detection would miss
// this, so update must pick it up via DetectProjectInstalled.
require.NoError(t, os.MkdirAll(filepath.Join(projectRoot, ".pi", "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)
}
61 changes: 61 additions & 0 deletions libs/aitools/agents/agents.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ const (
NameOpenCode = "opencode"
NameCopilot = "copilot"
NameAntigravity = "antigravity"
NamePi = "pi"
)

// Databricks plugin identity, shared across the agents that ship a plugin.
Expand Down Expand Up @@ -204,6 +205,31 @@ 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).
},
}

// 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 != "" {
return dir, nil
}
home, err := env.UserHomeDir(ctx)
if err != nil {
return "", err
}
return filepath.Join(home, ".pi", "agent"), nil
}

// openCodeConfigDir returns OpenCode's config directory. OpenCode stores its
Expand Down Expand Up @@ -249,3 +275,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
}
27 changes: 27 additions & 0 deletions libs/aitools/agents/agents_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
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.NotContains(t, names, "Claude Code")
for _, a := range Registry {
if a.Plugin != nil {
assert.NotContains(t, names, a.DisplayName)
}
}
}
36 changes: 36 additions & 0 deletions libs/aitools/agents/detect_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,42 @@ func configDir(t *testing.T, create bool) func(context.Context) (string, error)
return func(_ context.Context) (string, error) { return dir, nil }
}

func TestDetected(t *testing.T) {
ctx := t.Context()

t.Run("bare config dir is the default signal", func(t *testing.T) {
a := &Agent{ConfigDir: configDir(t, true)}
assert.True(t, a.Detected(ctx))
})

t.Run("missing config dir is not detected", func(t *testing.T) {
a := &Agent{ConfigDir: configDir(t, false)}
assert.False(t, a.Detected(ctx))
})
}

func TestPiConfigDir(t *testing.T) {
ctx := t.Context()
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)

t.Run("defaults to ~/.pi/agent", func(t *testing.T) {
t.Setenv("PI_CODING_AGENT_DIR", "")
dir, err := piConfigDir(ctx)
require.NoError(t, err)
assert.Equal(t, filepath.Join(home, ".pi", "agent"), dir)
})

t.Run("honors PI_CODING_AGENT_DIR override", func(t *testing.T) {
override := t.TempDir()
t.Setenv("PI_CODING_AGENT_DIR", override)
dir, err := piConfigDir(ctx)
require.NoError(t, err)
assert.Equal(t, override, dir)
})
}

func TestHasBinary(t *testing.T) {
ctx := t.Context()

Expand Down
60 changes: 60 additions & 0 deletions libs/aitools/agents/registry_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package agents

import (
"os"
"path/filepath"
"testing"

"github.com/databricks/cli/libs/env"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestSkillAgentRegistryPaths(t *testing.T) {
home := t.TempDir()
cwd := t.TempDir()
ctx := env.WithUserHomeDir(t.Context(), home)
ctx = env.Set(ctx, "XDG_CONFIG_HOME", filepath.Join(home, ".config"))

tests := []struct {
name string
binary string
displayName string
globalDir string
projectDir string
}{
{NamePi, "pi", "Pi", filepath.Join(home, ".pi", "agent", "skills"), filepath.Join(cwd, ".pi", "skills")},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
a := ByName(tc.name)
require.NotNil(t, a)
assert.Equal(t, tc.binary, a.Binary)
assert.Equal(t, tc.displayName, a.DisplayName)
assert.True(t, a.SupportsProjectScope)
assert.Nil(t, a.Plugin)

globalDir, err := a.SkillsDir(ctx)
require.NoError(t, err)
assert.Equal(t, tc.globalDir, globalDir)
assert.Equal(t, tc.projectDir, a.ProjectSkillsDir(cwd))
})
}
}

func TestDetectProjectInstalled(t *testing.T) {
cwd := t.TempDir()
for _, name := range []string{NamePi} {
dir := filepath.Join(ByName(name).ProjectSkillsDir(cwd), "databricks-core")
require.NoError(t, os.MkdirAll(dir, 0o755))
}
// A non-databricks skill must not count as a Databricks install.
require.NoError(t, os.MkdirAll(filepath.Join(cwd, ".pi", "skills", "other-skill"), 0o755))

var names []string
for _, a := range DetectProjectInstalled(cwd) {
names = append(names, a.Name)
}
assert.ElementsMatch(t, []string{NamePi}, names)
}
2 changes: 1 addition & 1 deletion libs/aitools/installer/installer.go
Original file line number Diff line number Diff line change
Expand Up @@ -529,7 +529,7 @@ func PrintInstallingFor(ctx context.Context, targetAgents []*agents.Agent) {
func printNoAgentsDetected(ctx context.Context) {
cmdio.LogString(ctx, cmdio.Yellow(ctx, "No supported coding agents detected."))
cmdio.LogString(ctx, "")
cmdio.LogString(ctx, "Supported agents: Claude Code, Cursor, Codex CLI, OpenCode, GitHub Copilot, Antigravity")
cmdio.LogString(ctx, "Supported agents: "+strings.Join(agents.SupportedNames(), ", "))
cmdio.LogString(ctx, "Please install at least one coding agent first.")
}

Expand Down
Loading
Loading