Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
Comment thread
lennartkats-db marked this conversation as resolved.
Outdated
}
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.
Comment thread
lennartkats-db marked this conversation as resolved.
Outdated
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)
}
72 changes: 72 additions & 0 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 @@ -109,6 +110,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 +206,41 @@ 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).
Comment thread
lennartkats-db marked this conversation as resolved.
Outdated
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
}

// openCodeConfigDir returns OpenCode's config directory. OpenCode stores its
Expand Down Expand Up @@ -249,3 +286,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)
}
}
}
67 changes: 67 additions & 0 deletions libs/aitools/agents/detect_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,73 @@ 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) {
Comment thread
lennartkats-db marked this conversation as resolved.
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)
})

t.Run("expands home in PI_CODING_AGENT_DIR override", func(t *testing.T) {
t.Setenv("PI_CODING_AGENT_DIR", "~/custom-agent")
dir, err := piConfigDir(ctx)
require.NoError(t, err)
assert.Equal(t, filepath.Join(home, "custom-agent"), dir)
})

t.Run("expands bare home in PI_CODING_AGENT_DIR override", func(t *testing.T) {
t.Setenv("PI_CODING_AGENT_DIR", "~")
dir, err := piConfigDir(ctx)
require.NoError(t, err)
assert.Equal(t, home, dir)
})

t.Run("expands Windows home separator", func(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("Windows-only path form")
}
t.Setenv("PI_CODING_AGENT_DIR", `~\custom-agent`)
dir, err := piConfigDir(ctx)
require.NoError(t, err)
assert.Equal(t, filepath.Join(home, "custom-agent"), dir)
})

t.Run("preserves other tilde prefixes", func(t *testing.T) {
t.Setenv("PI_CODING_AGENT_DIR", "~other/custom-agent")
dir, err := piConfigDir(ctx)
require.NoError(t, err)
assert.Equal(t, "~other/custom-agent", dir)
})
}

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

Expand Down
Loading
Loading