Skip to content
Open
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-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.
5 changes: 3 additions & 2 deletions cmd/aitools/install_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -149,15 +149,16 @@ func TestAgentChoicesOnlyOffersActionableAgents(t *testing.T) {
ctx := cmdio.MockDiscard(t.Context())

// 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.
// 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
2 changes: 2 additions & 0 deletions cmd/aitools/telemetry.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@ func agentType(name string) protos.AitoolsAgentType {
return protos.AitoolsAgentTypeAntigravity
case agents.NamePi:
return protos.AitoolsAgentTypePi
case agents.NameGemini:
return protos.AitoolsAgentTypeGemini
default:
return protos.AitoolsAgentTypeUnspecified
}
Expand Down
6 changes: 4 additions & 2 deletions cmd/aitools/update_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -395,9 +395,10 @@ func TestUpdateProjectIncludesProjectSkillAgents(t *testing.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.
// 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)
Expand Down Expand Up @@ -429,4 +430,5 @@ func TestUpdateProjectIncludesProjectSkillAgents(t *testing.T) {
cmd.SetArgs([]string{"--scope", "project"})
require.NoError(t, cmd.Execute())
assert.Contains(t, names, agents.NamePi)
assert.Contains(t, names, agents.NameGemini)
}
55 changes: 52 additions & 3 deletions libs/aitools/agents/agents.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,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
// 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 @@ -56,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
}
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 @@ -111,8 +126,15 @@ const (
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"

// Databricks plugin identity, shared across the agents that ship a plugin.
// The verified install commands are e.g.
//
Expand Down Expand Up @@ -216,6 +238,17 @@ var Registry = []*Agent{
// 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,
Expand Down Expand Up @@ -243,6 +276,22 @@ func piConfigDir(ctx context.Context) (string, error) {
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
// config under %APPDATA%\opencode (Roaming AppData) on Windows, and honors
// XDG_CONFIG_HOME on other platforms, defaulting to ~/.config/opencode. The
Expand Down
1 change: 1 addition & 0 deletions libs/aitools/agents/agents_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ 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 {
Expand Down
64 changes: 64 additions & 0 deletions libs/aitools/agents/detect_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,48 @@ func TestDetected(t *testing.T) {
a := &Agent{ConfigDir: configDir(t, false)}
assert.False(t, a.Detected(ctx))
})

t.Run("DetectFile requires the marker, not just the dir", func(t *testing.T) {
dir := t.TempDir()
a := &Agent{ConfigDir: func(context.Context) (string, error) { return dir, nil }, DetectFile: "marker"}
// Directory exists but the marker does not: not detected.
assert.False(t, a.Detected(ctx))
require.NoError(t, os.WriteFile(filepath.Join(dir, "marker"), []byte("x"), 0o644))
assert.True(t, a.Detected(ctx))
})

t.Run("Gemini remains detected after skills install before first run", func(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
t.Setenv("GEMINI_CLI_HOME", "")
require.NoError(t, os.MkdirAll(filepath.Join(home, ".gemini", "skills", "databricks-core"), 0o755))

gemini := ByName(NameGemini)
require.NotNil(t, gemini)
assert.NoFileExists(t, filepath.Join(home, ".gemini", geminiDetectFile))
assert.True(t, gemini.Detected(ctx))
})

t.Run("Gemini is not detected merely because Antigravity exists", func(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
// Antigravity creates ~/.gemini/antigravity, which also makes ~/.gemini exist.
require.NoError(t, os.MkdirAll(filepath.Join(home, ".gemini", "antigravity"), 0o755))

gemini := ByName(NameGemini)
require.NotNil(t, gemini)
antigravity := ByName(NameAntigravity)
require.NotNil(t, antigravity)

assert.True(t, antigravity.Detected(ctx), "Antigravity should be detected from its own dir")
assert.False(t, gemini.Detected(ctx), "Gemini must not be detected from Antigravity's shared ~/.gemini")

// Once Gemini writes its own marker, it is detected.
require.NoError(t, os.WriteFile(filepath.Join(home, ".gemini", geminiDetectFile), []byte("id"), 0o644))
assert.True(t, gemini.Detected(ctx))
})
}

func TestPiConfigDir(t *testing.T) {
Expand Down Expand Up @@ -111,6 +153,28 @@ func TestPiConfigDir(t *testing.T) {
})
}

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

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

t.Run("honors GEMINI_CLI_HOME override and appends .gemini", func(t *testing.T) {
root := t.TempDir()
t.Setenv("GEMINI_CLI_HOME", root)
dir, err := geminiConfigDir(ctx)
require.NoError(t, err)
assert.Equal(t, filepath.Join(root, ".gemini"), dir)
})
}

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

Expand Down
5 changes: 3 additions & 2 deletions libs/aitools/agents/registry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ func TestSkillAgentRegistryPaths(t *testing.T) {
projectDir string
}{
{NamePi, "pi", "Pi", filepath.Join(home, ".pi", "agent", "skills"), filepath.Join(cwd, ".pi", "skills")},
{NameGemini, "gemini", "Gemini CLI", filepath.Join(home, ".gemini", "skills"), filepath.Join(cwd, ".gemini", "skills")},
}

for _, tc := range tests {
Expand All @@ -45,7 +46,7 @@ func TestSkillAgentRegistryPaths(t *testing.T) {

func TestDetectProjectInstalled(t *testing.T) {
cwd := t.TempDir()
for _, name := range []string{NamePi} {
for _, name := range []string{NamePi, NameGemini} {
dir := filepath.Join(ByName(name).ProjectSkillsDir(cwd), "databricks-core")
require.NoError(t, os.MkdirAll(dir, 0o755))
}
Expand All @@ -56,5 +57,5 @@ func TestDetectProjectInstalled(t *testing.T) {
for _, a := range DetectProjectInstalled(cwd) {
names = append(names, a.Name)
}
assert.ElementsMatch(t, []string{NamePi}, names)
assert.ElementsMatch(t, []string{NamePi, NameGemini}, names)
}
1 change: 1 addition & 0 deletions libs/aitools/installer/installer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1030,6 +1030,7 @@ func TestSupportsProjectScopeSetCorrectly(t *testing.T) {
"copilot": false,
"antigravity": false,
"pi": true,
"gemini": true,
}

for _, agent := range agents.Registry {
Expand Down
1 change: 1 addition & 0 deletions libs/telemetry/protos/aitools_install.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const (
AitoolsAgentTypeCopilot AitoolsAgentType = "COPILOT"
AitoolsAgentTypeAntigravity AitoolsAgentType = "ANTIGRAVITY"
AitoolsAgentTypePi AitoolsAgentType = "PI"
AitoolsAgentTypeGemini AitoolsAgentType = "GEMINI"
)

// AitoolsInstallScope mirrors AitoolsInstallScope.Type in the databricks_cli
Expand Down
Loading