diff --git a/.nextchanges/cli/aitools-list-agents.md b/.nextchanges/cli/aitools-list-agents.md new file mode 100644 index 0000000000..a906bb1ed0 --- /dev/null +++ b/.nextchanges/cli/aitools-list-agents.md @@ -0,0 +1 @@ +`aitools list` now reports every supported coding agent (both text and `--output json`) with its detection status (whether the CLI binary and config directory were found) and databricks plugin installation status and version. diff --git a/cmd/aitools/list.go b/cmd/aitools/list.go index b958413787..256dc79605 100644 --- a/cmd/aitools/list.go +++ b/cmd/aitools/list.go @@ -75,16 +75,32 @@ 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`. Every supported agent in the +// registry gets an entry so the VSCode extension can render the full set, not +// just the ones with a recorded install. +// +// Detection uses the two cheap presence signals: BinaryDetected is the agent's +// CLI binary on PATH (this is the "did we detect Claude Code / Codex" signal), +// and ConfigDetected is its config dir on disk (the only signal for IDE-only +// agents like Antigravity that have no CLI binary). Managed says whether the CLI +// can install and track a databricks plugin for the agent. +// +// Installation is reported two ways: 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. PluginVersion / +// PluginInstalled come from the agent's own plugin manifest, so an install done +// directly through the agent's CLI (not via `databricks aitools install`) is +// still reported. 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"` + BinaryDetected bool `json:"binary_detected"` + ConfigDetected bool `json:"config_detected"` + PluginInstalled bool `json:"plugin_installed"` + PluginVersion string `json:"plugin_version,omitempty"` + Installed map[string]pluginInfo `json:"installed,omitempty"` } // pluginInfo is the per-scope plugin record surfaced in list output. @@ -194,21 +210,34 @@ 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 installation status/version. 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, + BinaryDetected: a.HasBinary(ctx), + ConfigDetected: a.Detected(ctx), + } + + // The agent's own plugin manifest records installs done directly through + // its CLI, which the CLI's per-scope state does not capture. + if version, ok := a.DatabricksPluginVersion(ctx); ok { + entry.PluginInstalled = true + entry.PluginVersion = version } installed := map[string]pluginInfo{} @@ -218,8 +247,10 @@ func buildAgentEntries(states map[string]*installer.InstallState) []agentEntry { } } if len(installed) > 0 { - entries = append(entries, agentEntry{Name: a.Name, Managed: true, Installed: installed}) + entry.Installed = installed } + + entries = append(entries, entry) } return entries } @@ -267,13 +298,13 @@ func renderListText(ctx context.Context, out listOutput, scope string) { } if len(out.Agents) > 0 { - cmdio.LogString(ctx, "Plugin installs:") + cmdio.LogString(ctx, "Agents:") cmdio.LogString(ctx, "") var ab strings.Builder atw := tabwriter.NewWriter(&ab, 0, 4, 2, ' ', 0) - fmt.Fprintln(atw, " AGENT\tSTATUS") + fmt.Fprintln(atw, " AGENT\tDETECTED\tSTATUS") for _, a := range out.Agents { - fmt.Fprintf(atw, " %s\t%s\n", agentDisplayName(a.Name), agentStatusLabel(a, out.Release)) + fmt.Fprintf(atw, " %s\t%s\t%s\n", a.DisplayName, agentDetectedLabel(a), agentStatusLabel(a, out.Release)) } atw.Flush() cmdio.LogString(ctx, ab.String()) @@ -305,17 +336,40 @@ func renderSkillTable(skills []skillEntry, bothScopes bool) string { return buf.String() } -// agentStatusLabel renders the text-view status for an agent, collapsing the -// per-scope plugin records into a single line. A stale scope (version != +// agentDetectedLabel renders the detection column: which of the two cheap +// presence signals fired. "cli" means the binary is on PATH, "config" means the +// config dir exists; "no" means neither was found. +func agentDetectedLabel(a agentEntry) string { + switch { + case a.BinaryDetected && a.ConfigDetected: + return "yes (cli, config)" + case a.BinaryDetected: + return "yes (cli)" + case a.ConfigDetected: + return "yes (config)" + default: + return "no" + } +} + +// agentStatusLabel renders the text-view install status for an agent. Agents the +// CLI can't manage a plugin for are marked files-only; managed agents with no +// recorded install read "not installed". When an install exists, the per-scope +// plugin records are collapsed into a single line: a stale scope (version != // release) is surfaced over an up-to-date one so an outdated install is never // hidden; project is preferred when every scope matches release. func agentStatusLabel(a agentEntry, release string) string { - version, upToDate := "", true + if !a.Managed { + return "skills only" + } + + version, upToDate, found := "", true, false for _, scope := range []string{installer.ScopeProject, installer.ScopeGlobal} { info, ok := a.Installed[scope] if !ok { continue } + found = true stale := info.Version != release if version == "" || (upToDate && stale) { version = info.Version @@ -325,10 +379,22 @@ func agentStatusLabel(a agentEntry, release string) string { } } + // Fall back to the agent's own plugin manifest when the CLI has no recorded + // install (e.g. the plugin was installed directly through the agent's CLI). + if !found { + if a.PluginInstalled { + if a.PluginVersion == release { + return "plugin · " + versionToken(a.PluginVersion) + " · up to date" + } + return "plugin · " + versionToken(a.PluginVersion) + " · update available" + } + return "plugin · not installed" + } + if upToDate { - return "databricks plugin · " + versionToken(version) + " · up to date" + return "plugin · " + versionToken(version) + " · up to date" } - return "databricks plugin · " + versionToken(version) + " · update available" + return "plugin · " + versionToken(version) + " · update available" } func installedStatusFromEntry(s skillEntry, bothScopes bool) string { diff --git a/cmd/aitools/list_test.go b/cmd/aitools/list_test.go index c977ac1b5c..a1407ac900 100644 --- a/cmd/aitools/list_test.go +++ b/cmd/aitools/list_test.go @@ -110,9 +110,12 @@ 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, + BinaryDetected: true, + ConfigDetected: true, + Installed: map[string]pluginInfo{installer.ScopeGlobal: {Version: "0.2.6"}}, }, }, } @@ -131,7 +134,10 @@ func TestRenderListJSONWithAgents(t *testing.T) { require.Len(t, agentsRaw, 1) 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["binary_detected"]) + assert.Equal(t, true, first["config_detected"]) installed := first["installed"].(map[string]any) global := installed["global"].(map[string]any) assert.Equal(t, "0.2.6", global["version"]) @@ -145,7 +151,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,16 +161,26 @@ 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")) + assert.Equal(t, "plugin · v0.2.6 · up to date", agentStatusLabel(byName["claude-code"], "0.2.6")) require.Contains(t, byName, "codex") assert.True(t, byName["codex"].Managed) 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") + assert.Equal(t, "plugin · v0.2.5 · update available", agentStatusLabel(byName["codex"], "0.2.6")) + + // Every registry agent is listed now, including skills-only agents like Cursor. + require.Contains(t, byName, "cursor") + assert.False(t, byName["cursor"].Managed) + assert.Empty(t, byName["cursor"].Installed) + assert.Equal(t, "skills only", agentStatusLabel(byName["cursor"], "0.2.6")) + + // A managed agent with no recorded install reads as not installed. + require.Contains(t, byName, "copilot") + assert.True(t, byName["copilot"].Managed) + assert.Empty(t, byName["copilot"].Installed) + assert.Equal(t, "plugin · not installed", agentStatusLabel(byName["copilot"], "0.2.6")) } func TestBuildAgentEntriesRecordsPerScopeVersions(t *testing.T) { @@ -177,7 +193,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, }) @@ -194,7 +210,7 @@ func TestBuildAgentEntriesRecordsPerScopeVersions(t *testing.T) { // The renderer collapses the scopes and surfaces the stale one, rather than // hiding it behind the up-to-date scope. - assert.Equal(t, "databricks plugin · v0.2.5 · update available", agentStatusLabel(cc, "0.2.6")) + assert.Equal(t, "plugin · v0.2.5 · update available", agentStatusLabel(cc, "0.2.6")) } func TestRenderListJSONScopeFiltersSummary(t *testing.T) { @@ -373,7 +389,7 @@ func TestRenderListTextGroupsExperimental(t *testing.T) { assert.NotContains(t, got, "[experimental]") } -func TestRenderListTextShowsPluginInstallsBeforeRawSkills(t *testing.T) { +func TestRenderListTextShowsAgentsBeforeRawSkills(t *testing.T) { ctx, stderr := cmdio.NewTestContextWithStderr(t.Context()) out := listOutput{ Release: "0.2.6", @@ -385,9 +401,17 @@ 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, + BinaryDetected: true, + ConfigDetected: true, + Installed: map[string]pluginInfo{installer.ScopeGlobal: {Version: "0.2.6", NativeScope: "user"}}, + }, + { + Name: "cursor", + DisplayName: "Cursor", + Managed: false, }, }, } @@ -395,13 +419,17 @@ func TestRenderListTextShowsPluginInstallsBeforeRawSkills(t *testing.T) { renderListText(ctx, out, installer.ScopeGlobal) got := stderr.String() - pluginIdx := strings.Index(got, "Plugin installs:") + agentsIdx := strings.Index(got, "Agents:") rawIdx := strings.Index(got, "Available raw skill directories") - require.GreaterOrEqual(t, pluginIdx, 0) + require.GreaterOrEqual(t, agentsIdx, 0) require.GreaterOrEqual(t, rawIdx, 0) - assert.Less(t, pluginIdx, rawIdx) + assert.Less(t, agentsIdx, rawIdx) assert.Contains(t, got, "Claude Code") - assert.Contains(t, got, "databricks plugin · v0.2.6 · up to date") + assert.Contains(t, got, "yes (cli, config)") + assert.Contains(t, got, "plugin · v0.2.6 · up to date") + // Skills-only agents are listed too, marked accordingly. + assert.Contains(t, got, "Cursor") + assert.Contains(t, got, "skills only") assert.Contains(t, got, "0/1 raw skill directories installed (global)") }