Skip to content
Open
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 .nextchanges/cli/aitools-install-output-json.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
`databricks aitools install` honors `--output json`, emitting a structured `{scope, agents[...]}` document that reports each agent's delivery and install status so coding agents and CI can consume the result without scraping the text output. JSON mode requires `--scope` and `--agents` so the command runs without interactive prompts ([#6481](https://github.com/databricks/cli/pull/6481)).

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@

=== install --output json emits parseable JSON on stdout; progress stays on stderr
>>> [CLI] experimental aitools install --skills-only --scope=global --agents=claude-code --output json
Command "install" is deprecated, use "databricks aitools install" instead.
Using skills version test-ref
Fetching skills manifest...
Installed 1 skill.
{
"scope": "global",
"agents": [
{
"name": "claude-code",
"delivery": "skills",
"status": "installed"
}
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Isolate HOME so parallel aitools tests don't race on a shared ~/.databricks.
sethome home

title "install --output json emits parseable JSON on stdout; progress stays on stderr"
# --agents makes the run fully non-interactive (no picker, no scope prompt), which
# --output json requires. Piping stdout through jq proves the JSON payload is the
# only thing on stdout; the human-readable progress lines go to stderr and still
# show in the merged capture below.
trace $CLI experimental aitools install --skills-only --scope=global --agents=claude-code --output json | jq .
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Mock server replaces raw.githubusercontent.com for manifest + skill files.
Env.DATABRICKS_SKILLS_BASE_URL = "$DATABRICKS_HOST"
Env.DATABRICKS_SKILLS_REF = "test-ref"

Ignore = [
"home",
]

EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"]

[[Server]]
Pattern = "GET /test-ref/manifest.json"
Response.Body = '''
{
"version": "2",
"updated_at": "2026-01-01T00:00:00Z",
"skills": {
"test-stable": {
"version": "1.0.0",
"description": "Stable test skill",
"files": ["SKILL.md"],
"repo_dir": "skills"
}
}
}
'''

[[Server]]
Pattern = "GET /test-ref/skills/test-stable/SKILL.md"
Response.Body = '''---
name: test-stable
---

# Test stable skill
'''
204 changes: 187 additions & 17 deletions cmd/aitools/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@ import (
"strings"

"github.com/charmbracelet/huh"
"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"
"github.com/databricks/cli/libs/log"
"github.com/spf13/cobra"
)
Expand Down Expand Up @@ -95,15 +97,27 @@ Agent selection:
(unset, interactive) A picker over all known agents, detected ones pre-checked.
(unset, non-interactive) Act on every detected agent.

Output:
--output json Emit a structured result instead of text. Requires --scope
and --agents so the command runs without interactive prompts.

Supported agents: ` + strings.Join(agents.SupportedNames(), ", "),
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
jsonMode := installOutputIsJSON(cmd)

if skillsOnly && pathFlag != "" {
return errors.New("cannot use --skills-only with --path; --path always writes raw skill files")
}

// --path is a plain file dump with no agents or install state, so there
// is no per-agent result to report. Reject --output json here rather than
// letting the dump run and silently emit no JSON.
if jsonMode && pathFlag != "" {
return errors.New("cannot use --output json with --path; --path writes raw skill files and produces no JSON result")
}

opts := installer.InstallOptions{
IncludeExperimental: includeExperimental,
SpecificSkills: splitAndTrim(skillsFlag),
Expand All @@ -128,6 +142,24 @@ Supported agents: ` + strings.Join(agents.SupportedNames(), ", "),
if err != nil {
return err
}

// JSON output must be fully non-interactive: every choice has to come
// from flags so no scope prompt, agent picker, or confirm is shown.
// Require the flags those prompts would otherwise resolve, and fail
// fast naming them.
if jsonMode {
var missing []string
if !projectFlag && !globalFlag {
missing = append(missing, "--scope")
}
if agentsFlag == "" {
missing = append(missing, "--agents")
}
if len(missing) > 0 {
return fmt.Errorf("--output json requires %s so the command runs without interactive prompts", strings.Join(missing, " and "))
}
}

scope, err := resolveScopeWithPrompt(ctx, projectFlag, globalFlag)
if err != nil {
return err
Expand Down Expand Up @@ -173,7 +205,28 @@ Supported agents: ` + strings.Join(agents.SupportedNames(), ", "),
Experimental: opts.IncludeExperimental,
})

return executePlan(ctx, src, plan, opts)
outcomes, runErr := executePlan(ctx, src, plan, opts, jsonMode)
if jsonMode {
if jerr := renderJSON(cmd.OutOrStdout(), buildInstallOutput(opts.Scope, outcomes, runErr)); jerr != nil {
// Rendering failed, so the JSON the caller parses is broken.
// Report the render error unless the run already failed for
// another reason.
if runErr == nil {
runErr = jerr
}
return runErr
}
// The JSON payload is the only thing on stdout and already reports
// the outcome. On failure, exit non-zero without root printing a
// duplicate "Error: ..." line to stderr; root prints errors itself
// (see cmd/root/root.go), so ErrAlreadyPrinted is how a command
// opts out of that, not cmd.SilenceErrors.
if runErr != nil {
return root.ErrAlreadyPrinted
}
return nil
}
return runErr
},
}

Expand All @@ -189,6 +242,20 @@ Supported agents: ` + strings.Join(agents.SupportedNames(), ", "),
return cmd
}

// installOutputIsJSON reports whether --output json was requested. Unlike list,
// install can run detached from root: the legacy `skills install` alias builds a
// NewInstallCmd and executes it directly (see newLegacySkillsInstallCmd), so the
// root-supplied --output flag may be absent. Treat a missing flag as text rather
// than panicking the way root.OutputType would.
func installOutputIsJSON(cmd *cobra.Command) bool {

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.

[nice to have] This duplicates root.OutputType(cmd), which the sibling list.go:141 already uses. The f == nil fallback is dead in production — --output is always a persistent root flag — and is the kind of speculative/defensive branch CLAUDE.md asks us to drop. Prefer root.OutputType(cmd) == flags.OutputJSON for consistency with list.

f := cmd.Flag("output")
if f == nil {
return false
}
out, ok := f.Value.(*flags.Output)
return ok && *out == flags.OutputJSON
}

// selectAgents returns the agents to act on when --agents is not given. The
// interactive path shows a picker over all known agents; the non-interactive
// path acts on detected agents, matching today's default. Skills delivery only
Expand Down Expand Up @@ -368,11 +435,48 @@ func printPlanSummary(ctx context.Context, plan []agentPlanItem, scope string) {
cmdio.LogString(ctx, "")
}

// executePlan carries out the plan. Skills installs go through the existing
// skills path (preserving its output). Plugin installs are reported but never
// silently fall back to skills: a blocked install is a warning (exit 0), unless
// the agent was explicitly named via --agents, which is an error.
func executePlan(ctx context.Context, src installer.ManifestSource, plan []agentPlanItem, opts installer.InstallOptions) error {
// agentOutcome is one agent's result after executePlan: how the databricks
// tools were delivered (or attempted), and, when the agent did not succeed, a
// human-readable message for --output json.
type agentOutcome struct {
agent *agents.Agent
delivery delivery
status outcomeStatus
message string // set when skipped or failed
}

type outcomeStatus string

const (
outcomeInstalled outcomeStatus = "installed"
outcomeSkipped outcomeStatus = "skipped"
outcomeFailed outcomeStatus = "failed"
)

// agentErrors wraps the failures of explicitly named agents, which are already
// reported in their per-agent outcomes. Wrapping lets the JSON layer tell a
// per-agent failure apart from a top-level failure that has no per-agent entry,
// so each is surfaced exactly once.
type agentErrors struct{ err error }

func (e *agentErrors) Error() string { return e.err.Error() }
func (e *agentErrors) Unwrap() error { return e.err }

// topLevelFailure returns the run error when it has no per-agent entry, or nil
// when the failure is already reported per agent — so a per-agent failure is not
// duplicated in the top-level error field.
func topLevelFailure(runErr error) error {
if _, ok := errors.AsType[*agentErrors](runErr); ok {
return nil
}
return runErr
}

// executePlan carries out the plan and returns each agent's outcome. Skills
// installs go through the existing skills path. Plugin installs are reported but
// never silently fall back to skills: a blocked install is a warning (exit 0),
// unless the agent was explicitly named via --agents, which is an error.
func executePlan(ctx context.Context, src installer.ManifestSource, plan []agentPlanItem, opts installer.InstallOptions, quiet bool) ([]agentOutcome, error) {
var skillsAgents []*agents.Agent
var pluginItems, skipItems []agentPlanItem
for _, it := range plan {
Expand All @@ -386,56 +490,84 @@ func executePlan(ctx context.Context, src installer.ManifestSource, plan []agent
}
}

var outcomes []agentOutcome
var explicitErrs []error

if len(skillsAgents) > 0 {
installer.PrintInstallingFor(ctx, skillsAgents)
if !quiet {
installer.PrintInstallingFor(ctx, skillsAgents)
}
// A skills install runs as a group; on failure the whole command fails.
if err := installSkillsForAgentsFn(ctx, src, skillsAgents, opts); err != nil {
return err
return outcomes, err
}
for _, a := range skillsAgents {
outcomes = append(outcomes, agentOutcome{agent: a, delivery: deliverySkills, status: outcomeInstalled})
}
}

pluginCount := 0
if len(pluginItems) > 0 {
ref, _, err := installer.GetSkillsRef(ctx)
if err != nil {
return err
return outcomes, err
}
records := map[string]installer.PluginRecord{}
for _, it := range pluginItems {
cmdio.LogString(ctx, fmt.Sprintf("Installing databricks plugin for %s...", it.agent.DisplayName))
if !quiet {
cmdio.LogString(ctx, fmt.Sprintf("Installing databricks plugin for %s...", it.agent.DisplayName))
}
rec, err := installPluginForAgentFn(ctx, it.agent, it.scope, ref)
if err != nil {
cmdio.LogString(ctx, cmdio.Yellow(ctx, fmt.Sprintf("Skipped %s: %v", it.agent.DisplayName, err)))
if !quiet {
cmdio.LogString(ctx, cmdio.Yellow(ctx, fmt.Sprintf("Skipped %s: %v", it.agent.DisplayName, err)))
}
outcomes = append(outcomes, agentOutcome{
agent: it.agent,
delivery: deliveryPlugin,
status: outcomeFailed,
message: err.Error(),
})
if it.explicit {
explicitErrs = append(explicitErrs, err)
}
continue
}
records[it.agent.Name] = rec
pluginCount++
outcomes = append(outcomes, agentOutcome{agent: it.agent, delivery: deliveryPlugin, status: outcomeInstalled})
// Remove any raw skills we previously dropped on this agent so the
// plugin and leftover files don't surface the same skills twice.
if err := cleanupLegacyFn(ctx, it.agent, opts.Scope); err != nil {
log.Debugf(ctx, "Legacy skill cleanup for %s failed: %v", it.agent.DisplayName, err)
}
cmdio.LogString(ctx, fmt.Sprintf(" %s databricks plugin %s", it.agent.DisplayName, versionToken(rec.Version)))
if !quiet {
cmdio.LogString(ctx, fmt.Sprintf(" %s databricks plugin %s", it.agent.DisplayName, versionToken(rec.Version)))
}
}
if len(records) > 0 {
if err := recordPluginInstallsFn(ctx, opts.Scope, records, ref); err != nil {
return err
return outcomes, err
}
}
}

for _, it := range skipItems {
cmdio.LogString(ctx, cmdio.Yellow(ctx, "Skipped "+it.agent.DisplayName+": "+it.reason))
if !quiet {
cmdio.LogString(ctx, cmdio.Yellow(ctx, "Skipped "+it.agent.DisplayName+": "+it.reason))
}
outcomes = append(outcomes, agentOutcome{
agent: it.agent,
delivery: deliverySkip,
status: outcomeSkipped,
message: it.reason,
})
if it.explicit {
explicitErrs = append(explicitErrs, fmt.Errorf("%s: %s", it.agent.DisplayName, it.reason))
}
}

if pluginCount > 0 {
if pluginCount > 0 && !quiet {
noun := "agent"
if pluginCount != 1 {
noun = "agents"
Expand All @@ -444,9 +576,47 @@ func executePlan(ctx context.Context, src installer.ManifestSource, plan []agent
}

if len(explicitErrs) > 0 {
return errors.Join(explicitErrs...)
return outcomes, &agentErrors{err: errors.Join(explicitErrs...)}
}
return outcomes, nil
}

type installOutput struct {
Scope string `json:"scope"`
Agents []agentResultJSON `json:"agents"`

// Error is a top-level failure message with no per-agent entry (e.g. a
// skills-group install failure); empty on success. It is local-only and never
// sent to telemetry.
Error string `json:"error,omitempty"`
}

type agentResultJSON struct {
Name string `json:"name"`
Delivery string `json:"delivery"`
Status string `json:"status"`
Message string `json:"message,omitempty"`
}

func buildInstallOutput(scope string, outcomes []agentOutcome, runErr error) installOutput {
out := installOutput{Scope: scope, Agents: make([]agentResultJSON, 0, len(outcomes))}
for _, o := range outcomes {
entry := agentResultJSON{
Name: o.agent.Name,
Delivery: o.delivery.String(),
Status: string(o.status),
Message: o.message,
}
out.Agents = append(out.Agents, entry)
}
// A top-level failure (skills-group install, ref lookup, plugin recording)
// has no per-agent entry, so surface it here too; otherwise the consumer sees
// a non-zero exit with an empty agents array and no reason. Per-agent failures
// stay in the agents entries above and are not repeated here.
if e := topLevelFailure(runErr); e != nil {
out.Error = e.Error()
}
return nil
return out
}

// resolveAgentNames parses a comma-separated list of agent names and validates
Expand Down
Loading
Loading