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
15 changes: 15 additions & 0 deletions cmd/aitools/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,21 @@ const (
deliverySkip
)

// String returns the delivery name used in `list --output json`, so the install
// plan and the list output name the same thing the same way.
func (d delivery) String() string {
switch d {
case deliveryPlugin:
return "plugin"
case deliverySkills:
return "skills"
case deliverySkip:
return "skip"
default:
return "unknown"
}
}

// agentPlanItem is the resolved plan for one agent: what we'll do and why.
type agentPlanItem struct {
agent *agents.Agent
Expand Down
137 changes: 107 additions & 30 deletions cmd/aitools/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"fmt"
"io"
"maps"
"os"
"slices"
"strings"
"text/tabwriter"
Expand Down Expand Up @@ -75,20 +76,40 @@ 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 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 agents with a plugin install, so these fields are additive.
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 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 install found there; always present, empty
// when nothing is installed, matching the skills shape.
Installed map[string]installInfo `json:"installed"`
}

// pluginInfo is the per-scope plugin record surfaced in list output.
type pluginInfo struct {
// hasPluginInstall reports whether any scope holds a databricks plugin install
// (as opposed to raw skill files).
func (a agentEntry) hasPluginInstall() bool {
for _, info := range a.Installed {
if info.Delivery == deliveryPlugin.String() {
return true
}
}
return false
}

// installInfo is one agent's install in one CLI scope: how the databricks tools
// were delivered there, and the version recorded for that delivery.
type installInfo struct {
// Delivery is deliveryPlugin ("plugin") when the CLI installed the databricks
// plugin through the agent's own CLI, or deliverySkills ("skills") when raw
// skill files were installed instead.
Delivery string `json:"delivery"`
Version string `json:"version,omitempty"`
NativeScope string `json:"native_scope,omitempty"`
}
Expand Down Expand Up @@ -194,36 +215,78 @@ 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 found for it.
// 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),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

detected comes from IsPreselected, which for a plugin agent is true only when its binary is on PATH — so an installed ~/.claude with no claude on PATH reports detected:false, contradicting the PR's "binary or config dir found". Intended, or did you mean a presence check?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I want to just copy the TUI UX, so I used IsPreselected which is the same method the TUI agent selector uses to decide if an agent is detected or not.

Plugin agents needs the binary on PATH to install properly, so returning detected: false is expected here

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

detected is populated from the preselection verdict, not a presence check.

The JSON field is named detected, but it's set from Agent.IsPreselected(ctx) rather than Agent.Detected(ctx). Those are different concepts, and they diverge from "is this agent present on the machine" in both directions (see IsPreselectedDisplayState in libs/aitools/agents/detect.go):

  • Configured but CLI not on PATHStateInstalledCLIMissingdetected: false. E.g. an agent installed via a GUI so its config dir exists but the binary isn't on PATH — reported as not detected, though it's actually present.
  • Binary on PATH but never configuredStateAvailabledetected: true. E.g. the CLI is installed but the config dir doesn't exist yet — reported as detected, though nothing's set up.

IsPreselected is the "should the picker pre-check this row" verdict, which is a reasonable thing to expose — but under the name detected a JSON consumer (the VSCode extension) will read it as presence and get the wrong answer at both boundaries.

Two ways to resolve:

  1. If presence is what consumers want, populate the field from a.Detected(ctx) (the config-dir match that already exists).
  2. If the preselection verdict is intentional, keep the value but rename the field (e.g. preselected / recommended) and expand the doc comment — right now it reads "the CLI's own presence verdict," which describes Detected, not IsPreselected.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TLDR; yes this is intentional.

The naming here is a bit tricky, but I think detected is the best name here for what it means without being overly verbose. We are asking "which agents are available to install plugins/skills for?" and for plugin agents if the binary is missing from PATH then we detected that the agent is not available for installation.

preselected or recommended couples this to the initial install UI and I'm not sure if we'll want to use this elsewhere (maybe we never will), so I biased towards a more neutral name.

If the conflict with a.Detected seems too confusing then I can rename (but IMO a.Detected would be better renamed to a.IsConfigPresent)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think both variables are not really represent what they imply.

IsConfigPresent is better then Detected.
Also, if it is within our control, let's rename IsPreselected to explain the real meaning of variable (IsConfigPresent).

}

installed := map[string]pluginInfo{}
// Always emit an installed map, empty when nothing is installed, so the
// JSON shape matches skillEntry ("installed": {}) rather than omitting it.
entry.Installed = map[string]installInfo{}
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] = installInfo{
Delivery: deliveryPlugin.String(),
Version: rec.Version,
NativeScope: rec.Scope,
}
continue
}
// A skills install produces no plugin record, so fall back to the
// agent's own skills dir on disk. This covers both skills-only agents
// (Plugin == nil) and plugin-capable agents installed with
// --skills-only, which are otherwise indistinguishable here.
if agentHasSkillsInScope(ctx, a, scope) {
entry.Installed[scope] = installInfo{
Delivery: deliverySkills.String(),
Version: installer.DisplaySkillsVersion(st.Release),
}
}
}
if len(installed) > 0 {
entries = append(entries, agentEntry{Name: a.Name, Managed: true, Installed: installed})
}

entries = append(entries, entry)
}
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 installs, 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
Expand Down Expand Up @@ -266,13 +329,25 @@ func renderListText(ctx context.Context, out listOutput, scope string) {
}
}

if len(out.Agents) > 0 {
// The text view keeps its original "Plugin installs:" section: only agents with
// a recorded plugin install are shown here, so an agent whose only install is
// raw skills isn't labelled as a plugin (its skills show in the table below).
// The richer per-agent detection state (every registry agent, skills installs
// included) is JSON-only, for the extension.
var pluginInstalls []agentEntry
for _, a := range out.Agents {
if a.hasPluginInstall() {
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()
Expand Down Expand Up @@ -308,12 +383,14 @@ func renderSkillTable(skills []skillEntry, bothScopes bool) 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.
// hidden; project is preferred when every scope matches release. Skills installs
// are skipped: this line reports the plugin, and their versions would otherwise
// decide the status of an agent that also has a plugin in another scope.
func agentStatusLabel(a agentEntry, release string) string {
version, upToDate := "", true
for _, scope := range []string{installer.ScopeProject, installer.ScopeGlobal} {
info, ok := a.Installed[scope]
if !ok {
if !ok || info.Delivery != deliveryPlugin.String() {
continue
}
stale := info.Version != release
Expand Down
Loading
Loading