Skip to content
Merged
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
9 changes: 3 additions & 6 deletions cmd/aitools/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -182,8 +182,7 @@ func selectAgents(ctx context.Context, scope string, skillsOnly bool) ([]*agents
}

var selected []*agents.Agent
for i := range agents.Registry {
a := &agents.Registry[i]
for _, a := range agents.Registry {
detected := a.Detected(ctx)
if !skillsOnly {
detected = detected || a.HasBinary(ctx)
Expand All @@ -199,8 +198,7 @@ func selectAgents(ctx context.Context, scope string, skillsOnly bool) ([]*agents
func agentChoices(ctx context.Context) []agentChoice {
cmdio.LogString(ctx, "Detecting coding agents...")
choices := make([]agentChoice, 0, len(agents.Registry))
for i := range agents.Registry {
a := &agents.Registry[i]
for _, a := range agents.Registry {
choices = append(choices, agentChoice{
agent: a,
label: a.DisplayName + " " + agentStateLabel(a.DisplayState(ctx)),
Expand Down Expand Up @@ -394,8 +392,7 @@ func executePlan(ctx context.Context, src installer.ManifestSource, plan []agent
func resolveAgentNames(_ context.Context, names string) ([]*agents.Agent, error) {
available := make(map[string]*agents.Agent, len(agents.Registry))
var availableNames []string
for i := range agents.Registry {
a := &agents.Registry[i]
for _, a := range agents.Registry {
available[a.Name] = a
availableNames = append(availableNames, a.Name)
}
Expand Down
112 changes: 111 additions & 1 deletion cmd/aitools/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"text/tabwriter"

"github.com/databricks/cli/cmd/root"
"github.com/databricks/cli/libs/aitools/agents"
"github.com/databricks/cli/libs/aitools/installer"
"github.com/databricks/cli/libs/cmdio"
"github.com/databricks/cli/libs/flags"
Expand Down Expand Up @@ -65,13 +66,38 @@ func NewListCmd() *cobra.Command {

// listOutput is the structured representation of `aitools list` used by both
// text rendering and `--output json` consumers. The JSON shape is part of
// the public CLI contract; do not break field names or types.
// the public CLI contract; do not break field names or types. The agents field
// is additive (omitempty) and does not affect the existing skills/summary shape.
type listOutput struct {
Release string `json:"release"`
Skills []skillEntry `json:"skills"`
Summary map[string]scopeSummary `json:"summary"`
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 (false for Cursor, which is added manually).
// 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.
type agentEntry struct {
Name string `json:"name"`
Managed bool `json:"managed"`
Installed map[string]pluginInfo `json:"installed,omitempty"`
// Status carries the manual-add hint for agents whose plugin can't be
// installed headlessly (Cursor); empty for CLI-managed agents.
Status string `json:"status,omitempty"`
}
Comment thread
simonfaltum marked this conversation as resolved.

// pluginInfo is the per-scope plugin record surfaced in list output.
type pluginInfo struct {
Version string `json:"version,omitempty"`
}

const statusManualAddPlugin = "manual_add_plugin"

type skillEntry struct {
Name string `json:"name"`
LatestVersion string `json:"latest_version"`
Expand Down Expand Up @@ -164,9 +190,51 @@ func buildListOutput(ctx context.Context, scope string) (listOutput, error) {
out.Summary[installer.ScopeProject] = scopeSummary{Installed: projectCount, Total: len(names), loaded: projectState != nil}
}

// Filter unloaded scopes here so buildAgentEntries can assume every state
// it receives is non-nil.
states := map[string]*installer.InstallState{}
if globalState != nil {
states[installer.ScopeGlobal] = globalState
}
if projectState != nil {
states[installer.ScopeProject] = projectState
}
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), plus Cursor (which is added
// manually) when present. 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(ctx context.Context, states map[string]*installer.InstallState) []agentEntry {
var entries []agentEntry
for _, a := range agents.Registry {
if a.Plugin == nil {
continue
}

installed := map[string]pluginInfo{}
for scope, st := range states {
if rec, ok := st.Plugins[a.Name]; ok {
installed[scope] = pluginInfo{Version: rec.Version}
}
}
if len(installed) > 0 {
entries = append(entries, agentEntry{Name: a.Name, Managed: true, Installed: installed})
continue
}

if a.Plugin.ManualOnly && (a.Detected(ctx) || a.HasBinary(ctx)) {
entries = append(entries, agentEntry{Name: a.Name, Status: statusManualAddPlugin})
}
}
return entries
}

// 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
Expand Down Expand Up @@ -215,6 +283,48 @@ func renderListText(ctx context.Context, out listOutput, scope string) {
cmdio.LogString(ctx, buf.String())

cmdio.LogString(ctx, summaryLine(out, scope))

if len(out.Agents) > 0 {
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 {
fmt.Fprintf(atw, " %s\t%s\n", a.Name, agentStatusLabel(a, out.Release))
}
atw.Flush()
cmdio.LogString(ctx, ab.String())
}
}

// agentStatusLabel renders the text-view status for an agent, collapsing the
// per-scope plugin records 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 {
if a.Status == statusManualAddPlugin {
return "plugin · add manually with /add-plugin"
}

version, upToDate := "", true
for _, scope := range []string{installer.ScopeProject, installer.ScopeGlobal} {
info, ok := a.Installed[scope]
if !ok {
continue
}
stale := info.Version != release
if version == "" || (upToDate && stale) {
version = info.Version
}
if stale {
upToDate = false
}
}

if upToDate {
return "plugin · v" + version + " · up to date"
}
return "plugin · v" + version + " · update available"
}

func installedStatusFromEntry(s skillEntry, bothScopes bool) string {
Expand Down
118 changes: 118 additions & 0 deletions cmd/aitools/list_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package aitools
import (
"bytes"
"encoding/json"
"os"
"path/filepath"
"testing"

"github.com/databricks/cli/libs/aitools/installer"
Expand Down Expand Up @@ -102,6 +104,122 @@ func TestRenderListJSON(t *testing.T) {
assert.Empty(t, second["installed"])
}

func TestRenderListJSONWithAgents(t *testing.T) {
out := listOutput{
Release: "0.2.6",
Skills: []skillEntry{},
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: "cursor", Status: statusManualAddPlugin},
},
}

var buf bytes.Buffer
require.NoError(t, renderListJSON(&buf, out))

var raw map[string]any
require.NoError(t, json.Unmarshal(buf.Bytes(), &raw))
// Existing contract keys remain.
assert.Contains(t, raw, "release")
assert.Contains(t, raw, "skills")
assert.Contains(t, raw, "summary")

agentsRaw := raw["agents"].([]any)
require.Len(t, agentsRaw, 2)

first := agentsRaw[0].(map[string]any)
assert.Equal(t, "claude-code", first["name"])
assert.Equal(t, true, first["managed"])
installed := first["installed"].(map[string]any)
global := installed["global"].(map[string]any)
assert.Equal(t, "0.2.6", global["version"])

second := agentsRaw[1].(map[string]any)
assert.Equal(t, "cursor", second["name"])
assert.Equal(t, false, second["managed"])
assert.Equal(t, "manual_add_plugin", second["status"])
}

func TestBuildAgentEntries(t *testing.T) {
tmp := t.TempDir()
t.Setenv("HOME", tmp)
t.Setenv("USERPROFILE", tmp)
require.NoError(t, os.MkdirAll(filepath.Join(tmp, ".cursor"), 0o755))
ctx := cmdio.MockDiscard(t.Context())

globalState := &installer.InstallState{
Plugins: map[string]installer.PluginRecord{
"claude-code": {Plugin: "databricks", Version: "0.2.6"},
"codex": {Plugin: "databricks", Version: "0.2.5"},
},
}

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, "claude-code")
assert.True(t, byName["claude-code"].Managed)
assert.Equal(t, "0.2.6", byName["claude-code"].Installed[installer.ScopeGlobal].Version)
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, "plugin · v0.2.5 · update available", agentStatusLabel(byName["codex"], "0.2.6"))

// Cursor is detected (config dir) but never CLI-managed.
require.Contains(t, byName, "cursor")
assert.False(t, byName["cursor"].Managed)
assert.Equal(t, statusManualAddPlugin, byName["cursor"].Status)
assert.Empty(t, byName["cursor"].Installed)
assert.Equal(t, "plugin · add manually with /add-plugin", agentStatusLabel(byName["cursor"], "0.2.6"))
}

func TestBuildAgentEntriesRecordsPerScopeVersions(t *testing.T) {
tmp := t.TempDir()
t.Setenv("HOME", tmp)
t.Setenv("USERPROFILE", tmp)
ctx := cmdio.MockDiscard(t.Context())

// Same agent recorded in both scopes: global current, project stale. Both
// versions are recorded; no scope is merged away.
globalState := &installer.InstallState{Plugins: map[string]installer.PluginRecord{
"claude-code": {Plugin: "databricks", Version: "0.2.6"},
}}
projectState := &installer.InstallState{Plugins: map[string]installer.PluginRecord{
"claude-code": {Plugin: "databricks", Version: "0.2.5"},
}}

entries := buildAgentEntries(ctx, map[string]*installer.InstallState{
installer.ScopeGlobal: globalState,
installer.ScopeProject: projectState,
})
byName := map[string]agentEntry{}
for _, e := range entries {
byName[e.Name] = e
}

require.Contains(t, byName, "claude-code")
cc := byName["claude-code"]
assert.True(t, cc.Managed)
assert.Equal(t, "0.2.6", cc.Installed[installer.ScopeGlobal].Version)
assert.Equal(t, "0.2.5", cc.Installed[installer.ScopeProject].Version)

// The renderer collapses the scopes and surfaces the stale one, rather than
// hiding it behind the up-to-date scope.
assert.Equal(t, "plugin · v0.2.5 · update available", agentStatusLabel(cc, "0.2.6"))
}

func TestRenderListJSONScopeFiltersSummary(t *testing.T) {
out := listOutput{
Release: "0.1.0",
Expand Down
30 changes: 28 additions & 2 deletions cmd/aitools/version.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,11 @@ package aitools
import (
"context"
"fmt"
"maps"
"slices"
"strings"

"github.com/databricks/cli/libs/aitools/agents"
"github.com/databricks/cli/libs/aitools/installer"
"github.com/databricks/cli/libs/cmdio"
"github.com/databricks/cli/libs/log"
Expand Down Expand Up @@ -58,15 +61,21 @@ func NewVersionCmd() *cobra.Command {
if bothScopes {
label = "Skills (global)"
}
printVersionLine(ctx, label, globalState, latestRef)
if len(globalState.Skills) > 0 {
printVersionLine(ctx, label, globalState, latestRef)
}
printPluginLines(ctx, globalState)
}

if projectState != nil {
label := "Skills"
if bothScopes {
label = "Skills (project)"
}
printVersionLine(ctx, label, projectState, latestRef)
if len(projectState.Skills) > 0 {
printVersionLine(ctx, label, projectState, latestRef)
}
printPluginLines(ctx, projectState)
}

return nil
Expand All @@ -76,6 +85,23 @@ func NewVersionCmd() *cobra.Command {
return cmd
}

// printPluginLines prints one line per plugin recorded in the scope's state.
func printPluginLines(ctx context.Context, state *installer.InstallState) {
for _, name := range slices.Sorted(maps.Keys(state.Plugins)) {
rec := state.Plugins[name]
cmdio.LogString(ctx, fmt.Sprintf(" Plugin (%s): v%s", agentDisplayName(name), rec.Version))
}
}

// agentDisplayName returns the agent's display name, falling back to its
// registry name when it isn't a known agent.
func agentDisplayName(name string) string {
if agent := agents.ByName(name); agent != nil {
return agent.DisplayName
}
return name
}

// printVersionLine prints a single version line for a scope.
func printVersionLine(ctx context.Context, label string, state *installer.InstallState, latestRef string) {
version := strings.TrimPrefix(state.Release, "v")
Expand Down
Loading
Loading