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
1 change: 1 addition & 0 deletions NEXT_CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
* Added `databricks aitools` command group for installing Databricks skills into your coding agents (Claude Code, Cursor, Codex CLI, OpenCode, GitHub Copilot, Antigravity). Skills are fetched from [github.com/databricks/databricks-agent-skills](https://github.com/databricks/databricks-agent-skills) and either symlinked into each agent's skills directory or copied into the current project. Use `databricks aitools install` to set up, `update` to pull newer versions, `list` to see what's available, and `uninstall` to remove them. Pick where they go with `--scope=project|global` (`--scope=both` is accepted on `update` and `list`).
* `[__settings__].default_profile` is now consulted as a fallback by `databricks api`, `databricks auth token`, and bundle commands when neither `--profile` nor `DATABRICKS_CONFIG_PROFILE` is set. `databricks auth token` continues to give precedence to `DATABRICKS_HOST` over `default_profile`. For bundle commands, `default_profile` only applies when the bundle does not pin its own `workspace.host`.
* `databricks postgres create-role --help` now documents the `--json` body shape and rejects the common mistake of wrapping the body in `{"role": ...}` client-side with a hint pointing at the correct shape ([#5111](https://github.com/databricks/cli/pull/5111)).
* `databricks aitools list` honors `--output json`, emitting a structured `{release, skills[...], summary{}}` document so coding agents and CI can consume the skill/version/installation matrix without scraping the tabular text output ([#5233](https://github.com/databricks/cli/pull/5233)).

### Bundles
* Make sure warnings asking for approval are understood by agents ([#5239](https://github.com/databricks/cli/pull/5239))
Expand Down
224 changes: 152 additions & 72 deletions cmd/aitools/list.go
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
package aitools

import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"maps"
"slices"
"strings"
"text/tabwriter"

"github.com/databricks/cli/cmd/root"
"github.com/databricks/cli/libs/aitools/installer"
"github.com/databricks/cli/libs/cmdio"
"github.com/databricks/cli/libs/flags"
"github.com/databricks/cli/libs/log"
"github.com/spf13/cobra"
)
Expand Down Expand Up @@ -58,128 +63,181 @@ func NewListCmd() *cobra.Command {
return cmd
}

// 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.
type listOutput struct {
Release string `json:"release"`
Skills []skillEntry `json:"skills"`
Summary map[string]scopeSummary `json:"summary"`
}

type skillEntry struct {
Name string `json:"name"`
LatestVersion string `json:"latest_version"`
Experimental bool `json:"experimental"`
Installed map[string]string `json:"installed"`
}

type scopeSummary struct {
Installed int `json:"installed"`
Total int `json:"total"`

// loaded preserves text rendering semantics without changing the JSON contract.
loaded bool
}

func defaultListSkills(cmd *cobra.Command, scope string) error {
ctx := cmd.Context()

ref, explicit, err := installer.GetSkillsRef(ctx)
out, err := buildListOutput(ctx, scope)
if err != nil {
return err
}

src := &installer.GitHubManifestSource{}
manifest, ref, err := installer.FetchSkillsManifestWithFallback(ctx, src, ref, !explicit)
if err != nil {
return fmt.Errorf("failed to fetch manifest: %w", err)
switch root.OutputType(cmd) {
case flags.OutputJSON:
return renderListJSON(cmd.OutOrStdout(), out)
default:
renderListText(ctx, out, scope)
return nil
}
}

// Load global state.
var globalState *installer.InstallState
if scope != installer.ScopeProject {
globalDir, gErr := installer.GlobalSkillsDir(ctx)
if gErr == nil {
globalState, err = installer.LoadState(globalDir)
if err != nil {
log.Debugf(ctx, "Could not load global install state: %v", err)
}
}
// buildListOutput fetches the manifest and per-scope install state and
// returns the structured listOutput. scope=="" loads both scopes; "global"
// or "project" loads only that scope.
func buildListOutput(ctx context.Context, scope string) (listOutput, error) {
ref, explicit, err := installer.GetSkillsRef(ctx)
if err != nil {
return listOutput{}, err
}

// Load project state.
var projectState *installer.InstallState
if scope != installer.ScopeGlobal {
projectDir, pErr := installer.ProjectSkillsDir(ctx)
if pErr == nil {
projectState, err = installer.LoadState(projectDir)
if err != nil {
log.Debugf(ctx, "Could not load project install state: %v", err)
}
}
src := &installer.GitHubManifestSource{}
manifest, ref, err := installer.FetchSkillsManifestWithFallback(ctx, src, ref, !explicit)
if err != nil {
return listOutput{}, fmt.Errorf("failed to fetch manifest: %w", err)
}

// Build sorted list of skill names.
names := slices.Sorted(maps.Keys(manifest.Skills))

version := strings.TrimPrefix(ref, "v")
cmdio.LogString(ctx, "Available skills (v"+version+"):")
cmdio.LogString(ctx, "")
globalState := loadStateForScope(ctx, scope, installer.ScopeProject, installer.GlobalSkillsDir, "global")
projectState := loadStateForScope(ctx, scope, installer.ScopeGlobal, installer.ProjectSkillsDir, "project")

var buf strings.Builder
tw := tabwriter.NewWriter(&buf, 0, 4, 2, ' ', 0)
fmt.Fprintln(tw, " NAME\tVERSION\tINSTALLED")
names := slices.Sorted(maps.Keys(manifest.Skills))

bothScopes := globalState != nil && projectState != nil
out := listOutput{
Release: strings.TrimPrefix(ref, "v"),
Skills: make([]skillEntry, 0, len(names)),
Summary: map[string]scopeSummary{},
}

globalCount := 0
projectCount := 0
globalCount, projectCount := 0, 0
for _, name := range names {
meta := manifest.Skills[name]

tag := ""
if meta.IsExperimental() {
tag = " [experimental]"
entry := skillEntry{
Name: name,
LatestVersion: meta.Version,
Experimental: meta.IsExperimental(),
Installed: map[string]string{},
}

installedStr := installedStatus(name, meta.Version, globalState, projectState, bothScopes)
if globalState != nil {
if _, ok := globalState.Skills[name]; ok {
if v, ok := globalState.Skills[name]; ok {
entry.Installed[installer.ScopeGlobal] = v
globalCount++
}
}
if projectState != nil {
if _, ok := projectState.Skills[name]; ok {
if v, ok := projectState.Skills[name]; ok {
entry.Installed[installer.ScopeProject] = v
projectCount++
}
}
out.Skills = append(out.Skills, entry)
}

fmt.Fprintf(tw, " %s%s\tv%s\t%s\n", name, tag, meta.Version, installedStr)
// Include a summary entry for every scope that was queried, even when the
// install state is missing — agents should see "0/N" rather than guess
// from the absence of a key.
if scope != installer.ScopeProject {
out.Summary[installer.ScopeGlobal] = scopeSummary{Installed: globalCount, Total: len(names), loaded: globalState != nil}
}
if scope != installer.ScopeGlobal {
out.Summary[installer.ScopeProject] = scopeSummary{Installed: projectCount, Total: len(names), loaded: projectState != nil}
}
tw.Flush()
cmdio.LogString(ctx, buf.String())

// Summary line.
switch {
case bothScopes:
cmdio.LogString(ctx, fmt.Sprintf("%d/%d skills installed (global), %d/%d (project)", globalCount, len(names), projectCount, len(names)))
case projectState != nil:
cmdio.LogString(ctx, fmt.Sprintf("%d/%d skills installed (project)", projectCount, len(names)))
case scope == installer.ScopeProject:
cmdio.LogString(ctx, fmt.Sprintf("%d/%d skills installed (project)", 0, len(names)))
default:
cmdio.LogString(ctx, fmt.Sprintf("%d/%d skills installed (global)", globalCount, len(names)))
return out, nil
}

// 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
// global when --scope=project).
func loadStateForScope(ctx context.Context, scopeFilter, excludeScope string, dirFn func(context.Context) (string, error), label string) *installer.InstallState {
if scopeFilter == excludeScope {
return nil
}
return nil
dir, err := dirFn(ctx)
if err != nil {
return nil
}
state, err := installer.LoadState(dir)
if err != nil {
log.Debugf(ctx, "Could not load %s install state: %v", label, err)
return nil
}
return state
}

// installedStatus returns the display string for a skill's installation status.
func installedStatus(name, latestVersion string, globalState, projectState *installer.InstallState, bothScopes bool) string {
globalVer := ""
projectVer := ""
func renderListJSON(w io.Writer, out listOutput) error {
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
return enc.Encode(out)
}

if globalState != nil {
globalVer = globalState.Skills[name]
}
if projectState != nil {
projectVer = projectState.Skills[name]
func renderListText(ctx context.Context, out listOutput, scope string) {
cmdio.LogString(ctx, "Available skills (v"+out.Release+"):")
cmdio.LogString(ctx, "")

bothScopes := scope == "" &&
out.Summary[installer.ScopeGlobal].loaded &&
out.Summary[installer.ScopeProject].loaded

var buf strings.Builder
tw := tabwriter.NewWriter(&buf, 0, 4, 2, ' ', 0)
fmt.Fprintln(tw, " NAME\tVERSION\tINSTALLED")
for _, s := range out.Skills {
tag := ""
if s.Experimental {
tag = " [experimental]"
}
fmt.Fprintf(tw, " %s%s\tv%s\t%s\n", s.Name, tag, s.LatestVersion, installedStatusFromEntry(s, bothScopes))
}
tw.Flush()
cmdio.LogString(ctx, buf.String())

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

func installedStatusFromEntry(s skillEntry, bothScopes bool) string {
globalVer := s.Installed[installer.ScopeGlobal]
projectVer := s.Installed[installer.ScopeProject]

if globalVer == "" && projectVer == "" {
return "not installed"
}

// If both scopes have the skill, show the project version (takes precedence).
if bothScopes && globalVer != "" && projectVer != "" {
return versionLabel(projectVer, latestVersion) + " (project, global)"
return versionLabel(projectVer, s.LatestVersion) + " (project, global)"
}

if projectVer != "" {
label := versionLabel(projectVer, latestVersion)
label := versionLabel(projectVer, s.LatestVersion)
if bothScopes {
return label + " (project)"
}
return label
}

label := versionLabel(globalVer, latestVersion)
label := versionLabel(globalVer, s.LatestVersion)
if bothScopes {
return label + " (global)"
}
Expand All @@ -193,3 +251,25 @@ func versionLabel(installed, latest string) string {
}
return "v" + installed + " (update available)"
}

func summaryLine(out listOutput, scope string) string {
g, gOK := out.Summary[installer.ScopeGlobal]
p, pOK := out.Summary[installer.ScopeProject]

switch {
case gOK && pOK:
// Mirror prior behavior: only print the dual-scope line when both
// scopes have a state file; otherwise only mention the one that does.
if g.loaded && p.loaded {
return fmt.Sprintf("%d/%d skills installed (global), %d/%d (project)", g.Installed, g.Total, p.Installed, p.Total)
}
if p.loaded {
return fmt.Sprintf("%d/%d skills installed (project)", p.Installed, p.Total)
}
return fmt.Sprintf("%d/%d skills installed (global)", g.Installed, g.Total)
case pOK:
return fmt.Sprintf("%d/%d skills installed (project)", p.Installed, p.Total)
default:
return fmt.Sprintf("%d/%d skills installed (global)", g.Installed, g.Total)
}
}
Loading
Loading