diff --git a/.nextchanges/cli/aitools-gemini.md b/.nextchanges/cli/aitools-gemini.md new file mode 100644 index 0000000000..952fc5bd46 --- /dev/null +++ b/.nextchanges/cli/aitools-gemini.md @@ -0,0 +1 @@ +`databricks aitools install` now supports Gemini CLI, installing Databricks agent skills into its skills directory. diff --git a/cmd/aitools/install_test.go b/cmd/aitools/install_test.go index 3f0040c71e..7cb904c5e8 100644 --- a/cmd/aitools/install_test.go +++ b/cmd/aitools/install_test.go @@ -149,8 +149,8 @@ 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 { @@ -158,6 +158,7 @@ func TestAgentChoicesOnlyOffersActionableAgents(t *testing.T) { } 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) diff --git a/cmd/aitools/telemetry.go b/cmd/aitools/telemetry.go index 60d1827fe9..899f5b117a 100644 --- a/cmd/aitools/telemetry.go +++ b/cmd/aitools/telemetry.go @@ -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 } diff --git a/cmd/aitools/update_test.go b/cmd/aitools/update_test.go index 35800d1e84..aec1220b41 100644 --- a/cmd/aitools/update_test.go +++ b/cmd/aitools/update_test.go @@ -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) @@ -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) } diff --git a/libs/aitools/agents/agents.go b/libs/aitools/agents/agents.go index 0d3c2d96d0..6c15aeda06 100644 --- a/libs/aitools/agents/agents.go +++ b/libs/aitools/agents/agents.go @@ -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 @@ -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. @@ -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. // @@ -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, @@ -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 +// 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 diff --git a/libs/aitools/agents/agents_test.go b/libs/aitools/agents/agents_test.go index f040e48c75..efa58b4638 100644 --- a/libs/aitools/agents/agents_test.go +++ b/libs/aitools/agents/agents_test.go @@ -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 { diff --git a/libs/aitools/agents/detect_test.go b/libs/aitools/agents/detect_test.go index 316456f035..fbcbd9d61a 100644 --- a/libs/aitools/agents/detect_test.go +++ b/libs/aitools/agents/detect_test.go @@ -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) { @@ -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() diff --git a/libs/aitools/agents/registry_test.go b/libs/aitools/agents/registry_test.go index d22a64a24c..0f8abf7653 100644 --- a/libs/aitools/agents/registry_test.go +++ b/libs/aitools/agents/registry_test.go @@ -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 { @@ -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)) } @@ -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) } diff --git a/libs/aitools/installer/installer_test.go b/libs/aitools/installer/installer_test.go index 7076e4f986..d42f202fa8 100644 --- a/libs/aitools/installer/installer_test.go +++ b/libs/aitools/installer/installer_test.go @@ -1030,6 +1030,7 @@ func TestSupportsProjectScopeSetCorrectly(t *testing.T) { "copilot": false, "antigravity": false, "pi": true, + "gemini": true, } for _, agent := range agents.Registry { diff --git a/libs/telemetry/protos/aitools_install.go b/libs/telemetry/protos/aitools_install.go index 92019f5427..105605adb6 100644 --- a/libs/telemetry/protos/aitools_install.go +++ b/libs/telemetry/protos/aitools_install.go @@ -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