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
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
62 changes: 62 additions & 0 deletions cmd/aitools/telemetry.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package aitools

import (
"context"
"slices"

"github.com/databricks/cli/cmd/root"
"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: opts.Scope,
Experimental: opts.Experimental,
},
})
}

// agentsField returns the deduped agent names from the plan, sorted so the
// same set of agents always produces the same array on the analytics side.
func agentsField(plan []agentPlanItem) []string {
if len(plan) == 0 {
return nil
}
names := make([]string, 0, len(plan))
seen := make(map[string]struct{}, len(plan))
for _, it := range plan {
if it.agent == nil {
continue
}
name := it.agent.Name
if _, ok := seen[name]; ok {
continue
}
seen[name] = struct{}{}
names = append(names, name)
}
slices.Sort(names)
return names
}
58 changes: 58 additions & 0 deletions cmd/aitools/telemetry_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package aitools

import (
"testing"

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

func TestAgentsField(t *testing.T) {
cases := []struct {
name string
plan []agentPlanItem
want []string
}{
{
name: "empty plan",
plan: nil,
want: nil,
},
{
name: "single agent",
plan: []agentPlanItem{{agent: &agents.Agent{Name: agents.NameClaudeCode}}},
want: []string{"claude-code"},
},
{
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: []string{"claude-code", "codex", "cursor"},
},
{
name: "duplicates are deduplicated",
plan: []agentPlanItem{
{agent: &agents.Agent{Name: agents.NameClaudeCode}},
{agent: &agents.Agent{Name: agents.NameClaudeCode}},
},
want: []string{"claude-code"},
},
{
name: "nil agent entries are skipped",
plan: []agentPlanItem{
{agent: nil},
{agent: &agents.Agent{Name: agents.NameCursor}},
},
want: []string{"cursor"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := agentsField(tc.plan)
assert.Equal(t, tc.want, got)
})
}
}
19 changes: 19 additions & 0 deletions libs/telemetry/protos/aitools_install.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package protos

// AitoolsInstallEvent is emitted on every execution of the `databricks aitools
// install` command. Every field is drawn from a closed set defined in the
// CLI, so the event carries no free text and no user data.
type AitoolsInstallEvent struct {
// Agent names the user targeted, deduplicated and sorted so the array is
// stable across invocations. Values come from the CLI's closed agent
// registry: claude-code, cursor, codex, opencode, github-copilot,
// antigravity.
Agents []string `json:"agents,omitempty"`

// Install scope: "project" (installed under the current working directory)
// or "global" (installed for the whole user).
Scope string `json:"scope,omitempty"`

// Whether the user passed --experimental to include experimental skills.
Experimental bool `json:"experimental,omitempty"`
}
1 change: 1 addition & 0 deletions libs/telemetry/protos/frontend_log.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,5 @@ type DatabricksCliLog struct {
BundleDeployEvent *BundleDeployEvent `json:"bundle_deploy_event,omitempty"`
SshTunnelEvent *SshTunnelEvent `json:"ssh_tunnel_event,omitempty"`
BundleConfigRemoteSyncEvent *BundleConfigRemoteSyncEvent `json:"bundle_config_remote_sync_event,omitempty"`
AitoolsInstallEvent *AitoolsInstallEvent `json:"aitools_install_event,omitempty"`
}
Loading