-
Notifications
You must be signed in to change notification settings - Fork 195
Add AI Tools plugin detection to the user-agent #5924
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
parthban-db
wants to merge
2
commits into
main
Choose a base branch
from
parthban-db/stack/skills-status
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| // This file adds one aitools/<tool>_<version> pair to the user-agent for each | ||
| // coding tool that has the Databricks plugin installed, e.g. | ||
| // "aitools/claude-code_0.2.9 aitools/codex_0.2.9". Nothing is added when no tool | ||
| // has it installed. The version comes from the tool's own plugin cache, falling | ||
| // back to the CLI install state. | ||
| package root | ||
|
|
||
| import ( | ||
| "context" | ||
|
|
||
| "github.com/databricks/cli/libs/aitools/installer" | ||
| "github.com/databricks/databricks-sdk-go/useragent" | ||
| ) | ||
|
|
||
| // Key in the user agent. | ||
| const aiToolsKey = "aitools" | ||
|
|
||
| func withAiToolsInUserAgent(ctx context.Context) context.Context { | ||
| // Each tool appends one aitools/<tool>_<version> pair to the same context; | ||
| // these accumulate, so this is not the accidental context nesting fatcontext | ||
| // flags. | ||
| for _, tool := range installer.InstalledTools(ctx) { | ||
| ctx = useragent.InContext(ctx, aiToolsKey, tool) //nolint:fatcontext | ||
| } | ||
| return ctx | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,45 @@ | ||
| package root | ||
|
|
||
| import ( | ||
| "testing" | ||
|
|
||
| "github.com/databricks/cli/libs/aitools/installer" | ||
| "github.com/databricks/databricks-sdk-go/useragent" | ||
| "github.com/stretchr/testify/assert" | ||
| "github.com/stretchr/testify/require" | ||
| ) | ||
|
|
||
| // recordPlugin marks the agent's plugin installed in the global CLI state, the | ||
| // simplest signal that drives InstalledTools for any agent. | ||
| func recordPlugin(t *testing.T, name, version string) { | ||
| t.Helper() | ||
| dir, err := installer.GlobalSkillsDir(t.Context()) | ||
| require.NoError(t, err) | ||
| require.NoError(t, installer.SaveState(dir, &installer.InstallState{ | ||
| SchemaVersion: 2, | ||
| Plugins: map[string]installer.PluginRecord{name: {Plugin: "databricks", Version: version}}, | ||
| })) | ||
| } | ||
|
|
||
| func TestWithAiToolsInUserAgent(t *testing.T) { | ||
| t.Run("none installed emits nothing", func(t *testing.T) { | ||
| tmp := t.TempDir() | ||
| t.Setenv("HOME", tmp) | ||
| t.Setenv("USERPROFILE", tmp) | ||
| t.Chdir(t.TempDir()) | ||
|
|
||
| ua := useragent.FromContext(withAiToolsInUserAgent(t.Context())) | ||
| assert.NotContains(t, ua, "aitools/") | ||
| }) | ||
|
|
||
| t.Run("installed tool becomes an aitools version pair", func(t *testing.T) { | ||
| tmp := t.TempDir() | ||
| t.Setenv("HOME", tmp) | ||
| t.Setenv("USERPROFILE", tmp) | ||
| t.Chdir(t.TempDir()) | ||
| recordPlugin(t, "codex", "0.2.9") | ||
|
|
||
| ua := useragent.FromContext(withAiToolsInUserAgent(t.Context())) | ||
| assert.Contains(t, ua, "aitools/codex_0.2.9") | ||
| }) | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,64 @@ | ||
| package installer | ||
|
|
||
| import ( | ||
| "context" | ||
| "slices" | ||
|
|
||
| "github.com/databricks/cli/libs/aitools/agents" | ||
| "github.com/databricks/cli/libs/log" | ||
| ) | ||
|
|
||
| // InstalledTools returns "<agent>_<version>" tokens for every coding tool that | ||
| // has the Databricks plugin installed, sorted. The version is taken from the | ||
| // agent's own plugin cache (ground truth, catches installs made directly through | ||
| // the agent), falling back to the version recorded in the CLI install state when | ||
| // the plugin is not found on disk. An agent with no resolvable version is | ||
| // omitted rather than reported without one. | ||
| func InstalledTools(ctx context.Context) []string { | ||
| recorded := recordedPluginVersions(ctx) | ||
|
|
||
| var tools []string | ||
| for _, a := range agents.Registry { | ||
| if a.Plugin == nil { | ||
| continue | ||
| } | ||
| version, _ := a.DatabricksPluginVersion(ctx) | ||
| if version == "" { | ||
| version = recorded[a.Name] | ||
| } | ||
| if version != "" { | ||
| tools = append(tools, a.Name+"_"+version) | ||
| } | ||
| } | ||
| slices.Sort(tools) | ||
| return tools | ||
| } | ||
|
|
||
| // recordedPluginVersions maps agent name to the databricks plugin version | ||
| // recorded in the CLI install state, merged across global and project scope. | ||
| // When both scopes record a version, the project scope wins (it is the more | ||
| // specific, closer-to-the-invocation install). | ||
| func recordedPluginVersions(ctx context.Context) map[string]string { | ||
| versions := map[string]string{} | ||
| // Load global first, then project, so project overwrites on conflict. | ||
| for _, scope := range []string{ScopeGlobal, ScopeProject} { | ||
| dir, err := skillsDir(ctx, scope) | ||
| if err != nil { | ||
| continue | ||
| } | ||
| state, err := LoadState(dir) | ||
| if err != nil { | ||
| log.Debugf(ctx, "Could not load AI Tools install state in %s: %v", dir, err) | ||
| continue | ||
| } | ||
| if state == nil { | ||
| continue | ||
| } | ||
| for name, rec := range state.Plugins { | ||
| if rec.Version != "" { | ||
| versions[name] = rec.Version | ||
| } | ||
| } | ||
| } | ||
| return versions | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,112 @@ | ||
| package installer | ||
|
|
||
| import ( | ||
| "context" | ||
| "os" | ||
| "path/filepath" | ||
| "testing" | ||
|
|
||
| "github.com/stretchr/testify/assert" | ||
| "github.com/stretchr/testify/require" | ||
| ) | ||
|
|
||
| // TestInstalledTools exercises the real agent registry: claude-code reads its | ||
| // authoritative manifest (installed_plugins.json), while agents without a | ||
| // verified manifest reader (e.g. codex, copilot) fall back to the CLI install | ||
| // state. HOME is redirected to a temp dir so both signals resolve there. | ||
| func TestInstalledTools(t *testing.T) { | ||
| tests := []struct { | ||
| name string | ||
| // claudeManifestVersion, when set, is written to claude-code's manifest. | ||
| claudeManifestVersion string | ||
| // globalPlugins / projectPlugins map agent -> recorded state version. | ||
| globalPlugins map[string]string | ||
| projectPlugins map[string]string | ||
| want []string | ||
| }{ | ||
| { | ||
| name: "none installed", | ||
| want: nil, | ||
| }, | ||
| { | ||
| // Direct-from-marketplace install: claude-code version from manifest. | ||
| name: "claude manifest version", | ||
| claudeManifestVersion: "0.2.9", | ||
| want: []string{"claude-code_0.2.9"}, | ||
| }, | ||
| { | ||
| // codex has no verified manifest reader, so it uses the state version. | ||
| name: "state fallback for codex", | ||
| globalPlugins: map[string]string{"codex": "0.2.7"}, | ||
| want: []string{"codex_0.2.7"}, | ||
| }, | ||
| { | ||
| // Manifest wins over state for claude-code when both are present. | ||
| name: "manifest overrides state", | ||
| claudeManifestVersion: "0.2.9", | ||
| globalPlugins: map[string]string{"claude-code": "0.2.5"}, | ||
| want: []string{"claude-code_0.2.9"}, | ||
| }, | ||
| { | ||
| // Project scope wins over global in the state fallback. | ||
| name: "project state overrides global", | ||
| globalPlugins: map[string]string{"codex": "0.2.5"}, | ||
| projectPlugins: map[string]string{"codex": "0.2.7"}, | ||
| want: []string{"codex_0.2.7"}, | ||
| }, | ||
| { | ||
| // Manifest (claude) and state (codex) contribute; output is sorted. | ||
| name: "mixed sources sorted", | ||
| claudeManifestVersion: "0.2.9", | ||
| globalPlugins: map[string]string{"codex": "0.2.8"}, | ||
| want: []string{"claude-code_0.2.9", "codex_0.2.8"}, | ||
| }, | ||
| } | ||
|
|
||
| for _, tc := range tests { | ||
| t.Run(tc.name, func(t *testing.T) { | ||
| tmp := t.TempDir() | ||
| t.Setenv("HOME", tmp) | ||
| t.Setenv("USERPROFILE", tmp) | ||
| // Fresh cwd so project-scope state does not leak from the package dir. | ||
| t.Chdir(t.TempDir()) | ||
|
|
||
| if tc.claudeManifestVersion != "" { | ||
| writeClaudeManifest(t, filepath.Join(tmp, ".claude"), tc.claudeManifestVersion) | ||
| } | ||
| savePluginState(t, GlobalSkillsDir, tc.globalPlugins) | ||
| savePluginState(t, ProjectSkillsDir, tc.projectPlugins) | ||
|
|
||
| assert.Equal(t, tc.want, InstalledTools(t.Context())) | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| // writeClaudeManifest writes a Claude installed_plugins.json recording the | ||
| // databricks plugin at the given version under configDir. | ||
| func writeClaudeManifest(t *testing.T, configDir, version string) { | ||
| t.Helper() | ||
| require.NoError(t, os.MkdirAll(filepath.Join(configDir, "plugins"), 0o755)) | ||
| body := `{"version":2,"plugins":{"databricks@claude-plugins-official":[{"version":"` + version + `"}]}}` | ||
| require.NoError(t, os.WriteFile(filepath.Join(configDir, "plugins", "installed_plugins.json"), []byte(body), 0o644)) | ||
| } | ||
|
|
||
| // savePluginState writes an install state recording a databricks plugin version | ||
| // for each agent into the directory returned by dirFn. It is a no-op when the | ||
| // map is empty, so a scope with no plugin installs leaves no state file. | ||
| func savePluginState(t *testing.T, dirFn func(context.Context) (string, error), versions map[string]string) { | ||
| t.Helper() | ||
| if len(versions) == 0 { | ||
| return | ||
| } | ||
| plugins := make(map[string]PluginRecord, len(versions)) | ||
| for name, v := range versions { | ||
| plugins[name] = PluginRecord{Plugin: "databricks", Version: v} | ||
| } | ||
| dir, err := dirFn(t.Context()) | ||
| require.NoError(t, err) | ||
| require.NoError(t, SaveState(dir, &InstallState{ | ||
| SchemaVersion: schemaVersionV2, | ||
| Plugins: plugins, | ||
| })) | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could we also test this function as this the single top level call point for new functionality? Maybe move it in
installeras a public function?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I am not sure which tests you want to add.