diff --git a/.nextchanges/cli/aitools-goose.md b/.nextchanges/cli/aitools-goose.md new file mode 100644 index 00000000000..22c9f2bf6e0 --- /dev/null +++ b/.nextchanges/cli/aitools-goose.md @@ -0,0 +1 @@ +`databricks aitools install` now supports Goose, installing Databricks agent skills into its skills directory. diff --git a/cmd/aitools/install_test.go b/cmd/aitools/install_test.go index 7cb904c5e88..0ce6452ee01 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/Gemini via skills). User-only plugin agents and global-only - // files agents are not. + // via plugin; Pi/Gemini/Goose 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 { @@ -159,6 +159,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.Contains(t, names, agents.NameGoose) 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 899f5b117a9..1e85d5dfbf7 100644 --- a/cmd/aitools/telemetry.go +++ b/cmd/aitools/telemetry.go @@ -85,6 +85,8 @@ func agentType(name string) protos.AitoolsAgentType { return protos.AitoolsAgentTypePi case agents.NameGemini: return protos.AitoolsAgentTypeGemini + case agents.NameGoose: + return protos.AitoolsAgentTypeGoose default: return protos.AitoolsAgentTypeUnspecified } diff --git a/cmd/aitools/update_test.go b/cmd/aitools/update_test.go index aec1220b41a..0167d9d751b 100644 --- a/cmd/aitools/update_test.go +++ b/cmd/aitools/update_test.go @@ -399,6 +399,7 @@ func TestUpdateProjectIncludesProjectSkillAgents(t *testing.T) { // 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)) + require.NoError(t, os.MkdirAll(filepath.Join(projectRoot, ".goose", "skills", "databricks-core"), 0o755)) ctx := cmdio.MockDiscard(t.Context()) dir, err := installer.ProjectSkillsDir(ctx) @@ -431,4 +432,5 @@ func TestUpdateProjectIncludesProjectSkillAgents(t *testing.T) { require.NoError(t, cmd.Execute()) assert.Contains(t, names, agents.NamePi) assert.Contains(t, names, agents.NameGemini) + assert.Contains(t, names, agents.NameGoose) } diff --git a/libs/aitools/agents/agents.go b/libs/aitools/agents/agents.go index 292695767da..6ad9ebc12f5 100644 --- a/libs/aitools/agents/agents.go +++ b/libs/aitools/agents/agents.go @@ -128,6 +128,7 @@ const ( NameAntigravity = "antigravity" NamePi = "pi" NameGemini = "gemini" + NameGoose = "goose" ) // Databricks plugin identity, shared across the agents that ship a plugin. @@ -246,6 +247,16 @@ var Registry = []*Agent{ // and installation_id is not reliable, so detection uses this Gemini-only file. MandatoryFile: "projects.json", }, + { + Name: NameGoose, + DisplayName: "Goose", + ConfigDir: gooseConfigDir, + SupportsProjectScope: true, + ProjectConfigDir: ".goose", + Binary: "goose", + // Goose 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, @@ -312,6 +323,35 @@ func openCodeConfigDir(ctx context.Context) (string, error) { return filepath.Join(xdg, "opencode"), nil } +// gooseConfigDir returns Goose's config directory, matching how Goose resolves it +// so skills land where it reads them, including under a relocated root. The Windows +// path keeps the legacy "Block" segment for backwards compatibility. +// See crates/goose/src/config/paths.rs (etcetera crate). https://block.github.io/goose/ +func gooseConfigDir(ctx context.Context) (string, error) { + if root := env.Get(ctx, "GOOSE_PATH_ROOT"); filepath.IsAbs(root) { + return filepath.Join(root, "config"), nil + } + if runtime.GOOS == "windows" { + if appData := env.Get(ctx, "APPDATA"); appData != "" { + return filepath.Join(appData, "Block", "goose", "config"), nil + } + home, err := env.UserHomeDir(ctx) + if err != nil { + return "", err + } + return filepath.Join(home, "AppData", "Roaming", "Block", "goose", "config"), nil + } + home, err := env.UserHomeDir(ctx) + if err != nil { + return "", err + } + xdg := env.Get(ctx, "XDG_CONFIG_HOME") + if !filepath.IsAbs(xdg) { + xdg = filepath.Join(home, ".config") + } + return filepath.Join(xdg, "goose"), nil +} + // ByName returns the registry agent with the given name, or nil if not found. func ByName(name string) *Agent { for _, a := range Registry { diff --git a/libs/aitools/agents/agents_test.go b/libs/aitools/agents/agents_test.go index efa58b46383..42492f5a82d 100644 --- a/libs/aitools/agents/agents_test.go +++ b/libs/aitools/agents/agents_test.go @@ -19,6 +19,7 @@ func TestSkillsOnlyNamesMatchesRegistry(t *testing.T) { // Skills-only agents (Plugin nil) are listed; plugin agents are not. assert.Contains(t, names, "Pi") assert.Contains(t, names, "Gemini CLI") + assert.Contains(t, names, "Goose") 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 62f214db28d..dfb7a8c6ec1 100644 --- a/libs/aitools/agents/detect_test.go +++ b/libs/aitools/agents/detect_test.go @@ -153,6 +153,56 @@ func TestPiConfigDir(t *testing.T) { }) } +func TestGooseConfigDir(t *testing.T) { + ctx := t.Context() + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + + absRoot := t.TempDir() + absXDG := t.TempDir() + absAppData := t.TempDir() + // Windows resolves under %APPDATA%, falling back to %USERPROFILE%\AppData\Roaming. + winDefault := filepath.Join(home, "AppData", "Roaming", "Block", "goose", "config") + + // onlyOn restricts a case to one platform: "windows", "unix" (any non-Windows), + // or "" for every platform. Goose only honors an absolute path override. + tests := []struct { + name string + onlyOn string + gooseRoot string + xdg string + appData string + want string + }{ + {name: "absolute GOOSE_PATH_ROOT wins", gooseRoot: absRoot, want: filepath.Join(absRoot, "config")}, + {name: "relative GOOSE_PATH_ROOT ignored, unix", onlyOn: "unix", gooseRoot: "relative/root", want: filepath.Join(home, ".config", "goose")}, + {name: "relative GOOSE_PATH_ROOT ignored, windows", onlyOn: "windows", gooseRoot: "relative/root", want: winDefault}, + {name: "honors XDG_CONFIG_HOME", onlyOn: "unix", xdg: absXDG, want: filepath.Join(absXDG, "goose")}, + {name: "ignores relative XDG_CONFIG_HOME", onlyOn: "unix", xdg: "relative/config", want: filepath.Join(home, ".config", "goose")}, + {name: "defaults to ~/.config when XDG unset", onlyOn: "unix", want: filepath.Join(home, ".config", "goose")}, + {name: "honors APPDATA", onlyOn: "windows", appData: absAppData, want: filepath.Join(absAppData, "Block", "goose", "config")}, + {name: "defaults to USERPROFILE when APPDATA unset", onlyOn: "windows", want: winDefault}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if tc.onlyOn == "windows" && runtime.GOOS != "windows" { + t.Skip("Windows-only path") + } + if tc.onlyOn == "unix" && runtime.GOOS == "windows" { + t.Skip("non-Windows path") + } + t.Setenv("GOOSE_PATH_ROOT", tc.gooseRoot) + t.Setenv("XDG_CONFIG_HOME", tc.xdg) + t.Setenv("APPDATA", tc.appData) + dir, err := gooseConfigDir(ctx) + require.NoError(t, err) + assert.Equal(t, tc.want, dir) + }) + } +} + func TestGeminiConfigDir(t *testing.T) { ctx := t.Context() home := t.TempDir() diff --git a/libs/aitools/agents/registry_test.go b/libs/aitools/agents/registry_test.go index 0f8abf76539..f94e2ddcc6f 100644 --- a/libs/aitools/agents/registry_test.go +++ b/libs/aitools/agents/registry_test.go @@ -3,6 +3,7 @@ package agents import ( "os" "path/filepath" + "runtime" "testing" "github.com/databricks/cli/libs/env" @@ -15,6 +16,12 @@ func TestSkillAgentRegistryPaths(t *testing.T) { cwd := t.TempDir() ctx := env.WithUserHomeDir(t.Context(), home) ctx = env.Set(ctx, "XDG_CONFIG_HOME", filepath.Join(home, ".config")) + ctx = env.Set(ctx, "APPDATA", "") + + gooseGlobalDir := filepath.Join(home, ".config", "goose", "skills") + if runtime.GOOS == "windows" { + gooseGlobalDir = filepath.Join(home, "AppData", "Roaming", "Block", "goose", "config", "skills") + } tests := []struct { name string @@ -25,6 +32,7 @@ func TestSkillAgentRegistryPaths(t *testing.T) { }{ {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")}, + {NameGoose, "goose", "Goose", gooseGlobalDir, filepath.Join(cwd, ".goose", "skills")}, } for _, tc := range tests { @@ -46,7 +54,7 @@ func TestSkillAgentRegistryPaths(t *testing.T) { func TestDetectProjectInstalled(t *testing.T) { cwd := t.TempDir() - for _, name := range []string{NamePi, NameGemini} { + for _, name := range []string{NamePi, NameGemini, NameGoose} { dir := filepath.Join(ByName(name).ProjectSkillsDir(cwd), "databricks-core") require.NoError(t, os.MkdirAll(dir, 0o755)) } @@ -57,5 +65,5 @@ func TestDetectProjectInstalled(t *testing.T) { for _, a := range DetectProjectInstalled(cwd) { names = append(names, a.Name) } - assert.ElementsMatch(t, []string{NamePi, NameGemini}, names) + assert.ElementsMatch(t, []string{NamePi, NameGemini, NameGoose}, names) } diff --git a/libs/aitools/installer/installer_test.go b/libs/aitools/installer/installer_test.go index d42f202fa80..a0846c67fa7 100644 --- a/libs/aitools/installer/installer_test.go +++ b/libs/aitools/installer/installer_test.go @@ -1031,6 +1031,7 @@ func TestSupportsProjectScopeSetCorrectly(t *testing.T) { "antigravity": false, "pi": true, "gemini": true, + "goose": true, } for _, agent := range agents.Registry { diff --git a/libs/telemetry/protos/aitools_install.go b/libs/telemetry/protos/aitools_install.go index b36cb89a387..42d2b912490 100644 --- a/libs/telemetry/protos/aitools_install.go +++ b/libs/telemetry/protos/aitools_install.go @@ -15,6 +15,7 @@ const ( AitoolsAgentTypeAntigravity AitoolsAgentType = "ANTIGRAVITY" AitoolsAgentTypePi AitoolsAgentType = "PI" AitoolsAgentTypeGemini AitoolsAgentType = "GEMINI_CLI" + AitoolsAgentTypeGoose AitoolsAgentType = "GOOSE" ) // AitoolsInstallScope mirrors AitoolsInstallScope.Type in the databricks_cli