From af7c70bae29fc4569d8673af5c683d1c33abadc1 Mon Sep 17 00:00:00 2001 From: Russell Clarey Date: Wed, 22 Jul 2026 10:18:10 +0200 Subject: [PATCH 1/4] add agents to aitools list --- .nextchanges/cli/aitools-list-agents.md | 1 + cmd/aitools/list.go | 81 +++++++++++++++++-------- cmd/aitools/list_test.go | 56 +++++++++++++---- 3 files changed, 101 insertions(+), 37 deletions(-) create mode 100644 .nextchanges/cli/aitools-list-agents.md diff --git a/.nextchanges/cli/aitools-list-agents.md b/.nextchanges/cli/aitools-list-agents.md new file mode 100644 index 00000000000..198aecddd38 --- /dev/null +++ b/.nextchanges/cli/aitools-list-agents.md @@ -0,0 +1 @@ +`aitools list --output json` now reports every supported coding agent (not just those with a recorded install), each with whether it was detected on the system and its per-scope databricks plugin installation. The text output is unchanged. diff --git a/cmd/aitools/list.go b/cmd/aitools/list.go index b958413787b..88a3db3b51d 100644 --- a/cmd/aitools/list.go +++ b/cmd/aitools/list.go @@ -75,16 +75,29 @@ type listOutput struct { Agents []agentEntry `json:"agents,omitempty"` } -// agentEntry reports per-agent plugin state for `list`. It mirrors skillEntry: -// Installed maps scope -> the plugin recorded in that scope, so a stale scoped -// install stays visible next to an up-to-date one. Managed says whether the CLI -// installs and tracks the plugin. Up-to-date-ness is derived by comparing each -// Installed version against the top-level release, exactly as the skills view -// does, so there is no precomputed cross-scope status to keep in sync. +// agentEntry reports per-agent state for `list`. The JSON output carries an +// entry for every supported agent in the registry so the VSCode extension can +// render the full set, not just the ones with a recorded install; the text view +// keeps its original "Plugin installs:" section (managed agents with a recorded +// install only), so the extra fields here are additive to the JSON contract. +// +// Detected is the CLI's own presence verdict for the agent (Agent.IsPreselected): +// it folds the cheap binary-on-PATH and config-dir-on-disk signals through the +// agent-type-specific rules the install picker uses, so JSON consumers get the +// same answer the CLI would act on without re-implementing that logic. Managed +// says whether the CLI can install and track a databricks plugin for the agent. +// +// Installed maps CLI scope -> the plugin the CLI recorded in that scope, so a +// stale scoped install stays visible next to an up-to-date one, and +// up-to-date-ness is derived by comparing each version against the top-level +// release exactly as the skills view does. It is always present (empty when +// nothing is recorded), matching the skills shape. type agentEntry struct { - Name string `json:"name"` - Managed bool `json:"managed"` - Installed map[string]pluginInfo `json:"installed,omitempty"` + Name string `json:"name"` + DisplayName string `json:"display_name"` + Managed bool `json:"managed"` + Detected bool `json:"detected"` + Installed map[string]pluginInfo `json:"installed"` } // pluginInfo is the per-scope plugin record surfaced in list output. @@ -194,32 +207,38 @@ func buildListOutput(ctx context.Context, scope string) (listOutput, error) { if projectState != nil { states[installer.ScopeProject] = projectState } - out.Agents = buildAgentEntries(states) + out.Agents = buildAgentEntries(ctx, states) return out, nil } -// buildAgentEntries reports per-agent plugin state: each plugin agent with a -// recorded install (its version per scope). states maps scope -> install state -// and must contain only non-nil states; the caller filters scopes it did not -// load. Status across scopes is left for the renderer (and JSON consumers) to -// derive from the per-scope versions, so no cross-scope record is merged away here. -func buildAgentEntries(states map[string]*installer.InstallState) []agentEntry { - var entries []agentEntry +// buildAgentEntries reports state for every supported agent in the registry: +// detection (binary on PATH and config dir on disk), whether the CLI can manage +// a databricks plugin for it, and the per-scope install recorded by the CLI. +// states maps CLI scope -> install state and must contain only non-nil states; +// the caller filters scopes it did not load. Status across scopes is left for +// the renderer (and JSON consumers) to derive from the per-scope versions, so no +// cross-scope record is merged away here. +func buildAgentEntries(ctx context.Context, states map[string]*installer.InstallState) []agentEntry { + entries := make([]agentEntry, 0, len(agents.Registry)) for _, a := range agents.Registry { - if a.Plugin == nil { - continue + entry := agentEntry{ + Name: a.Name, + DisplayName: a.DisplayName, + Managed: a.Plugin != nil, + Detected: a.IsPreselected(ctx), } - installed := map[string]pluginInfo{} + // Always emit an installed map, empty when nothing is recorded, so the + // JSON shape matches skillEntry ("installed": {}) rather than omitting it. + entry.Installed = map[string]pluginInfo{} for scope, st := range states { if rec, ok := st.Plugins[a.Name]; ok { - installed[scope] = pluginInfo{Version: rec.Version, NativeScope: rec.Scope} + entry.Installed[scope] = pluginInfo{Version: rec.Version, NativeScope: rec.Scope} } } - if len(installed) > 0 { - entries = append(entries, agentEntry{Name: a.Name, Managed: true, Installed: installed}) - } + + entries = append(entries, entry) } return entries } @@ -266,13 +285,23 @@ func renderListText(ctx context.Context, out listOutput, scope string) { } } - if len(out.Agents) > 0 { + // The text view keeps its original "Plugin installs:" section: only managed + // agents with a recorded install are shown here. The richer per-agent + // detection state (every registry agent) is JSON-only, for the extension. + var pluginInstalls []agentEntry + for _, a := range out.Agents { + if a.Managed && len(a.Installed) > 0 { + pluginInstalls = append(pluginInstalls, a) + } + } + + if len(pluginInstalls) > 0 { cmdio.LogString(ctx, "Plugin installs:") cmdio.LogString(ctx, "") var ab strings.Builder atw := tabwriter.NewWriter(&ab, 0, 4, 2, ' ', 0) fmt.Fprintln(atw, " AGENT\tSTATUS") - for _, a := range out.Agents { + for _, a := range pluginInstalls { fmt.Fprintf(atw, " %s\t%s\n", agentDisplayName(a.Name), agentStatusLabel(a, out.Release)) } atw.Flush() diff --git a/cmd/aitools/list_test.go b/cmd/aitools/list_test.go index c977ac1b5c8..9ac06b25e64 100644 --- a/cmd/aitools/list_test.go +++ b/cmd/aitools/list_test.go @@ -110,9 +110,17 @@ func TestRenderListJSONWithAgents(t *testing.T) { Summary: map[string]scopeSummary{installer.ScopeGlobal: {Installed: 0, Total: 0}}, Agents: []agentEntry{ { - Name: "claude-code", - Managed: true, - Installed: map[string]pluginInfo{installer.ScopeGlobal: {Version: "0.2.6"}}, + Name: "claude-code", + DisplayName: "Claude Code", + Managed: true, + Detected: true, + Installed: map[string]pluginInfo{installer.ScopeGlobal: {Version: "0.2.6"}}, + }, + { + Name: "cursor", + DisplayName: "Cursor", + Managed: false, + Installed: map[string]pluginInfo{}, }, }, } @@ -128,13 +136,21 @@ func TestRenderListJSONWithAgents(t *testing.T) { assert.Contains(t, raw, "summary") agentsRaw := raw["agents"].([]any) - require.Len(t, agentsRaw, 1) + require.Len(t, agentsRaw, 2) first := agentsRaw[0].(map[string]any) assert.Equal(t, "claude-code", first["name"]) + assert.Equal(t, "Claude Code", first["display_name"]) assert.Equal(t, true, first["managed"]) + assert.Equal(t, true, first["detected"]) installed := first["installed"].(map[string]any) global := installed["global"].(map[string]any) assert.Equal(t, "0.2.6", global["version"]) + + // A not-installed agent emits "installed": {} rather than omitting the key, + // matching the skills shape. + second := agentsRaw[1].(map[string]any) + assert.Contains(t, second, "installed") + assert.Empty(t, second["installed"]) } func TestBuildAgentEntries(t *testing.T) { @@ -145,7 +161,7 @@ func TestBuildAgentEntries(t *testing.T) { }, } - entries := buildAgentEntries(map[string]*installer.InstallState{ + entries := buildAgentEntries(t.Context(), map[string]*installer.InstallState{ installer.ScopeGlobal: globalState, }) byName := map[string]agentEntry{} @@ -155,6 +171,7 @@ func TestBuildAgentEntries(t *testing.T) { require.Contains(t, byName, "claude-code") assert.True(t, byName["claude-code"].Managed) + assert.Equal(t, "Claude Code", byName["claude-code"].DisplayName) assert.Equal(t, "0.2.6", byName["claude-code"].Installed[installer.ScopeGlobal].Version) assert.Equal(t, "databricks plugin · v0.2.6 · up to date", agentStatusLabel(byName["claude-code"], "0.2.6")) @@ -163,8 +180,15 @@ func TestBuildAgentEntries(t *testing.T) { assert.Equal(t, "0.2.5", byName["codex"].Installed[installer.ScopeGlobal].Version) assert.Equal(t, "databricks plugin · v0.2.5 · update available", agentStatusLabel(byName["codex"], "0.2.6")) - // Cursor has no plugin, so it never appears as a plugin agent entry. - assert.NotContains(t, byName, "cursor") + // The JSON output carries an entry for every registry agent, including + // skills-only agents like Cursor and managed agents with no recorded install. + require.Contains(t, byName, "cursor") + assert.False(t, byName["cursor"].Managed) + assert.Empty(t, byName["cursor"].Installed) + + require.Contains(t, byName, "copilot") + assert.True(t, byName["copilot"].Managed) + assert.Empty(t, byName["copilot"].Installed) } func TestBuildAgentEntriesRecordsPerScopeVersions(t *testing.T) { @@ -177,7 +201,7 @@ func TestBuildAgentEntriesRecordsPerScopeVersions(t *testing.T) { "claude-code": {Plugin: "databricks", Version: "0.2.5"}, }} - entries := buildAgentEntries(map[string]*installer.InstallState{ + entries := buildAgentEntries(t.Context(), map[string]*installer.InstallState{ installer.ScopeGlobal: globalState, installer.ScopeProject: projectState, }) @@ -385,9 +409,16 @@ func TestRenderListTextShowsPluginInstallsBeforeRawSkills(t *testing.T) { }, Agents: []agentEntry{ { - Name: "claude-code", - Managed: true, - Installed: map[string]pluginInfo{installer.ScopeGlobal: {Version: "0.2.6", NativeScope: "user"}}, + Name: "claude-code", + DisplayName: "Claude Code", + Managed: true, + Detected: true, + Installed: map[string]pluginInfo{installer.ScopeGlobal: {Version: "0.2.6", NativeScope: "user"}}, + }, + { + Name: "cursor", + DisplayName: "Cursor", + Managed: false, }, }, } @@ -402,6 +433,9 @@ func TestRenderListTextShowsPluginInstallsBeforeRawSkills(t *testing.T) { assert.Less(t, pluginIdx, rawIdx) assert.Contains(t, got, "Claude Code") assert.Contains(t, got, "databricks plugin · v0.2.6 · up to date") + // Skills-only agents (and any without a recorded install) are JSON-only and + // must not appear in the text "Plugin installs:" section. + assert.NotContains(t, got, "Cursor") assert.Contains(t, got, "0/1 raw skill directories installed (global)") } From 737b0e006c83f43f9e59c79cd9e89c285d194baa Mon Sep 17 00:00:00 2001 From: Russell Clarey Date: Wed, 29 Jul 2026 16:00:38 +0200 Subject: [PATCH 2/4] Fix aitools list JSON omitting skills-only agent installs --- cmd/aitools/list.go | 30 +++++++++++++++++++ cmd/aitools/list_test.go | 46 +++++++++++++++++++++++++++++- libs/aitools/agents/skills.go | 9 +++++- libs/aitools/agents/skills_test.go | 24 ++++++++++++++++ 4 files changed, 107 insertions(+), 2 deletions(-) diff --git a/cmd/aitools/list.go b/cmd/aitools/list.go index 88a3db3b51d..fe4a239a311 100644 --- a/cmd/aitools/list.go +++ b/cmd/aitools/list.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "maps" + "os" "slices" "strings" "text/tabwriter" @@ -235,6 +236,14 @@ func buildAgentEntries(ctx context.Context, states map[string]*installer.Install for scope, st := range states { if rec, ok := st.Plugins[a.Name]; ok { entry.Installed[scope] = pluginInfo{Version: rec.Version, NativeScope: rec.Scope} + continue + } + // Skills-only agents (Plugin == nil) never get a plugin record: their + // skills are symlinked/copied into the agent's own skills dir instead. + // Detect the install from disk and report the scope's recorded release + // as the version, so JSON consumers see them as installed too. + if a.Plugin == nil && agentHasSkillsInScope(ctx, a, scope) { + entry.Installed[scope] = pluginInfo{Version: installer.DisplaySkillsVersion(st.Release)} } } @@ -243,6 +252,27 @@ func buildAgentEntries(ctx context.Context, states map[string]*installer.Install return entries } +// agentHasSkillsInScope reports whether databricks skills are present in the +// agent's own skills directory for the given CLI scope. It is the on-disk +// install signal for skills-only agents, which have no plugin record to consult. +func agentHasSkillsInScope(ctx context.Context, a *agents.Agent, scope string) bool { + if scope == installer.ScopeProject { + if !a.SupportsProjectScope { + return false + } + cwd, err := os.Getwd() + if err != nil { + return false + } + return agents.HasDatabricksSkillsIn(a.ProjectSkillsDir(cwd)) + } + dir, err := a.SkillsDir(ctx) + if err != nil { + return false + } + return agents.HasDatabricksSkillsIn(dir) +} + // loadStateForScope returns the install state for the named scope when the // scope filter allows it. excludeScope is the scope value that means "skip // loading this one" (so passing ScopeProject to the global loader skips diff --git a/cmd/aitools/list_test.go b/cmd/aitools/list_test.go index 9ac06b25e64..4def007c018 100644 --- a/cmd/aitools/list_test.go +++ b/cmd/aitools/list_test.go @@ -3,11 +3,14 @@ package aitools import ( "bytes" "encoding/json" + "os" + "path/filepath" "strings" "testing" "github.com/databricks/cli/libs/aitools/installer" "github.com/databricks/cli/libs/cmdio" + "github.com/databricks/cli/libs/env" "github.com/spf13/cobra" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -154,6 +157,9 @@ func TestRenderListJSONWithAgents(t *testing.T) { } func TestBuildAgentEntries(t *testing.T) { + // Isolate HOME so the skills-only on-disk detection doesn't pick up the + // developer's real agent skills dirs. + ctx := env.WithUserHomeDir(t.Context(), t.TempDir()) globalState := &installer.InstallState{ Plugins: map[string]installer.PluginRecord{ "claude-code": {Plugin: "databricks", Version: "0.2.6"}, @@ -161,7 +167,7 @@ func TestBuildAgentEntries(t *testing.T) { }, } - entries := buildAgentEntries(t.Context(), map[string]*installer.InstallState{ + entries := buildAgentEntries(ctx, map[string]*installer.InstallState{ installer.ScopeGlobal: globalState, }) byName := map[string]agentEntry{} @@ -191,6 +197,44 @@ func TestBuildAgentEntries(t *testing.T) { assert.Empty(t, byName["copilot"].Installed) } +func TestBuildAgentEntriesReportsSkillsOnlyAgentFromDisk(t *testing.T) { + // Skills-only agents (Plugin == nil) never get a plugin record; their skills + // are symlinked into the agent's own skills dir. The install must still be + // reported from disk, versioned by the scope's recorded release. + home := t.TempDir() + ctx := env.WithUserHomeDir(t.Context(), home) + // Pin XDG_CONFIG_HOME so OpenCode's config dir resolves under the temp home + // rather than the developer's real ~/.config. + ctx = env.Set(ctx, "XDG_CONFIG_HOME", filepath.Join(home, ".config")) + + // OpenCode is skills-only; its global skills dir is $XDG_CONFIG_HOME/opencode/skills. + // The CLI installs skills there as symlinks to the canonical store, so build + // the symlink to exercise the real on-disk shape. + canonical := filepath.Join(home, ".databricks", "aitools", "skills", "databricks-jobs") + require.NoError(t, os.MkdirAll(canonical, 0o755)) + skillsDir := filepath.Join(home, ".config", "opencode", "skills") + require.NoError(t, os.MkdirAll(skillsDir, 0o755)) + require.NoError(t, os.Symlink(canonical, filepath.Join(skillsDir, "databricks-jobs"))) + + globalState := &installer.InstallState{Release: "0.2.6"} + entries := buildAgentEntries(ctx, map[string]*installer.InstallState{ + installer.ScopeGlobal: globalState, + }) + byName := map[string]agentEntry{} + for _, e := range entries { + byName[e.Name] = e + } + + require.Contains(t, byName, "opencode") + opencode := byName["opencode"] + assert.False(t, opencode.Managed) + assert.Equal(t, "0.2.6", opencode.Installed[installer.ScopeGlobal].Version) + + // A skills-only agent with nothing on disk stays empty. + require.Contains(t, byName, "cursor") + assert.Empty(t, byName["cursor"].Installed) +} + func TestBuildAgentEntriesRecordsPerScopeVersions(t *testing.T) { // Same agent recorded in both scopes: global current, project stale. Both // versions are recorded; no scope is merged away. diff --git a/libs/aitools/agents/skills.go b/libs/aitools/agents/skills.go index 522cee6d991..b575f825c2b 100644 --- a/libs/aitools/agents/skills.go +++ b/libs/aitools/agents/skills.go @@ -38,13 +38,20 @@ func HasDatabricksSkillsInstalled(ctx context.Context) bool { } // HasDatabricksSkillsIn checks if dir contains a subdirectory starting with "databricks". +// The CLI installs skills into an agent's skills dir as symlinks to the canonical +// store, and os.ReadDir reports symlinks via Lstat (so IsDir is false for them), so +// entries are resolved with os.Stat to follow the link to its target. func HasDatabricksSkillsIn(dir string) bool { entries, err := os.ReadDir(dir) if err != nil { return false } for _, e := range entries { - if e.IsDir() && strings.HasPrefix(e.Name(), databricksSkillPrefix) { + if !strings.HasPrefix(e.Name(), databricksSkillPrefix) { + continue + } + info, err := os.Stat(filepath.Join(dir, e.Name())) + if err == nil && info.IsDir() { return true } } diff --git a/libs/aitools/agents/skills_test.go b/libs/aitools/agents/skills_test.go index a3366e95ca8..4f58438b8b1 100644 --- a/libs/aitools/agents/skills_test.go +++ b/libs/aitools/agents/skills_test.go @@ -141,6 +141,30 @@ func TestHasDatabricksSkillsInstalledDatabricksAppsCanonical(t *testing.T) { assert.True(t, HasDatabricksSkillsInstalled(t.Context())) } +func TestHasDatabricksSkillsInFollowsSymlinks(t *testing.T) { + // The CLI installs skills into an agent's dir as symlinks to the canonical + // store, so the check must follow the link rather than rely on IsDir (which + // os.ReadDir reports false for a symlink). + tmp := t.TempDir() + target := filepath.Join(tmp, "store", "databricks-jobs") + require.NoError(t, os.MkdirAll(target, 0o755)) + + skillsDir := filepath.Join(tmp, "skills") + require.NoError(t, os.MkdirAll(skillsDir, 0o755)) + require.NoError(t, os.Symlink(target, filepath.Join(skillsDir, "databricks-jobs"))) + + assert.True(t, HasDatabricksSkillsIn(skillsDir)) +} + +func TestHasDatabricksSkillsInIgnoresDanglingSymlink(t *testing.T) { + tmp := t.TempDir() + skillsDir := filepath.Join(tmp, "skills") + require.NoError(t, os.MkdirAll(skillsDir, 0o755)) + require.NoError(t, os.Symlink(filepath.Join(tmp, "missing"), filepath.Join(skillsDir, "databricks-jobs"))) + + assert.False(t, HasDatabricksSkillsIn(skillsDir)) +} + func TestHasDatabricksSkillsInstalledLegacyPath(t *testing.T) { tmpHome := t.TempDir() t.Setenv("HOME", tmpHome) From 2f9260bccdf0e46e8267ba00122d114c00511e08 Mon Sep 17 00:00:00 2001 From: Russell Clarey Date: Wed, 29 Jul 2026 16:03:22 +0200 Subject: [PATCH 3/4] remove changelog entry --- .nextchanges/cli/aitools-list-agents.md | 1 - 1 file changed, 1 deletion(-) delete mode 100644 .nextchanges/cli/aitools-list-agents.md diff --git a/.nextchanges/cli/aitools-list-agents.md b/.nextchanges/cli/aitools-list-agents.md deleted file mode 100644 index 198aecddd38..00000000000 --- a/.nextchanges/cli/aitools-list-agents.md +++ /dev/null @@ -1 +0,0 @@ -`aitools list --output json` now reports every supported coding agent (not just those with a recorded install), each with whether it was detected on the system and its per-scope databricks plugin installation. The text output is unchanged. From a33e56055c3fc9d1bdc2b09b08d21108d5da1e04 Mon Sep 17 00:00:00 2001 From: Russell Clarey Date: Fri, 31 Jul 2026 11:37:35 +0200 Subject: [PATCH 4/4] improve agentEntry comments --- cmd/aitools/list.go | 35 +++++++++++++---------------------- 1 file changed, 13 insertions(+), 22 deletions(-) diff --git a/cmd/aitools/list.go b/cmd/aitools/list.go index fe4a239a311..0efded8b158 100644 --- a/cmd/aitools/list.go +++ b/cmd/aitools/list.go @@ -76,29 +76,20 @@ type listOutput struct { Agents []agentEntry `json:"agents,omitempty"` } -// agentEntry reports per-agent state for `list`. The JSON output carries an -// entry for every supported agent in the registry so the VSCode extension can -// render the full set, not just the ones with a recorded install; the text view -// keeps its original "Plugin installs:" section (managed agents with a recorded -// install only), so the extra fields here are additive to the JSON contract. -// -// Detected is the CLI's own presence verdict for the agent (Agent.IsPreselected): -// it folds the cheap binary-on-PATH and config-dir-on-disk signals through the -// agent-type-specific rules the install picker uses, so JSON consumers get the -// same answer the CLI would act on without re-implementing that logic. Managed -// says whether the CLI can install and track a databricks plugin for the agent. -// -// Installed maps CLI scope -> the plugin the CLI recorded in that scope, so a -// stale scoped install stays visible next to an up-to-date one, and -// up-to-date-ness is derived by comparing each version against the top-level -// release exactly as the skills view does. It is always present (empty when -// nothing is recorded), matching the skills shape. +// agentEntry is one agent in `list --output json`. The JSON carries an entry for +// every registry agent so the extension sees the full set; the text view still +// lists only managed agents with a recorded install, so these fields are additive. type agentEntry struct { - Name string `json:"name"` - DisplayName string `json:"display_name"` - Managed bool `json:"managed"` - Detected bool `json:"detected"` - Installed map[string]pluginInfo `json:"installed"` + Name string `json:"name"` + DisplayName string `json:"display_name"` + // Managed is whether the CLI can install and track a databricks plugin for it. + Managed bool `json:"managed"` + // Detected is the CLI's presence verdict (Agent.IsPreselected), so consumers get + // the same answer the CLI would act on. + Detected bool `json:"detected"` + // Installed maps CLI scope -> the plugin recorded there; always present, empty + // when nothing is recorded, matching the skills shape. + Installed map[string]pluginInfo `json:"installed"` } // pluginInfo is the per-scope plugin record surfaced in list output.