diff --git a/.nextchanges/cli/aitools-pi.md b/.nextchanges/cli/aitools-pi.md new file mode 100644 index 00000000000..4167a39b147 --- /dev/null +++ b/.nextchanges/cli/aitools-pi.md @@ -0,0 +1 @@ +`databricks aitools install` now supports Pi, installing Databricks agent skills into its skills directory. diff --git a/cmd/aitools/aitools.go b/cmd/aitools/aitools.go index fe5178ac3a0..451e8cee523 100644 --- a/cmd/aitools/aitools.go +++ b/cmd/aitools/aitools.go @@ -1,6 +1,9 @@ package aitools import ( + "strings" + + "github.com/databricks/cli/libs/aitools/agents" "github.com/spf13/cobra" ) @@ -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`, diff --git a/cmd/aitools/install.go b/cmd/aitools/install.go index 033beb79ad9..3543ad4de1b 100644 --- a/cmd/aitools/install.go +++ b/cmd/aitools/install.go @@ -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). @@ -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() @@ -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'.") } diff --git a/cmd/aitools/install_test.go b/cmd/aitools/install_test.go index 825ce1ced6c..3f0040c71e5 100644 --- a/cmd/aitools/install_test.go +++ b/cmd/aitools/install_test.go @@ -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) diff --git a/cmd/aitools/telemetry.go b/cmd/aitools/telemetry.go index 5d4293f5818..60d1827fe95 100644 --- a/cmd/aitools/telemetry.go +++ b/cmd/aitools/telemetry.go @@ -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 } diff --git a/cmd/aitools/update.go b/cmd/aitools/update.go index 0a521e97439..977ad25ff13 100644 --- a/cmd/aitools/update.go +++ b/cmd/aitools/update.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "maps" + "os" "slices" "github.com/databricks/cli/libs/aitools/agents" @@ -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 } @@ -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 { diff --git a/cmd/aitools/update_test.go b/cmd/aitools/update_test.go index 2dee55bc5c3..35800d1e845 100644 --- a/cmd/aitools/update_test.go +++ b/cmd/aitools/update_test.go @@ -3,6 +3,8 @@ package aitools import ( "context" "errors" + "os" + "path/filepath" "testing" "github.com/databricks/cli/libs/aitools/agents" @@ -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) +} diff --git a/libs/aitools/agents/agents.go b/libs/aitools/agents/agents.go index 572282da03b..0d3c2d96d07 100644 --- a/libs/aitools/agents/agents.go +++ b/libs/aitools/agents/agents.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" "runtime" + "strings" "github.com/databricks/cli/libs/env" ) @@ -109,6 +110,7 @@ const ( NameOpenCode = "opencode" NameCopilot = "copilot" NameAntigravity = "antigravity" + NamePi = "pi" ) // Databricks plugin identity, shared across the agents that ship a plugin. @@ -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). +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 @@ -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 +} diff --git a/libs/aitools/agents/agents_test.go b/libs/aitools/agents/agents_test.go new file mode 100644 index 00000000000..f040e48c751 --- /dev/null +++ b/libs/aitools/agents/agents_test.go @@ -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) + } + } +} diff --git a/libs/aitools/agents/detect_test.go b/libs/aitools/agents/detect_test.go index 5df24602772..316456f0350 100644 --- a/libs/aitools/agents/detect_test.go +++ b/libs/aitools/agents/detect_test.go @@ -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) { + 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() diff --git a/libs/aitools/agents/registry_test.go b/libs/aitools/agents/registry_test.go new file mode 100644 index 00000000000..d22a64a24c1 --- /dev/null +++ b/libs/aitools/agents/registry_test.go @@ -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) +} diff --git a/libs/aitools/installer/installer.go b/libs/aitools/installer/installer.go index 93521292735..10923fe491e 100644 --- a/libs/aitools/installer/installer.go +++ b/libs/aitools/installer/installer.go @@ -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.") } diff --git a/libs/aitools/installer/installer_test.go b/libs/aitools/installer/installer_test.go index f9256d2d809..7076e4f986b 100644 --- a/libs/aitools/installer/installer_test.go +++ b/libs/aitools/installer/installer_test.go @@ -1029,6 +1029,7 @@ func TestSupportsProjectScopeSetCorrectly(t *testing.T) { "opencode": false, "copilot": false, "antigravity": false, + "pi": true, } for _, agent := range agents.Registry { diff --git a/libs/telemetry/protos/aitools_install.go b/libs/telemetry/protos/aitools_install.go index ea78dc532f8..92019f54272 100644 --- a/libs/telemetry/protos/aitools_install.go +++ b/libs/telemetry/protos/aitools_install.go @@ -13,6 +13,7 @@ const ( AitoolsAgentTypeOpenCode AitoolsAgentType = "OPENCODE" AitoolsAgentTypeCopilot AitoolsAgentType = "COPILOT" AitoolsAgentTypeAntigravity AitoolsAgentType = "ANTIGRAVITY" + AitoolsAgentTypePi AitoolsAgentType = "PI" ) // AitoolsInstallScope mirrors AitoolsInstallScope.Type in the databricks_cli