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
11 changes: 9 additions & 2 deletions cmd/aitools/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,10 @@ func NewInstallCmd() *cobra.Command {
var projectFlag, globalFlag bool

cmd := &cobra.Command{
Use: "install",
Short: "Install Databricks skills and plugins for coding agents",
Use: "install",
// Resolve auth best-effort so telemetry can upload; see tryConfigureAuth.
PreRunE: tryConfigureAuth,
Short: "Install Databricks skills and plugins for coding agents",
Long: `Install Databricks skills and plugins for detected coding agents.

By default this installs the databricks plugin through each agent's own CLI
Expand Down Expand Up @@ -150,6 +152,11 @@ Supported agents: Claude Code, Cursor, Codex CLI, OpenCode, GitHub Copilot, Anti
}
}

defer logInstallEvent(ctx, plan, installOpts{
Scope: opts.Scope,
Experimental: opts.IncludeExperimental,
})

return executePlan(ctx, src, plan, opts)
},
}
Expand Down
17 changes: 9 additions & 8 deletions cmd/aitools/install_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"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/telemetry"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
Expand Down Expand Up @@ -238,7 +239,7 @@ func TestInstallSkillsOnlyAllAgents(t *testing.T) {
setupTestAgents(t)
calls := setupInstallMock(t)

ctx := cmdio.MockDiscard(t.Context())
ctx := telemetry.WithNewLogger(cmdio.MockDiscard(t.Context()))
cmd := NewInstallCmd()
cmd.SetContext(ctx)
cmd.SetArgs([]string{"--skills-only"})
Expand All @@ -253,7 +254,7 @@ func TestInstallSkillsOnlySpecificSkills(t *testing.T) {
setupTestAgents(t)
calls := setupInstallMock(t)

ctx := cmdio.MockDiscard(t.Context())
ctx := telemetry.WithNewLogger(cmdio.MockDiscard(t.Context()))
cmd := NewInstallCmd()
cmd.SetContext(ctx)
cmd.SetArgs([]string{"--skills-only", "--skills", "databricks,databricks-apps"})
Expand All @@ -267,7 +268,7 @@ func TestInstallSkillsOnlyExperimental(t *testing.T) {
setupTestAgents(t)
calls := setupInstallMock(t)

ctx := cmdio.MockDiscard(t.Context())
ctx := telemetry.WithNewLogger(cmdio.MockDiscard(t.Context()))
cmd := NewInstallCmd()
cmd.SetContext(ctx)
cmd.SetArgs([]string{"--skills-only", "--experimental"})
Expand All @@ -288,7 +289,7 @@ func TestInstallPluginFirstDefault(t *testing.T) {

ctx, stderr := cmdio.NewTestContextWithStderr(t.Context())
cmd := NewInstallCmd()
cmd.SetContext(ctx)
cmd.SetContext(telemetry.WithNewLogger(ctx))

require.NoError(t, cmd.Execute())
require.Len(t, *plugins, 1)
Expand Down Expand Up @@ -327,7 +328,7 @@ func TestInstallInteractivePickerAndConfirm(t *testing.T) {
go drainReader(test.Stderr)

cmd := NewInstallCmd()
cmd.SetContext(ctx)
cmd.SetContext(telemetry.WithNewLogger(ctx))

errc := make(chan error, 1)
go func() { errc <- cmd.RunE(cmd, nil) }()
Expand All @@ -350,7 +351,7 @@ func TestInstallExplicitAgentWorksUndetected(t *testing.T) {
t.Setenv("DATABRICKS_SKILLS_REF", "v0.2.6")
plugins := setupPluginMock(t)

ctx := cmdio.MockDiscard(t.Context())
ctx := telemetry.WithNewLogger(cmdio.MockDiscard(t.Context()))
cmd := NewInstallCmd()
cmd.SetContext(ctx)
cmd.SetArgs([]string{"--agents", "codex"})
Expand Down Expand Up @@ -463,7 +464,7 @@ func TestInstallScopeFlag(t *testing.T) {
setupTestAgents(t)
calls := setupInstallMock(t)

ctx := cmdio.MockDiscard(t.Context())
ctx := telemetry.WithNewLogger(cmdio.MockDiscard(t.Context()))
cmd := NewInstallCmd()
cmd.SetContext(ctx)
cmd.SetArgs(tt.args)
Expand Down Expand Up @@ -503,7 +504,7 @@ func TestInstallNoFlagNonInteractiveUsesGlobal(t *testing.T) {
setupTestAgents(t)
calls := setupInstallMock(t)

ctx := cmdio.MockDiscard(t.Context())
ctx := telemetry.WithNewLogger(cmdio.MockDiscard(t.Context()))
cmd := NewInstallCmd()
cmd.SetContext(ctx)
cmd.SetArgs([]string{"--skills-only"})
Expand Down
15 changes: 8 additions & 7 deletions cmd/aitools/legacy_skills_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"testing"

"github.com/databricks/cli/libs/cmdio"
"github.com/databricks/cli/libs/telemetry"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
Expand All @@ -13,7 +14,7 @@ func TestLegacySkillsInstallDelegatesToInstall(t *testing.T) {
setupTestAgents(t)
calls := setupInstallMock(t)

ctx := cmdio.MockDiscard(t.Context())
ctx := telemetry.WithNewLogger(cmdio.MockDiscard(t.Context()))

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.

We need the logger for the telemetry because otherwise, it will panic. A logger is always present in the production path through the root command.

cmd := newLegacySkillsInstallCmd()
cmd.SetContext(ctx)

Expand All @@ -28,7 +29,7 @@ func TestLegacySkillsInstallForwardsSkillName(t *testing.T) {
setupTestAgents(t)
calls := setupInstallMock(t)

ctx := cmdio.MockDiscard(t.Context())
ctx := telemetry.WithNewLogger(cmdio.MockDiscard(t.Context()))
cmd := newLegacySkillsInstallCmd()
cmd.SetContext(ctx)

Expand All @@ -43,7 +44,7 @@ func TestLegacySkillsInstallExecuteNoArgs(t *testing.T) {
setupTestAgents(t)
calls := setupInstallMock(t)

ctx := cmdio.MockDiscard(t.Context())
ctx := telemetry.WithNewLogger(cmdio.MockDiscard(t.Context()))
cmd := newLegacySkillsInstallCmd()
cmd.SetContext(ctx)
cmd.SetArgs([]string{})
Expand All @@ -60,7 +61,7 @@ func TestLegacySkillsInstallExecuteWithSkillName(t *testing.T) {
setupTestAgents(t)
calls := setupInstallMock(t)

ctx := cmdio.MockDiscard(t.Context())
ctx := telemetry.WithNewLogger(cmdio.MockDiscard(t.Context()))
cmd := newLegacySkillsInstallCmd()
cmd.SetContext(ctx)
cmd.SetArgs([]string{"databricks"})
Expand All @@ -76,7 +77,7 @@ func TestLegacySkillsInstallForwardsExperimental(t *testing.T) {
setupTestAgents(t)
calls := setupInstallMock(t)

ctx := cmdio.MockDiscard(t.Context())
ctx := telemetry.WithNewLogger(cmdio.MockDiscard(t.Context()))
cmd := newLegacySkillsInstallCmd()
cmd.SetContext(ctx)
cmd.SetArgs([]string{"--experimental"})
Expand All @@ -89,7 +90,7 @@ func TestLegacySkillsInstallForwardsExperimental(t *testing.T) {
}

func TestLegacySkillsInstallExecuteRejectsTwoArgs(t *testing.T) {
ctx := cmdio.MockDiscard(t.Context())
ctx := telemetry.WithNewLogger(cmdio.MockDiscard(t.Context()))
cmd := newLegacySkillsInstallCmd()
cmd.SetContext(ctx)
cmd.SetArgs([]string{"a", "b"})
Expand All @@ -111,7 +112,7 @@ func TestLegacySkillsListDelegatesToListFn(t *testing.T) {
return nil
}

ctx := cmdio.MockDiscard(t.Context())
ctx := telemetry.WithNewLogger(cmdio.MockDiscard(t.Context()))
cmd := newLegacySkillsListCmd()
cmd.SetContext(ctx)

Expand Down
99 changes: 99 additions & 0 deletions cmd/aitools/telemetry.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package aitools

import (
"context"
"slices"

"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/telemetry"
"github.com/databricks/cli/libs/telemetry/protos"
"github.com/spf13/cobra"
)

// tryConfigureAuth resolves auth config onto the context so telemetry can
// upload at command exit. It does not fail if auth is not configured.
func tryConfigureAuth(cmd *cobra.Command, args []string) error {
ctx := root.SkipPrompt(cmd.Context())
ctx = root.SkipLoadBundle(ctx)
cmd.SetContext(ctx)
_, _ = root.MustAnyClient(cmd, args)
return nil
}

// installOpts is the subset of install options telemetry records, kept narrow
// so every field that leaves the machine is visible here.
type installOpts struct {
Scope string
Experimental bool
}

// logInstallEvent buffers an install event; cmd/root uploads it at exit.
func logInstallEvent(ctx context.Context, plan []agentPlanItem, opts installOpts) {
telemetry.Log(ctx, protos.DatabricksCliLog{
AitoolsInstallEvent: &protos.AitoolsInstallEvent{
Agents: agentsField(plan),
Scope: scopeType(opts.Scope),
Experimental: opts.Experimental,
},
})
}

// agentsField returns the deduped agent enums from the plan, sorted so the
// same set of agents always produces the same array on the analytics side.
func agentsField(plan []agentPlanItem) []protos.AitoolsAgentType {
if len(plan) == 0 {
return nil
}
out := make([]protos.AitoolsAgentType, 0, len(plan))
seen := make(map[protos.AitoolsAgentType]struct{}, len(plan))
for _, it := range plan {
if it.agent == nil {
continue
}
t := agentType(it.agent.Name)
if _, ok := seen[t]; ok {
continue
}
seen[t] = struct{}{}
out = append(out, t)
}
slices.Sort(out)
return out
}

// agentType maps a CLI agent registry name to its telemetry enum. Every agent
// in agents.Registry must have a case here; TestAgentTypeCoversRegistry fails
// if one is missing. The default maps genuinely unknown names to Unspecified
// so an older proto never drops an install.
func agentType(name string) protos.AitoolsAgentType {
switch name {
case agents.NameClaudeCode:
return protos.AitoolsAgentTypeClaudeCode
case agents.NameCursor:
return protos.AitoolsAgentTypeCursor
case agents.NameCodex:
return protos.AitoolsAgentTypeCodex
case agents.NameOpenCode:
return protos.AitoolsAgentTypeOpenCode
case agents.NameCopilot:
return protos.AitoolsAgentTypeCopilot
case agents.NameAntigravity:
return protos.AitoolsAgentTypeAntigravity
default:
return protos.AitoolsAgentTypeUnspecified
}
}

// scopeType maps a resolved install scope to its telemetry enum.
func scopeType(scope string) protos.AitoolsInstallScope {
switch scope {
case installer.ScopeGlobal:
return protos.AitoolsInstallScopeGlobal
case installer.ScopeProject:
return protos.AitoolsInstallScopeProject
default:
return protos.AitoolsInstallScopeUnspecified
}
}
86 changes: 86 additions & 0 deletions cmd/aitools/telemetry_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package aitools

import (
"testing"

"github.com/databricks/cli/libs/aitools/agents"
"github.com/databricks/cli/libs/aitools/installer"
"github.com/databricks/cli/libs/telemetry/protos"
"github.com/stretchr/testify/assert"
)

func TestAgentsField(t *testing.T) {
cases := []struct {
name string
plan []agentPlanItem
want []protos.AitoolsAgentType
}{
{
name: "empty plan",
plan: nil,
want: nil,
},
{
name: "single agent",
plan: []agentPlanItem{{agent: &agents.Agent{Name: agents.NameClaudeCode}}},
want: []protos.AitoolsAgentType{protos.AitoolsAgentTypeClaudeCode},
},
{
name: "multiple agents are sorted",
plan: []agentPlanItem{
{agent: &agents.Agent{Name: agents.NameCursor}},
{agent: &agents.Agent{Name: agents.NameClaudeCode}},
{agent: &agents.Agent{Name: agents.NameCodex}},
},
want: []protos.AitoolsAgentType{
protos.AitoolsAgentTypeClaudeCode,
protos.AitoolsAgentTypeCodex,
protos.AitoolsAgentTypeCursor,
},
},
{
name: "duplicates are deduplicated",
plan: []agentPlanItem{
{agent: &agents.Agent{Name: agents.NameClaudeCode}},
{agent: &agents.Agent{Name: agents.NameClaudeCode}},
},
want: []protos.AitoolsAgentType{protos.AitoolsAgentTypeClaudeCode},
},
{
name: "nil agent entries are skipped",
plan: []agentPlanItem{
{agent: nil},
{agent: &agents.Agent{Name: agents.NameCursor}},
},
want: []protos.AitoolsAgentType{protos.AitoolsAgentTypeCursor},
},
{
name: "unknown agent maps to unspecified",
plan: []agentPlanItem{{agent: &agents.Agent{Name: "future-agent"}}},
want: []protos.AitoolsAgentType{protos.AitoolsAgentTypeUnspecified},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := agentsField(tc.plan)
assert.Equal(t, tc.want, got)
})
}
}

// TestAgentTypeCoversRegistry fails when an agent is added to agents.Registry
// without a matching telemetry enum in agentType. This is the guard against
// silently logging a new agent as UNSPECIFIED.
func TestAgentTypeCoversRegistry(t *testing.T) {
for _, a := range agents.Registry {
assert.NotEqualf(t, protos.AitoolsAgentTypeUnspecified, agentType(a.Name),
"agent %q has no telemetry enum: add a case to agentType and a value to "+
"AitoolsAgentType in enum.proto (Universe) + aitools_install.go (CLI)", a.Name)
}
}

func TestScopeType(t *testing.T) {
assert.Equal(t, protos.AitoolsInstallScopeGlobal, scopeType(installer.ScopeGlobal))
assert.Equal(t, protos.AitoolsInstallScopeProject, scopeType(installer.ScopeProject))
assert.Equal(t, protos.AitoolsInstallScopeUnspecified, scopeType(""))
}
Loading
Loading