Skip to content

aitools: add funnel telemetry to update, list, version and uninstall - #7470

Open
anton-107 wants to merge 1 commit into
aitools/install-funnel-telemetryfrom
aitools/command-funnel-telemetry
Open

anton-107 wants to merge 1 commit into
aitools/install-funnel-telemetryfrom
aitools/command-funnel-telemetry

Conversation

@anton-107

@anton-107 anton-107 commented Nov 11, 2026 •

Copy link
Copy Markdown
Contributor

Stacked on #7469.

Stacked on: aitools: add install funnel telemetry

Summary

aitools update, list, version and uninstall now log the same aitools_command_event funnel as aitools install (STARTED, then FINISHED with the outcome and error category) and resolve auth best-effort in the background so the events can upload at exit. list and version never wait for that resolution at exit, and neither do update and uninstall runs that end before their work (flag and scope errors, a declined confirm, nothing installed in the scope, a --skills name that is not installed), the same rule #7469 applies to install before its plan is confirmed; their events upload only if auth has already resolved.

 NewUpdateCmd / NewListCmd / NewVersionCmd / NewUninstallCmd
-  return cmd
+  return instrumentCommand(cmd, protos.AitoolsCommand<X>)
+    cmd.RunE = withCommandFunnel(...)  // STARTED; FINISHED from RunE's error
+    withTelemetryAuth(cmd)             // background auth; no wait at exit unless
+                                       // RunE calls waitForTelemetryAuth (update and
+                                       // uninstall, once a scope has state)
finish(err)
  nil                                  -> SUCCESS
  errors.Is(err, ErrNothingInstalled)  -> NOTHING_TO_DO  (still exits 1 with the same text)
  prompt aborted / no agents picked    -> CANCELLED
  otherwise                            -> FAILURE, classifyInstallError(err)
                                          (scope flag errors tagged INVALID_FLAGS, as in install)

RunE returns nil, but
  uninstall confirm answered "no"                      -> CANCELLED
  uninstall removed nothing and nothing failed         -> NOTHING_TO_DO
  every plugin/skill update or removal attempted       -> FAILURE with the first error's category
    failed and nothing else succeeded                     (still exits 0)
  (each via funnelFrom(ctx).finishWith)

Installer additions: ErrNothingInstalled (same error text as before), PluginUpdate.Err (failed plugins are returned after the updated ones; update prints only the updated ones), and UpdateResult.CheckErr (the manifest fetch error a --check run only warns about), and UninstallSkillsWithResult returning UninstallResult{PluginsRemoved, SkillsRemoved, PluginFailures, SkillFailures}, which replaces UninstallSkills and UninstallSkillsOpts. A plugin record for an agent this CLI does not know counts as a failed update or removal (ErrUnknownAgent, UNKNOWN_AGENT), and a pre-state (legacy) install returns ErrLegacyInstall (same text, STATE_INVALID). uninstall --skills with a skill that is not installed returns a SkillError (same text). Full uninstall now removes skills in sorted order.

Changelog: .nextchanges/cli/aitools-telemetry-funnel.md.

Evidence

  • Before (return instrumentCommand(cmd, protos.AitoolsCommandList) in list.go reverted to return cmd):
    --- FAIL: TestInstrumentedCommandsResolveAuth   Expected value not to be nil  (list has no PreRunE)
    --- FAIL: TestListFunnelFailure  expected [STARTED FINISHED:FAILURE:NETWORK], actual []
    
    After (current head):
    ok  cmd/aitools  TestInstrumentedCommandsResolveAuth, TestListFunnelFailure,
                     TestUninstallFunnelWhenRemovalsFail, TestUpdateFunnelWhenPluginsFail,
                     TestNothingInstalledFinishesNothingToDo,
                     TestInstrumentedCommandsDoNotWaitForTelemetryAuth,
                     TestUpdateAndUninstallWorkWaitsForTelemetryAuth
    
  • No wait: with instrumentCommand forced to wait, TestInstrumentedCommandsDoNotWaitForTelemetryAuth fails for version, list, the update/uninstall flag errors and update/uninstall with nothing installed (10 s each, budget set to 10 s); with waitForTelemetryAuth a no-op, TestUpdateAndUninstallWorkWaitsForTelemetryAuth fails for both commands. With PreRunE cleared in instrumentCommand, TestInstrumentedCommandsResolveAuth fails for update, list, version and uninstall.
  • Built CLI, fake token, DATABRICKS_HOST=https://127.0.0.1:1 (refused): aitools version 18 ms, update --scope=bogus 17 ms, update and uninstall with nothing installed 17-18 ms, uninstall with one skill recorded 3.0 s.
  • Acceptance cmd/aitools/telemetry/update-uninstall pins the uploaded STARTED/FINISHED events of update and uninstall after an install. list, version, and update/uninstall with nothing installed upload only if auth has already resolved, which depends on timing, so their events are pinned by unit tests instead. workspace-unreachable checks that the background resolution prints nothing on install, update and uninstall runs that wait for it (with the handler's level clamp removed, it shows the hostmetadata warning).
  • At this head: go build ./..., ./task test-exp-aitools, cmd/aitools, libs/aitools and libs/telemetry unit tests, the cmd/aitools acceptance tests, ./task lint-q, ./task fmt-q, ./task checks, and go vet for windows and darwin: all pass.

Merge Danger

Door: two-way

Blast Radius: latency

  • update and uninstall runs on a scope with something installed now contact the configured workspace: up to about 6 s longer on a slow workspace (3 s auth wait + 3 s upload), at least 3 s per run while connections are refused or time out.
  • list, version, and update/uninstall runs that end at a flag or scope error, a declined confirm, with nothing installed or at a --skills name that is not installed never wait for auth. When the resolution has already finished (e.g. a PAT or a valid cached token, with host metadata cached), they still upload and can take up to 3 s longer (the upload timeout) on a slow workspace; otherwise their events are dropped.
  • Exit codes and output are unchanged; uninstall and update failures that were only warnings still exit 0 and are reported only in telemetry.
  • The background resolution never refreshes an OAuth token (aitools: bound best-effort telemetry auth #7468); with an expired cached token the events of these commands are dropped, as for install.
  • Not tested here: Windows and macOS (run on Linux only); a real workspace upload.

This pull request and its description were written by Isaac.

@anton-107 anton-107 added the ai-auto PRs made by AI loops & factories, label Nov 11, 2026
@anton-107
anton-107 requested review from a team as code owners November 11, 2026 14:24
@anton-107 anton-107 added the ai-auto PRs made by AI loops & factories, label Nov 11, 2026
@anton-107
anton-107 force-pushed the aitools/install-funnel-telemetry branch from 5637f13 to 21d6b41 Compare November 11, 2026 14:32
@anton-107
anton-107 force-pushed the aitools/command-funnel-telemetry branch from a5568f8 to 2b4b8c6 Compare November 11, 2026 14:32
@github-actions

github-actions Bot commented Nov 11, 2026 •

Copy link
Copy Markdown
Contributor

Commit: f3794235c | Workflow run

Regression test report

Selected 11 changed tests (limit=20, 0 not selected)

11 tests: regression=7 unreleased=0 coverage=4 failing=0
test branch main (8b4f052) latest (v1.26.0)
TestAccept/cmd/aitools/install-host-metadata ✅ ❌ ❌
TestAccept/cmd/aitools/list/explicit-ref ✅ ✅ ✅
TestAccept/cmd/aitools/list/latest ✅ ✅ ✅
TestAccept/cmd/aitools/list/latest-lookup-fails ✅ ✅ ✅
TestAccept/cmd/aitools/list/pinned ✅ ✅ ✅
TestAccept/cmd/aitools/telemetry/apps-init-tip ✅ ❌ ❌
TestAccept/cmd/aitools/telemetry/hint ✅ ❌ ❌
TestAccept/cmd/aitools/telemetry/install ✅ ❌ ❌
TestAccept/cmd/aitools/telemetry/install-failure ✅ ❌ ❌
TestAccept/cmd/aitools/telemetry/update-uninstall ✅ ❌ ❌
TestAccept/cmd/aitools/telemetry/workspace-unreachable ✅ ❌ ❌
TestAccept/cmd/aitools/install-host-metadata: branch ✅ | main (8b4f052) ❌ | latest (v1.26.0) ❌

main (8b4f052):

acceptance_test.go:1213: Substituted DATABRICKS_SKILLS_BASE_URL "$DATABRICKS_HOST" -> "http://127.0.0.1:33397" ("[DATABRICKS_HOST]")
    acceptance_test.go:1345: 
        	Error Trace:	/home/runner/work/cli/cli/libs/testdiff/testdiff.go:22
        	            				/home/runner/work/cli/cli/acceptance/acceptance_test.go:1345
        	            				/home/runner/work/cli/cli/acceptance/acceptance_test.go:1086
        	            				/home/runner/work/cli/cli/acceptance/acceptance_test.go:642
        	Error:      	Not equal: 
        	            	expected: "\n=== workspace unreachable: the background auth resolution prints nothing\n\n>>> [CLI] aitools install --skills-only --scope=global --agents=claude-code\nInstalling Databricks skills for Claude Code...\nUsing skills version test-ref\nFetching skills manifest...\nInstalled 1 skill.\n\n=== --debug still shows the hostmetadata line, at debug level\nDebug: [hostmetadata] failed to fetch host metadata for https://unreachable.test\n"
        	            	actual  : "\n=== workspace unreachable: the background auth resolution prints nothing\n\n>>> [CLI] aitools install --skills-only --scope=global --agents=claude-code\nWarn: [hostmetadata] failed to fetch host metadata for https://unreachable.test, will skip for 1m0s\nInstalling Databricks skills for Claude Code...\nUsing skills version test-ref\nFetching skills manifest...\nInstalled 1 skill.\n\n=== --debug still shows the hostmetadata line, at debug level\n\nExit code: 1\n"
        	            	
        	            	Diff:
        	            	--- Expected
        	            	+++ Actual
        	            	@@ -4,2 +4,3 @@
        	            	 >>> [CLI] aitools install --skills-only --scope=global --agents=claude-code
        	            	+Warn: [hostmetadata] failed to fetch host metadata for https://unreachable.test, will skip for 1m0s
        	            	 Installing Databricks skills for Claude Code...
        	            	@@ -10,3 +11,4 @@
        	            	 === --debug still shows the hostmetadata line, at debug level
        	            	-Debug: [hostmetadata] failed to fetch host metadata for https://unreachable.test
        	            	 
        	            	+Exit code: 1
        	            	+
        	Test:       	TestAccept/cmd/aitools/install-host-metadata
        	Messages:   	cmd/aitools/install-host-metadata/output.txt vs /tmp/TestAcceptcmdaitoolsinstall-host-metadata1323435399/002/output.txt

latest (v1.26.0):

acceptance_test.go:1213: Substituted DATABRICKS_SKILLS_BASE_URL "$DATABRICKS_HOST" -> "http://127.0.0.1:39743" ("[DATABRICKS_HOST]")
    acceptance_test.go:1345: 
        	Error Trace:	/home/runner/work/cli/cli/libs/testdiff/testdiff.go:22
        	            				/home/runner/work/cli/cli/acceptance/acceptance_test.go:1345
        	            				/home/runner/work/cli/cli/acceptance/acceptance_test.go:1086
        	            				/home/runner/work/cli/cli/acceptance/acceptance_test.go:642
        	Error:      	Not equal: 
        	            	expected: "\n=== workspace unreachable: the background auth resolution prints nothing\n\n>>> [CLI] aitools install --skills-only --scope=global --agents=claude-code\nInstalling Databricks skills for Claude Code...\nUsing skills version test-ref\nFetching skills manifest...\nInstalled 1 skill.\n\n=== --debug still shows the hostmetadata line, at debug level\nDebug: [hostmetadata] failed to fetch host metadata for https://unreachable.test\n"
        	            	actual  : "\n=== workspace unreachable: the background auth resolution prints nothing\n\n>>> [CLI] aitools install --skills-only --scope=global --agents=claude-code\nWarn: [hostmetadata] failed to fetch host metadata for https://unreachable.test, will skip for 1m0s\nInstalling Databricks skills for Claude Code...\nUsing skills version test-ref\nFetching skills manifest...\nInstalled 1 skill.\n\n=== --debug still shows the hostmetadata line, at debug level\n\nExit code: 1\n"
        	            	
        	            	Diff:
        	            	--- Expected
        	            	+++ Actual
        	            	@@ -4,2 +4,3 @@
        	            	 >>> [CLI] aitools install --skills-only --scope=global --agents=claude-code
        	            	+Warn: [hostmetadata] failed to fetch host metadata for https://unreachable.test, will skip for 1m0s
        	            	 Installing Databricks skills for Claude Code...
        	            	@@ -10,3 +11,4 @@
        	            	 === --debug still shows the hostmetadata line, at debug level
        	            	-Debug: [hostmetadata] failed to fetch host metadata for https://unreachable.test
        	            	 
        	            	+Exit code: 1
        	            	+
        	Test:       	TestAccept/cmd/aitools/install-host-metadata
        	Messages:   	cmd/aitools/install-host-metadata/output.txt vs /tmp/TestAcceptcmdaitoolsinstall-host-metadata1921120524/002/output.txt
TestAccept/cmd/aitools/telemetry/apps-init-tip: branch ✅ | main (8b4f052) ❌ | latest (v1.26.0) ❌

main (8b4f052):

acceptance_test.go:1213: Substituted DATABRICKS_CLI_GITHUB_API_URL "$DATABRICKS_HOST" -> "http://127.0.0.1:39637" ("[DATABRICKS_HOST]")
    acceptance_test.go:1213: Substituted DATABRICKS_SKILLS_BASE_URL "$DATABRICKS_HOST" -> "http://127.0.0.1:39637" ("[DATABRICKS_HOST]")
    acceptance_test.go:1345: 
        	Error Trace:	/home/runner/work/cli/cli/libs/testdiff/testdiff.go:22
        	            				/home/runner/work/cli/cli/acceptance/acceptance_test.go:1345
        	            				/home/runner/work/cli/cli/acceptance/acceptance_test.go:1086
        	            				/home/runner/work/cli/cli/acceptance/acceptance_test.go:642
        	Error:      	Not equal: 
        	            	expected: "\n=== apps init with flags prints the tip: one hint-shown event per hint printed\n\n>>> [CLI] apps init --template ./template --skip-install --name app-tip\nDatabricks skills are not installed. To work with Databricks reliably, first run: databricks aitools install\n✔ Creating project\n\n✔ Project created successfully!\n\n  Location: [TEST_TMP_DIR]/app-tip\n  Files: 3\n\nTip: coding agents detected without Databricks skills. Run 'databricks aitools install' to install them.\n\n>>> print_telemetry\n{\"command\":\"apps_init\",\"exit_code\":0,\"aitools_hint_event\":{\"hint_kind\":\"INSTALL_TEXT\"}}\n{\"command\":\"apps_init\",\"exit_code\":0,\"aitools_hint_event\":{\"hint_kind\":\"APPS_INIT_TIP\"}}\n\n=== plugin installed: no hint, no tip, no event\n\n>>> [CLI] apps init --template ./template --skip-install --name app-plugin\n✔ Creating project\n\n✔ Project created successfully!\n\n  Location: [TEST_TMP_DIR]/app-plugin\n  Files: 3\n\n\n>>> print_telemetry\n"
        	            	actual  : "\n=== apps init with flags prints the tip: one hint-shown event per hint printed\n\n>>> [CLI] apps init --template ./template --skip-install --name app-tip\nDatabricks skills are not installed. To work with Databricks reliably, first run: databricks aitools install\n✔ Creating project\n\n✔ Project created successfully!\n\n  Location: [TEST_TMP_DIR]/app-tip\n  Files: 3\n\nTip: coding agents detected without Databricks skills. Run 'databricks aitools install' to install them.\n\n>>> print_telemetry\n\n=== plugin installed: no hint, no tip, no event\n\n>>> [CLI] apps init --template ./template --skip-install --name app-plugin\n✔ Creating project\n\n✔ Project created successfully!\n\n  Location: [TEST_TMP_DIR]/app-plugin\n  Files: 3\n\nTip: coding agents detected without Databricks skills. Run 'databricks aitools install' to install them.\n\n>>> print_telemetry\n"
        	            	
        	            	Diff:
        	            	--- Expected
        	            	+++ Actual
        	            	@@ -15,4 +15,2 @@
        	            	 >>> print_telemetry
        	            	-{"command":"apps_init","exit_code":0,"aitools_hint_event":{"hint_kind":"INSTALL_TEXT"}}
        	            	-{"command":"apps_init","exit_code":0,"aitools_hint_event":{"hint_kind":"APPS_INIT_TIP"}}
        	            	 
        	            	@@ -28,2 +26,3 @@
        	            	 
        	            	+Tip: coding agents detected without Databricks skills. Run 'databricks aitools install' to install them.
        	            	 
        	Test:       	TestAccept/cmd/aitools/telemetry/apps-init-tip
        	Messages:   	cmd/aitools/telemetry/apps-init-tip/output.txt vs /tmp/TestAcceptcmdaitoolstelemetryapps-init-tip135993295/002/output.txt
    acceptance_test.go:1123: LOG.apps-init: 
    acceptance_test.go:1123: LOG.apps-init-plugin:

latest (v1.26.0):

acceptance_test.go:1213: Substituted DATABRICKS_CLI_GITHUB_API_URL "$DATABRICKS_HOST" -> "http://127.0.0.1:33567" ("[DATABRICKS_HOST]")
    acceptance_test.go:1213: Substituted DATABRICKS_SKILLS_BASE_URL "$DATABRICKS_HOST" -> "http://127.0.0.1:33567" ("[DATABRICKS_HOST]")
    acceptance_test.go:1345: 
        	Error Trace:	/home/runner/work/cli/cli/libs/testdiff/testdiff.go:22
        	            				/home/runner/work/cli/cli/acceptance/acceptance_test.go:1345
        	            				/home/runner/work/cli/cli/acceptance/acceptance_test.go:1086
        	            				/home/runner/work/cli/cli/acceptance/acceptance_test.go:642
        	Error:      	Not equal: 
        	            	expected: "\n=== apps init with flags prints the tip: one hint-shown event per hint printed\n\n>>> [CLI] apps init --template ./template --skip-install --name app-tip\nDatabricks skills are not installed. To work with Databricks reliably, first run: databricks aitools install\n✔ Creating project\n\n✔ Project created successfully!\n\n  Location: [TEST_TMP_DIR]/app-tip\n  Files: 3\n\nTip: coding agents detected without Databricks skills. Run 'databricks aitools install' to install them.\n\n>>> print_telemetry\n{\"command\":\"apps_init\",\"exit_code\":0,\"aitools_hint_event\":{\"hint_kind\":\"INSTALL_TEXT\"}}\n{\"command\":\"apps_init\",\"exit_code\":0,\"aitools_hint_event\":{\"hint_kind\":\"APPS_INIT_TIP\"}}\n\n=== plugin installed: no hint, no tip, no event\n\n>>> [CLI] apps init --template ./template --skip-install --name app-plugin\n✔ Creating project\n\n✔ Project created successfully!\n\n  Location: [TEST_TMP_DIR]/app-plugin\n  Files: 3\n\n\n>>> print_telemetry\n"
        	            	actual  : "\n=== apps init with flags prints the tip: one hint-shown event per hint printed\n\n>>> [CLI] apps init --template ./template --skip-install --name app-tip\nDatabricks skills are not installed. To work with Databricks reliably, first run: databricks aitools install\n✔ Creating project\n\n✔ Project created successfully!\n\n  Location: [TEST_TMP_DIR]/app-tip\n  Files: 3\n\nTip: coding agents detected without Databricks skills. Run 'databricks aitools install' to install them.\n\n>>> print_telemetry\n\n=== plugin installed: no hint, no tip, no event\n\n>>> [CLI] apps init --template ./template --skip-install --name app-plugin\n✔ Creating project\n\n✔ Project created successfully!\n\n  Location: [TEST_TMP_DIR]/app-plugin\n  Files: 3\n\nTip: coding agents detected without Databricks skills. Run 'databricks aitools install' to install them.\n\n>>> print_telemetry\n"
        	            	
        	            	Diff:
        	            	--- Expected
        	            	+++ Actual
        	            	@@ -15,4 +15,2 @@
        	            	 >>> print_telemetry
        	            	-{"command":"apps_init","exit_code":0,"aitools_hint_event":{"hint_kind":"INSTALL_TEXT"}}
        	            	-{"command":"apps_init","exit_code":0,"aitools_hint_event":{"hint_kind":"APPS_INIT_TIP"}}
        	            	 
        	            	@@ -28,2 +26,3 @@
        	            	 
        	            	+Tip: coding agents detected without Databricks skills. Run 'databricks aitools install' to install them.
        	            	 
        	Test:       	TestAccept/cmd/aitools/telemetry/apps-init-tip
        	Messages:   	cmd/aitools/telemetry/apps-init-tip/output.txt vs /tmp/TestAcceptcmdaitoolstelemetryapps-init-tip615681053/002/output.txt
    acceptance_test.go:1123: LOG.apps-init: 
    acceptance_test.go:1123: LOG.apps-init-plugin:
TestAccept/cmd/aitools/telemetry/hint: branch ✅ | main (8b4f052) ❌ | latest (v1.26.0) ❌

main (8b4f052):

acceptance_test.go:1213: Substituted DATABRICKS_CLI_GITHUB_API_URL "$DATABRICKS_HOST" -> "http://127.0.0.1:35699" ("[DATABRICKS_HOST]")
    acceptance_test.go:1213: Substituted DATABRICKS_SKILLS_BASE_URL "$DATABRICKS_HOST" -> "http://127.0.0.1:35699" ("[DATABRICKS_HOST]")
    acceptance_test.go:1345: 
        	Error Trace:	/home/runner/work/cli/cli/libs/testdiff/testdiff.go:22
        	            				/home/runner/work/cli/cli/acceptance/acceptance_test.go:1345
        	            				/home/runner/work/cli/cli/acceptance/acceptance_test.go:1086
        	            				/home/runner/work/cli/cli/acceptance/acceptance_test.go:642
        	Error:      	Not equal: 
        	            	expected: "\n=== hint printed on an authenticated command: one hint-shown event is uploaded\n\n>>> [CLI] jobs list\nDatabricks skills are not installed. To work with Databricks reliably, first run: databricks aitools install\n\n>>> print_telemetry\n{\"command\":\"jobs_list\",\"exit_code\":0,\"aitools_hint_event\":{\"hint_kind\":\"INSTALL_TEXT\"}}\n\n=== hint printed on a command that resolves no auth: nothing is uploaded\n\n>>> [CLI] version\nDatabricks skills are not installed. To work with Databricks reliably, first run: databricks aitools install\nDatabricks CLI v[CLI_VERSION]\n\n>>> print_telemetry\n\n=== plugin installed: no hint, no event\n\n>>> [CLI] jobs list\n\n>>> print_telemetry\n"
        	            	actual  : "\n=== hint printed on an authenticated command: one hint-shown event is uploaded\n\n>>> [CLI] jobs list\nDatabricks skills are not installed. To work with Databricks reliably, first run: databricks aitools install\n\n>>> print_telemetry\n\n=== hint printed on a command that resolves no auth: nothing is uploaded\n\n>>> [CLI] version\nDatabricks skills are not installed. To work with Databricks reliably, first run: databricks aitools install\nDatabricks CLI v[CLI_VERSION]\n\n>>> print_telemetry\n\n=== plugin installed: no hint, no event\n\n>>> [CLI] jobs list\n\n>>> print_telemetry\n"
        	            	
        	            	Diff:
        	            	--- Expected
        	            	+++ Actual
        	            	@@ -7,3 +7,2 @@
        	            	 >>> print_telemetry
        	            	-{"command":"jobs_list","exit_code":0,"aitools_hint_event":{"hint_kind":"INSTALL_TEXT"}}
        	            	 
        	Test:       	TestAccept/cmd/aitools/telemetry/hint
        	Messages:   	cmd/aitools/telemetry/hint/output.txt vs /tmp/TestAcceptcmdaitoolstelemetryhint3614887498/002/output.txt

latest (v1.26.0):

acceptance_test.go:1213: Substituted DATABRICKS_CLI_GITHUB_API_URL "$DATABRICKS_HOST" -> "http://127.0.0.1:36791" ("[DATABRICKS_HOST]")
    acceptance_test.go:1213: Substituted DATABRICKS_SKILLS_BASE_URL "$DATABRICKS_HOST" -> "http://127.0.0.1:36791" ("[DATABRICKS_HOST]")
    acceptance_test.go:1345: 
        	Error Trace:	/home/runner/work/cli/cli/libs/testdiff/testdiff.go:22
        	            				/home/runner/work/cli/cli/acceptance/acceptance_test.go:1345
        	            				/home/runner/work/cli/cli/acceptance/acceptance_test.go:1086
        	            				/home/runner/work/cli/cli/acceptance/acceptance_test.go:642
        	Error:      	Not equal: 
        	            	expected: "\n=== hint printed on an authenticated command: one hint-shown event is uploaded\n\n>>> [CLI] jobs list\nDatabricks skills are not installed. To work with Databricks reliably, first run: databricks aitools install\n\n>>> print_telemetry\n{\"command\":\"jobs_list\",\"exit_code\":0,\"aitools_hint_event\":{\"hint_kind\":\"INSTALL_TEXT\"}}\n\n=== hint printed on a command that resolves no auth: nothing is uploaded\n\n>>> [CLI] version\nDatabricks skills are not installed. To work with Databricks reliably, first run: databricks aitools install\nDatabricks CLI v[CLI_VERSION]\n\n>>> print_telemetry\n\n=== plugin installed: no hint, no event\n\n>>> [CLI] jobs list\n\n>>> print_telemetry\n"
        	            	actual  : "\n=== hint printed on an authenticated command: one hint-shown event is uploaded\n\n>>> [CLI] jobs list\nDatabricks skills are not installed. To work with Databricks reliably, first run: databricks aitools install\n\n>>> print_telemetry\n\n=== hint printed on a command that resolves no auth: nothing is uploaded\n\n>>> [CLI] version\nDatabricks skills are not installed. To work with Databricks reliably, first run: databricks aitools install\nDatabricks CLI v[CLI_VERSION]\n\n>>> print_telemetry\n\n=== plugin installed: no hint, no event\n\n>>> [CLI] jobs list\n\n>>> print_telemetry\n"
        	            	
        	            	Diff:
        	            	--- Expected
        	            	+++ Actual
        	            	@@ -7,3 +7,2 @@
        	            	 >>> print_telemetry
        	            	-{"command":"jobs_list","exit_code":0,"aitools_hint_event":{"hint_kind":"INSTALL_TEXT"}}
        	            	 
        	Test:       	TestAccept/cmd/aitools/telemetry/hint
        	Messages:   	cmd/aitools/telemetry/hint/output.txt vs /tmp/TestAcceptcmdaitoolstelemetryhint1122751315/002/output.txt
TestAccept/cmd/aitools/telemetry/install: branch ✅ | main (8b4f052) ❌ | latest (v1.26.0) ❌

main (8b4f052):

acceptance_test.go:1213: Substituted DATABRICKS_CLI_GITHUB_API_URL "$DATABRICKS_HOST" -> "http://127.0.0.1:34209" ("[DATABRICKS_HOST]")
    acceptance_test.go:1213: Substituted DATABRICKS_SKILLS_BASE_URL "$DATABRICKS_HOST" -> "http://127.0.0.1:34209" ("[DATABRICKS_HOST]")
    acceptance_test.go:1345: Diff:
        --- cmd/aitools/telemetry/install/output.txt
        +++ /tmp/TestAcceptcmdaitoolstelemetryinstall4244416249/002/output.txt
        @@ -8,14 +8,7 @@
         Installed 1 skill.
         
         >>> print_telemetry
        -{"command":"aitools_install","exit_code":0,"aitools_command_event":{"command":"INSTALL","stage":"STARTED","interactive":false,"install_mode":"SKILLS_ONLY"}}
        -{"command":"aitools_install","exit_code":0,"aitools_command_event":{"command":"INSTALL","stage":"STEP_COMPLETED","step":"FLAGS_VALIDATED","interactive":false,"install_mode":"SKILLS_ONLY"}}
        -{"command":"aitools_install","exit_code":0,"aitools_command_event":{"command":"INSTALL","stage":"STEP_COMPLETED","step":"SCOPE_RESOLVED","interactive":false,"install_mode":"SKILLS_ONLY","scope":"GLOBAL"}}
        -{"command":"aitools_install","exit_code":0,"aitools_command_event":{"command":"INSTALL","stage":"STEP_COMPLETED","step":"AGENTS_SELECTED","interactive":false,"install_mode":"SKILLS_ONLY","scope":"GLOBAL"}}
        -{"command":"aitools_install","exit_code":0,"aitools_command_event":{"command":"INSTALL","stage":"STEP_COMPLETED","step":"PLAN_CONFIRMED","interactive":false,"install_mode":"SKILLS_ONLY","scope":"GLOBAL"}}
        -{"command":"aitools_install","exit_code":0,"aitools_command_event":{"command":"INSTALL","stage":"STEP_COMPLETED","step":"SKILLS_INSTALLED","interactive":false,"install_mode":"SKILLS_ONLY","scope":"GLOBAL"}}
         {"command":"aitools_install","exit_code":0,"aitools_install_event":{"agents":["CLAUDE_CODE"],"scope":"GLOBAL","error_category":"TYPE_UNSPECIFIED"}}
        -{"command":"aitools_install","exit_code":0,"aitools_command_event":{"command":"INSTALL","stage":"FINISHED","outcome":"SUCCESS","interactive":false,"install_mode":"SKILLS_ONLY","agents":["CLAUDE_CODE"],"scope":"GLOBAL"}}
         
         === databricks-cli auth with a valid cached token: the event is uploaded
         
        @@ -26,14 +18,7 @@
         Installed 1 skill.
         
         >>> print_telemetry
        -{"command":"aitools_install","exit_code":0,"aitools_command_event":{"command":"INSTALL","stage":"STARTED","interactive":false,"install_mode":"SKILLS_ONLY"}}
        -{"command":"aitools_install","exit_code":0,"aitools_command_event":{"command":"INSTALL","stage":"STEP_COMPLETED","step":"FLAGS_VALIDATED","interactive":false,"install_mode":"SKILLS_ONLY"}}
        -{"command":"aitools_install","exit_code":0,"aitools_command_event":{"command":"INSTALL","stage":"STEP_COMPLETED","step":"SCOPE_RESOLVED","interactive":false,"install_mode":"SKILLS_ONLY","scope":"GLOBAL"}}
        -{"command":"aitools_install","exit_code":0,"aitools_command_event":{"command":"INSTALL","stage":"STEP_COMPLETED","step":"AGENTS_SELECTED","interactive":false,"install_mode":"SKILLS_ONLY","scope":"GLOBAL"}}
        -{"command":"aitools_install","exit_code":0,"aitools_command_event":{"command":"INSTALL","stage":"STEP_COMPLETED","step":"PLAN_CONFIRMED","interactive":false,"install_mode":"SKILLS_ONLY","scope":"GLOBAL"}}
        -{"command":"aitools_install","exit_code":0,"aitools_command_event":{"command":"INSTALL","stage":"STEP_COMPLETED","step":"SKILLS_INSTALLED","interactive":false,"install_mode":"SKILLS_ONLY","scope":"GLOBAL"}}
         {"command":"aitools_install","exit_code":0,"aitools_install_event":{"agents":["CLAUDE_CODE"],"scope":"GLOBAL","error_category":"TYPE_UNSPECIFIED"}}
        -{"command":"aitools_install","exit_code":0,"aitools_command_event":{"command":"INSTALL","stage":"FINISHED","outcome":"SUCCESS","interactive":false,"install_mode":"SKILLS_ONLY","agents":["CLAUDE_CODE"],"scope":"GLOBAL"}}
         
         === databricks-cli auth with an expired cached token: no refresh, nothing is uploaded
         
        @@ -44,6 +28,22 @@
         Installed 1 skill.
         
         >>> print_requests.py //oidc/v1/token //telemetry-ext
        +{
        +  "method": "POST",
        +  "path": "/oidc/v1/token",
        +  "raw_body": "client_id=databricks-cli&grant_type=refresh_token&refresh_token=test-refresh-token"
        +}
        +{
        +  "method": "POST",
        +  "path": "/telemetry-ext",
        +  "body": {
        +    "uploadTime": [UNIX_TIME_MILLIS],
        +    "items": [],
        +    "protoLogs": [
        +      "{\"frontend_log_event_id\":\"[UUID][0]\",\"entry\":{\"databricks_cli_log\":{\"execution_context\":{\"cmd_exec_id\":\"[UUID][1]\",\"version\":\"[CLI_VERSION]\",\"command\":\"aitools_install\",\"operating_system\":\"linux\",\"execution_time_ms\":75,\"exit_code\":0},\"aitools_install_event\":{\"agents\":[\"CLAUDE_CODE\"],\"scope\":\"GLOBAL\",\"error_category\":\"TYPE_UNSPECIFIED\"}}}}"
        +    ]
        +  }
        +}
         
         >>> gron.py [TEST_TMP_DIR]/home/.databricks/token-cache.json
        -json.tokens.test-profile.access_token = "expired-access-token";
        +json.tokens.test-profile.access_token = "oauth-token";
        
FAIL

latest (v1.26.0):

acceptance_test.go:1213: Substituted DATABRICKS_CLI_GITHUB_API_URL "$DATABRICKS_HOST" -> "http://127.0.0.1:39551" ("[DATABRICKS_HOST]")
    acceptance_test.go:1213: Substituted DATABRICKS_SKILLS_BASE_URL "$DATABRICKS_HOST" -> "http://127.0.0.1:39551" ("[DATABRICKS_HOST]")
    acceptance_test.go:1345: Diff:
        --- cmd/aitools/telemetry/install/output.txt
        +++ /tmp/TestAcceptcmdaitoolstelemetryinstall505401204/002/output.txt
        @@ -8,14 +8,7 @@
         Installed 1 skill.
         
         >>> print_telemetry
        -{"command":"aitools_install","exit_code":0,"aitools_command_event":{"command":"INSTALL","stage":"STARTED","interactive":false,"install_mode":"SKILLS_ONLY"}}
        -{"command":"aitools_install","exit_code":0,"aitools_command_event":{"command":"INSTALL","stage":"STEP_COMPLETED","step":"FLAGS_VALIDATED","interactive":false,"install_mode":"SKILLS_ONLY"}}
        -{"command":"aitools_install","exit_code":0,"aitools_command_event":{"command":"INSTALL","stage":"STEP_COMPLETED","step":"SCOPE_RESOLVED","interactive":false,"install_mode":"SKILLS_ONLY","scope":"GLOBAL"}}
        -{"command":"aitools_install","exit_code":0,"aitools_command_event":{"command":"INSTALL","stage":"STEP_COMPLETED","step":"AGENTS_SELECTED","interactive":false,"install_mode":"SKILLS_ONLY","scope":"GLOBAL"}}
        -{"command":"aitools_install","exit_code":0,"aitools_command_event":{"command":"INSTALL","stage":"STEP_COMPLETED","step":"PLAN_CONFIRMED","interactive":false,"install_mode":"SKILLS_ONLY","scope":"GLOBAL"}}
        -{"command":"aitools_install","exit_code":0,"aitools_command_event":{"command":"INSTALL","stage":"STEP_COMPLETED","step":"SKILLS_INSTALLED","interactive":false,"install_mode":"SKILLS_ONLY","scope":"GLOBAL"}}
         {"command":"aitools_install","exit_code":0,"aitools_install_event":{"agents":["CLAUDE_CODE"],"scope":"GLOBAL","error_category":"TYPE_UNSPECIFIED"}}
        -{"command":"aitools_install","exit_code":0,"aitools_command_event":{"command":"INSTALL","stage":"FINISHED","outcome":"SUCCESS","interactive":false,"install_mode":"SKILLS_ONLY","agents":["CLAUDE_CODE"],"scope":"GLOBAL"}}
         
         === databricks-cli auth with a valid cached token: the event is uploaded
         
        @@ -26,14 +18,7 @@
         Installed 1 skill.
         
         >>> print_telemetry
        -{"command":"aitools_install","exit_code":0,"aitools_command_event":{"command":"INSTALL","stage":"STARTED","interactive":false,"install_mode":"SKILLS_ONLY"}}
        -{"command":"aitools_install","exit_code":0,"aitools_command_event":{"command":"INSTALL","stage":"STEP_COMPLETED","step":"FLAGS_VALIDATED","interactive":false,"install_mode":"SKILLS_ONLY"}}
        -{"command":"aitools_install","exit_code":0,"aitools_command_event":{"command":"INSTALL","stage":"STEP_COMPLETED","step":"SCOPE_RESOLVED","interactive":false,"install_mode":"SKILLS_ONLY","scope":"GLOBAL"}}
        -{"command":"aitools_install","exit_code":0,"aitools_command_event":{"command":"INSTALL","stage":"STEP_COMPLETED","step":"AGENTS_SELECTED","interactive":false,"install_mode":"SKILLS_ONLY","scope":"GLOBAL"}}
        -{"command":"aitools_install","exit_code":0,"aitools_command_event":{"command":"INSTALL","stage":"STEP_COMPLETED","step":"PLAN_CONFIRMED","interactive":false,"install_mode":"SKILLS_ONLY","scope":"GLOBAL"}}
        -{"command":"aitools_install","exit_code":0,"aitools_command_event":{"command":"INSTALL","stage":"STEP_COMPLETED","step":"SKILLS_INSTALLED","interactive":false,"install_mode":"SKILLS_ONLY","scope":"GLOBAL"}}
         {"command":"aitools_install","exit_code":0,"aitools_install_event":{"agents":["CLAUDE_CODE"],"scope":"GLOBAL","error_category":"TYPE_UNSPECIFIED"}}
        -{"command":"aitools_install","exit_code":0,"aitools_command_event":{"command":"INSTALL","stage":"FINISHED","outcome":"SUCCESS","interactive":false,"install_mode":"SKILLS_ONLY","agents":["CLAUDE_CODE"],"scope":"GLOBAL"}}
         
         === databricks-cli auth with an expired cached token: no refresh, nothing is uploaded
         
        @@ -44,6 +28,22 @@
         Installed 1 skill.
         
         >>> print_requests.py //oidc/v1/token //telemetry-ext
        +{
        +  "method": "POST",
        +  "path": "/oidc/v1/token",
        +  "raw_body": "client_id=databricks-cli&grant_type=refresh_token&refresh_token=test-refresh-token"
        +}
        +{
        +  "method": "POST",
        +  "path": "/telemetry-ext",
        +  "body": {
        +    "uploadTime": [UNIX_TIME_MILLIS],
        +    "items": [],
        +    "protoLogs": [
        +      "{\"frontend_log_event_id\":\"[UUID][0]\",\"entry\":{\"databricks_cli_log\":{\"execution_context\":{\"cmd_exec_id\":\"[UUID][1]\",\"version\":\"[CLI_VERSION]\",\"command\":\"aitools_install\",\"operating_system\":\"linux\",\"execution_time_ms\":9,\"exit_code\":0},\"aitools_install_event\":{\"agents\":[\"CLAUDE_CODE\"],\"scope\":\"GLOBAL\",\"error_category\":\"TYPE_UNSPECIFIED\"}}}}"
        +    ]
        +  }
        +}
         
         >>> gron.py [TEST_TMP_DIR]/home/.databricks/token-cache.json
        -json.tokens.test-profile.access_token = "expired-access-token";
        +json.tokens.test-profile.access_token = "oauth-token";
        
FAIL
TestAccept/cmd/aitools/telemetry/install-failure: branch ✅ | main (8b4f052) ❌ | latest (v1.26.0) ❌

main (8b4f052):

acceptance_test.go:1213: Substituted DATABRICKS_CLI_GITHUB_API_URL "$DATABRICKS_HOST" -> "http://127.0.0.1:34283" ("[DATABRICKS_HOST]")
    acceptance_test.go:1213: Substituted DATABRICKS_SKILLS_BASE_URL "$DATABRICKS_HOST" -> "http://127.0.0.1:34283" ("[DATABRICKS_HOST]")
    acceptance_test.go:1345: Diff:
        --- cmd/aitools/telemetry/install-failure/output.txt
        +++ /tmp/TestAcceptcmdaitoolstelemetryinstall-failure2146436390/002/output.txt
        @@ -8,12 +8,6 @@
         Error: skill "nonexistent" not found
         
         >>> print_telemetry
        -{"command":"aitools_install","exit_code":1,"aitools_command_event":{"command":"INSTALL","stage":"STARTED","interactive":false,"install_mode":"SKILLS_ONLY"}}
        -{"command":"aitools_install","exit_code":1,"aitools_command_event":{"command":"INSTALL","stage":"STEP_COMPLETED","step":"FLAGS_VALIDATED","interactive":false,"install_mode":"SKILLS_ONLY"}}
        -{"command":"aitools_install","exit_code":1,"aitools_command_event":{"command":"INSTALL","stage":"STEP_COMPLETED","step":"SCOPE_RESOLVED","interactive":false,"install_mode":"SKILLS_ONLY","scope":"GLOBAL"}}
        -{"command":"aitools_install","exit_code":1,"aitools_command_event":{"command":"INSTALL","stage":"STEP_COMPLETED","step":"AGENTS_SELECTED","interactive":false,"install_mode":"SKILLS_ONLY","scope":"GLOBAL"}}
        -{"command":"aitools_install","exit_code":1,"aitools_command_event":{"command":"INSTALL","stage":"STEP_COMPLETED","step":"PLAN_CONFIRMED","interactive":false,"install_mode":"SKILLS_ONLY","scope":"GLOBAL"}}
        -{"command":"aitools_install","exit_code":1,"aitools_command_event":{"command":"INSTALL","stage":"FINISHED","outcome":"FAILURE","error_category":"SKILL_NOT_FOUND","interactive":false,"install_mode":"SKILLS_ONLY","agents":["CURSOR"],"scope":"GLOBAL"}}
         {"command":"aitools_install","exit_code":1,"aitools_install_event":{"agents":["CURSOR"],"scope":"GLOBAL","error_category":"SKILL_NOT_FOUND"}}
         
         === a manifest the server does not have is MANIFEST_NOT_FOUND
        @@ -25,12 +19,6 @@
         Error: skills manifest at databricks-agent-skills@missing-ref: not found
         
         >>> print_telemetry
        -{"command":"aitools_install","exit_code":1,"aitools_command_event":{"command":"INSTALL","stage":"STARTED","interactive":false,"install_mode":"SKILLS_ONLY"}}
        -{"command":"aitools_install","exit_code":1,"aitools_command_event":{"command":"INSTALL","stage":"STEP_COMPLETED","step":"FLAGS_VALIDATED","interactive":false,"install_mode":"SKILLS_ONLY"}}
        -{"command":"aitools_install","exit_code":1,"aitools_command_event":{"command":"INSTALL","stage":"STEP_COMPLETED","step":"SCOPE_RESOLVED","interactive":false,"install_mode":"SKILLS_ONLY","scope":"GLOBAL"}}
        -{"command":"aitools_install","exit_code":1,"aitools_command_event":{"command":"INSTALL","stage":"STEP_COMPLETED","step":"AGENTS_SELECTED","interactive":false,"install_mode":"SKILLS_ONLY","scope":"GLOBAL"}}
        -{"command":"aitools_install","exit_code":1,"aitools_command_event":{"command":"INSTALL","stage":"STEP_COMPLETED","step":"PLAN_CONFIRMED","interactive":false,"install_mode":"SKILLS_ONLY","scope":"GLOBAL"}}
        -{"command":"aitools_install","exit_code":1,"aitools_command_event":{"command":"INSTALL","stage":"FINISHED","outcome":"FAILURE","error_category":"MANIFEST_NOT_FOUND","interactive":false,"install_mode":"SKILLS_ONLY","agents":["CURSOR"],"scope":"GLOBAL"}}
         {"command":"aitools_install","exit_code":1,"aitools_install_event":{"agents":["CURSOR"],"scope":"GLOBAL","error_category":"MANIFEST_NOT_FOUND"}}
         
         === any other HTTP error status from the manifest fetch is not classified yet (the error is untyped)
        @@ -42,12 +30,6 @@
         Error: failed to fetch manifest: HTTP 500
         
         >>> print_telemetry
        -{"command":"aitools_install","exit_code":1,"aitools_command_event":{"command":"INSTALL","stage":"STARTED","interactive":false,"install_mode":"SKILLS_ONLY"}}
        -{"command":"aitools_install","exit_code":1,"aitools_command_event":{"command":"INSTALL","stage":"STEP_COMPLETED","step":"FLAGS_VALIDATED","interactive":false,"install_mode":"SKILLS_ONLY"}}
        -{"command":"aitools_install","exit_code":1,"aitools_command_event":{"command":"INSTALL","stage":"STEP_COMPLETED","step":"SCOPE_RESOLVED","interactive":false,"install_mode":"SKILLS_ONLY","scope":"GLOBAL"}}
        -{"command":"aitools_install","exit_code":1,"aitools_command_event":{"command":"INSTALL","stage":"STEP_COMPLETED","step":"AGENTS_SELECTED","interactive":false,"install_mode":"SKILLS_ONLY","scope":"GLOBAL"}}
        -{"command":"aitools_install","exit_code":1,"aitools_command_event":{"command":"INSTALL","stage":"STEP_COMPLETED","step":"PLAN_CONFIRMED","interactive":false,"install_mode":"SKILLS_ONLY","scope":"GLOBAL"}}
        -{"command":"aitools_install","exit_code":1,"aitools_command_event":{"command":"INSTALL","stage":"FINISHED","outcome":"FAILURE","error_category":"UNCATEGORIZED","interactive":false,"install_mode":"SKILLS_ONLY","agents":["CURSOR"],"scope":"GLOBAL"}}
         {"command":"aitools_install","exit_code":1,"aitools_install_event":{"agents":["CURSOR"],"scope":"GLOBAL","error_category":"UNCATEGORIZED"}}
         
         === a named agent skipped for the scope takes its category from the agent's outcome
        @@ -57,10 +39,4 @@
         Error: Cursor: does not support project-scoped skills
         
         >>> print_telemetry
        -{"command":"aitools_install","exit_code":1,"aitools_command_event":{"command":"INSTALL","stage":"STARTED","interactive":false,"install_mode":"SKILLS_ONLY"}}
        -{"command":"aitools_install","exit_code":1,"aitools_command_event":{"command":"INSTALL","stage":"STEP_COMPLETED","step":"FLAGS_VALIDATED","interactive":false,"install_mode":"SKILLS_ONLY"}}
        -{"command":"aitools_install","exit_code":1,"aitools_command_event":{"command":"INSTALL","stage":"STEP_COMPLETED","step":"SCOPE_RESOLVED","interactive":false,"install_mode":"SKILLS_ONLY","scope":"PROJECT"}}
        -{"command":"aitools_install","exit_code":1,"aitools_command_event":{"command":"INSTALL","stage":"STEP_COMPLETED","step":"AGENTS_SELECTED","interactive":false,"install_mode":"SKILLS_ONLY","scope":"PROJECT"}}
        -{"command":"aitools_install","exit_code":1,"aitools_command_event":{"command":"INSTALL","stage":"STEP_COMPLETED","step":"PLAN_CONFIRMED","interactive":false,"install_mode":"SKILLS_ONLY","scope":"PROJECT"}}
        -{"command":"aitools_install","exit_code":1,"aitools_command_event":{"command":"INSTALL","stage":"FINISHED","outcome":"FAILURE","error_category":"UNSUPPORTED_SCOPE","interactive":false,"install_mode":"SKILLS_ONLY","agents":["CURSOR"],"scope":"PROJECT"}}
         {"command":"aitools_install","exit_code":1,"aitools_install_event":{"agents":["CURSOR"],"scope":"PROJECT","error_category":"TYPE_UNSPECIFIED","agent_results":[{"agent":"CURSOR","error_category":"UNSUPPORTED_SCOPE"}]}}

latest (v1.26.0):

acceptance_test.go:1213: Substituted DATABRICKS_CLI_GITHUB_API_URL "$DATABRICKS_HOST" -> "http://127.0.0.1:33767" ("[DATABRICKS_HOST]")
    acceptance_test.go:1213: Substituted DATABRICKS_SKILLS_BASE_URL "$DATABRICKS_HOST" -> "http://127.0.0.1:33767" ("[DATABRICKS_HOST]")
    acceptance_test.go:1345: Diff:
        --- cmd/aitools/telemetry/install-failure/output.txt
        +++ /tmp/TestAcceptcmdaitoolstelemetryinstall-failure1145902776/002/output.txt
        @@ -8,12 +8,6 @@
         Error: skill "nonexistent" not found
         
         >>> print_telemetry
        -{"command":"aitools_install","exit_code":1,"aitools_command_event":{"command":"INSTALL","stage":"STARTED","interactive":false,"install_mode":"SKILLS_ONLY"}}
        -{"command":"aitools_install","exit_code":1,"aitools_command_event":{"command":"INSTALL","stage":"STEP_COMPLETED","step":"FLAGS_VALIDATED","interactive":false,"install_mode":"SKILLS_ONLY"}}
        -{"command":"aitools_install","exit_code":1,"aitools_command_event":{"command":"INSTALL","stage":"STEP_COMPLETED","step":"SCOPE_RESOLVED","interactive":false,"install_mode":"SKILLS_ONLY","scope":"GLOBAL"}}
        -{"command":"aitools_install","exit_code":1,"aitools_command_event":{"command":"INSTALL","stage":"STEP_COMPLETED","step":"AGENTS_SELECTED","interactive":false,"install_mode":"SKILLS_ONLY","scope":"GLOBAL"}}
        -{"command":"aitools_install","exit_code":1,"aitools_command_event":{"command":"INSTALL","stage":"STEP_COMPLETED","step":"PLAN_CONFIRMED","interactive":false,"install_mode":"SKILLS_ONLY","scope":"GLOBAL"}}
        -{"command":"aitools_install","exit_code":1,"aitools_command_event":{"command":"INSTALL","stage":"FINISHED","outcome":"FAILURE","error_category":"SKILL_NOT_FOUND","interactive":false,"install_mode":"SKILLS_ONLY","agents":["CURSOR"],"scope":"GLOBAL"}}
         {"command":"aitools_install","exit_code":1,"aitools_install_event":{"agents":["CURSOR"],"scope":"GLOBAL","error_category":"SKILL_NOT_FOUND"}}
         
         === a manifest the server does not have is MANIFEST_NOT_FOUND
        @@ -25,12 +19,6 @@
         Error: skills manifest at databricks-agent-skills@missing-ref: not found
         
         >>> print_telemetry
        -{"command":"aitools_install","exit_code":1,"aitools_command_event":{"command":"INSTALL","stage":"STARTED","interactive":false,"install_mode":"SKILLS_ONLY"}}
        -{"command":"aitools_install","exit_code":1,"aitools_command_event":{"command":"INSTALL","stage":"STEP_COMPLETED","step":"FLAGS_VALIDATED","interactive":false,"install_mode":"SKILLS_ONLY"}}
        -{"command":"aitools_install","exit_code":1,"aitools_command_event":{"command":"INSTALL","stage":"STEP_COMPLETED","step":"SCOPE_RESOLVED","interactive":false,"install_mode":"SKILLS_ONLY","scope":"GLOBAL"}}
        -{"command":"aitools_install","exit_code":1,"aitools_command_event":{"command":"INSTALL","stage":"STEP_COMPLETED","step":"AGENTS_SELECTED","interactive":false,"install_mode":"SKILLS_ONLY","scope":"GLOBAL"}}
        -{"command":"aitools_install","exit_code":1,"aitools_command_event":{"command":"INSTALL","stage":"STEP_COMPLETED","step":"PLAN_CONFIRMED","interactive":false,"install_mode":"SKILLS_ONLY","scope":"GLOBAL"}}
        -{"command":"aitools_install","exit_code":1,"aitools_command_event":{"command":"INSTALL","stage":"FINISHED","outcome":"FAILURE","error_category":"MANIFEST_NOT_FOUND","interactive":false,"install_mode":"SKILLS_ONLY","agents":["CURSOR"],"scope":"GLOBAL"}}
         {"command":"aitools_install","exit_code":1,"aitools_install_event":{"agents":["CURSOR"],"scope":"GLOBAL","error_category":"MANIFEST_NOT_FOUND"}}
         
         === any other HTTP error status from the manifest fetch is not classified yet (the error is untyped)
        @@ -42,12 +30,6 @@
         Error: failed to fetch manifest: HTTP 500
         
         >>> print_telemetry
        -{"command":"aitools_install","exit_code":1,"aitools_command_event":{"command":"INSTALL","stage":"STARTED","interactive":false,"install_mode":"SKILLS_ONLY"}}
        -{"command":"aitools_install","exit_code":1,"aitools_command_event":{"command":"INSTALL","stage":"STEP_COMPLETED","step":"FLAGS_VALIDATED","interactive":false,"install_mode":"SKILLS_ONLY"}}
        -{"command":"aitools_install","exit_code":1,"aitools_command_event":{"command":"INSTALL","stage":"STEP_COMPLETED","step":"SCOPE_RESOLVED","interactive":false,"install_mode":"SKILLS_ONLY","scope":"GLOBAL"}}
        -{"command":"aitools_install","exit_code":1,"aitools_command_event":{"command":"INSTALL","stage":"STEP_COMPLETED","step":"AGENTS_SELECTED","interactive":false,"install_mode":"SKILLS_ONLY","scope":"GLOBAL"}}
        -{"command":"aitools_install","exit_code":1,"aitools_command_event":{"command":"INSTALL","stage":"STEP_COMPLETED","step":"PLAN_CONFIRMED","interactive":false,"install_mode":"SKILLS_ONLY","scope":"GLOBAL"}}
        -{"command":"aitools_install","exit_code":1,"aitools_command_event":{"command":"INSTALL","stage":"FINISHED","outcome":"FAILURE","error_category":"UNCATEGORIZED","interactive":false,"install_mode":"SKILLS_ONLY","agents":["CURSOR"],"scope":"GLOBAL"}}
         {"command":"aitools_install","exit_code":1,"aitools_install_event":{"agents":["CURSOR"],"scope":"GLOBAL","error_category":"UNCATEGORIZED"}}
         
         === a named agent skipped for the scope takes its category from the agent's outcome
        @@ -57,10 +39,4 @@
         Error: Cursor: does not support project-scoped skills
         
         >>> print_telemetry
        -{"command":"aitools_install","exit_code":1,"aitools_command_event":{"command":"INSTALL","stage":"STARTED","interactive":false,"install_mode":"SKILLS_ONLY"}}
        -{"command":"aitools_install","exit_code":1,"aitools_command_event":{"command":"INSTALL","stage":"STEP_COMPLETED","step":"FLAGS_VALIDATED","interactive":false,"install_mode":"SKILLS_ONLY"}}
        -{"command":"aitools_install","exit_code":1,"aitools_command_event":{"command":"INSTALL","stage":"STEP_COMPLETED","step":"SCOPE_RESOLVED","interactive":false,"install_mode":"SKILLS_ONLY","scope":"PROJECT"}}
        -{"command":"aitools_install","exit_code":1,"aitools_command_event":{"command":"INSTALL","stage":"STEP_COMPLETED","step":"AGENTS_SELECTED","interactive":false,"install_mode":"SKILLS_ONLY","scope":"PROJECT"}}
        -{"command":"aitools_install","exit_code":1,"aitools_command_event":{"command":"INSTALL","stage":"STEP_COMPLETED","step":"PLAN_CONFIRMED","interactive":false,"install_mode":"SKILLS_ONLY","scope":"PROJECT"}}
        -{"command":"aitools_install","exit_code":1,"aitools_command_event":{"command":"INSTALL","stage":"FINISHED","outcome":"FAILURE","error_category":"UNSUPPORTED_SCOPE","interactive":false,"install_mode":"SKILLS_ONLY","agents":["CURSOR"],"scope":"PROJECT"}}
         {"command":"aitools_install","exit_code":1,"aitools_install_event":{"agents":["CURSOR"],"scope":"PROJECT","error_category":"TYPE_UNSPECIFIED","agent_results":[{"agent":"CURSOR","error_category":"UNSUPPORTED_SCOPE"}]}}
TestAccept/cmd/aitools/telemetry/update-uninstall: branch ✅ | main (8b4f052) ❌ | latest (v1.26.0) ❌

main (8b4f052):

acceptance_test.go:1213: Substituted DATABRICKS_CLI_GITHUB_API_URL "$DATABRICKS_HOST" -> "http://127.0.0.1:43541" ("[DATABRICKS_HOST]")
    acceptance_test.go:1213: Substituted DATABRICKS_SKILLS_BASE_URL "$DATABRICKS_HOST" -> "http://127.0.0.1:43541" ("[DATABRICKS_HOST]")
    acceptance_test.go:1345: 
        	Error Trace:	/home/runner/work/cli/cli/libs/testdiff/testdiff.go:22
        	            				/home/runner/work/cli/cli/acceptance/acceptance_test.go:1345
        	            				/home/runner/work/cli/cli/acceptance/acceptance_test.go:1086
        	            				/home/runner/work/cli/cli/acceptance/acceptance_test.go:642
        	Error:      	Not equal: 
        	            	expected: "\n=== update: STARTED, FINISHED with SUCCESS\n\n>>> [CLI] aitools update --scope=global\nAlready up to date.\n\n>>> print_telemetry\n{\"command\":\"aitools_update\",\"exit_code\":0,\"aitools_command_event\":{\"command\":\"UPDATE\",\"stage\":\"STARTED\",\"interactive\":false}}\n{\"command\":\"aitools_update\",\"exit_code\":0,\"aitools_command_event\":{\"command\":\"UPDATE\",\"stage\":\"FINISHED\",\"outcome\":\"SUCCESS\",\"interactive\":false,\"scope\":\"GLOBAL\"}}\n\n=== uninstall: STARTED, FINISHED with SUCCESS\n\n>>> [CLI] aitools uninstall --scope=global\nUninstalled 1 skill.\n\n>>> print_telemetry\n{\"command\":\"aitools_uninstall\",\"exit_code\":0,\"aitools_command_event\":{\"command\":\"UNINSTALL\",\"stage\":\"STARTED\",\"interactive\":false}}\n{\"command\":\"aitools_uninstall\",\"exit_code\":0,\"aitools_command_event\":{\"command\":\"UNINSTALL\",\"stage\":\"FINISHED\",\"outcome\":\"SUCCESS\",\"interactive\":false,\"scope\":\"GLOBAL\"}}\n"
        	            	actual  : "\n=== update: STARTED, FINISHED with SUCCESS\n\n>>> [CLI] aitools update --scope=global\nAlready up to date.\n\n>>> print_telemetry\n\n=== uninstall: STARTED, FINISHED with SUCCESS\n\n>>> [CLI] aitools uninstall --scope=global\nUninstalled 1 skill.\n\n>>> print_telemetry\n"
        	            	
        	            	Diff:
        	            	--- Expected
        	            	+++ Actual
        	            	@@ -7,4 +7,2 @@
        	            	 >>> print_telemetry
        	            	-{"command":"aitools_update","exit_code":0,"aitools_command_event":{"command":"UPDATE","stage":"STARTED","interactive":false}}
        	            	-{"command":"aitools_update","exit_code":0,"aitools_command_event":{"command":"UPDATE","stage":"FINISHED","outcome":"SUCCESS","interactive":false,"scope":"GLOBAL"}}
        	            	 
        	            	@@ -16,4 +14,2 @@
        	            	 >>> print_telemetry
        	            	-{"command":"aitools_uninstall","exit_code":0,"aitools_command_event":{"command":"UNINSTALL","stage":"STARTED","interactive":false}}
        	            	-{"command":"aitools_uninstall","exit_code":0,"aitools_command_event":{"command":"UNINSTALL","stage":"FINISHED","outcome":"SUCCESS","interactive":false,"scope":"GLOBAL"}}
        	            	 
        	Test:       	TestAccept/cmd/aitools/telemetry/update-uninstall
        	Messages:   	cmd/aitools/telemetry/update-uninstall/output.txt vs /tmp/TestAcceptcmdaitoolstelemetryupdate-uninstall2404280030/002/output.txt
    acceptance_test.go:1123: 
        LOG.install: Installing Databricks skills for Cursor...
        LOG.install: Using skills version test-ref
        LOG.install: Fetching skills manifest...
        LOG.install: Installed 1 skill.
    acceptance_test.go:1123: LOG.install-telemetry: {"command":"aitools_install","exit_code":0,"aitools_install_event":{"agents":["CURSOR"],"scope":"GLOBAL","error_category":"TYPE_UNSPECIFIED"}}

latest (v1.26.0):

acceptance_test.go:1213: Substituted DATABRICKS_CLI_GITHUB_API_URL "$DATABRICKS_HOST" -> "http://127.0.0.1:37037" ("[DATABRICKS_HOST]")
    acceptance_test.go:1213: Substituted DATABRICKS_SKILLS_BASE_URL "$DATABRICKS_HOST" -> "http://127.0.0.1:37037" ("[DATABRICKS_HOST]")
    acceptance_test.go:1345: 
        	Error Trace:	/home/runner/work/cli/cli/libs/testdiff/testdiff.go:22
        	            				/home/runner/work/cli/cli/acceptance/acceptance_test.go:1345
        	            				/home/runner/work/cli/cli/acceptance/acceptance_test.go:1086
        	            				/home/runner/work/cli/cli/acceptance/acceptance_test.go:642
        	Error:      	Not equal: 
        	            	expected: "\n=== update: STARTED, FINISHED with SUCCESS\n\n>>> [CLI] aitools update --scope=global\nAlready up to date.\n\n>>> print_telemetry\n{\"command\":\"aitools_update\",\"exit_code\":0,\"aitools_command_event\":{\"command\":\"UPDATE\",\"stage\":\"STARTED\",\"interactive\":false}}\n{\"command\":\"aitools_update\",\"exit_code\":0,\"aitools_command_event\":{\"command\":\"UPDATE\",\"stage\":\"FINISHED\",\"outcome\":\"SUCCESS\",\"interactive\":false,\"scope\":\"GLOBAL\"}}\n\n=== uninstall: STARTED, FINISHED with SUCCESS\n\n>>> [CLI] aitools uninstall --scope=global\nUninstalled 1 skill.\n\n>>> print_telemetry\n{\"command\":\"aitools_uninstall\",\"exit_code\":0,\"aitools_command_event\":{\"command\":\"UNINSTALL\",\"stage\":\"STARTED\",\"interactive\":false}}\n{\"command\":\"aitools_uninstall\",\"exit_code\":0,\"aitools_command_event\":{\"command\":\"UNINSTALL\",\"stage\":\"FINISHED\",\"outcome\":\"SUCCESS\",\"interactive\":false,\"scope\":\"GLOBAL\"}}\n"
        	            	actual  : "\n=== update: STARTED, FINISHED with SUCCESS\n\n>>> [CLI] aitools update --scope=global\nAlready up to date.\n\n>>> print_telemetry\n\n=== uninstall: STARTED, FINISHED with SUCCESS\n\n>>> [CLI] aitools uninstall --scope=global\nUninstalled 1 skill.\n\n>>> print_telemetry\n"
        	            	
        	            	Diff:
        	            	--- Expected
        	            	+++ Actual
        	            	@@ -7,4 +7,2 @@
        	            	 >>> print_telemetry
        	            	-{"command":"aitools_update","exit_code":0,"aitools_command_event":{"command":"UPDATE","stage":"STARTED","interactive":false}}
        	            	-{"command":"aitools_update","exit_code":0,"aitools_command_event":{"command":"UPDATE","stage":"FINISHED","outcome":"SUCCESS","interactive":false,"scope":"GLOBAL"}}
        	            	 
        	            	@@ -16,4 +14,2 @@
        	            	 >>> print_telemetry
        	            	-{"command":"aitools_uninstall","exit_code":0,"aitools_command_event":{"command":"UNINSTALL","stage":"STARTED","interactive":false}}
        	            	-{"command":"aitools_uninstall","exit_code":0,"aitools_command_event":{"command":"UNINSTALL","stage":"FINISHED","outcome":"SUCCESS","interactive":false,"scope":"GLOBAL"}}
        	            	 
        	Test:       	TestAccept/cmd/aitools/telemetry/update-uninstall
        	Messages:   	cmd/aitools/telemetry/update-uninstall/output.txt vs /tmp/TestAcceptcmdaitoolstelemetryupdate-uninstall1996787579/002/output.txt
    acceptance_test.go:1123: 
        LOG.install: Installing Databricks skills for Cursor...
        LOG.install: Using skills version test-ref
        LOG.install: Fetching skills manifest...
        LOG.install: Installed 1 skill.
    acceptance_test.go:1123: LOG.install-telemetry: {"command":"aitools_install","exit_code":0,"aitools_install_event":{"agents":["CURSOR"],"scope":"GLOBAL","error_category":"TYPE_UNSPECIFIED"}}
TestAccept/cmd/aitools/telemetry/workspace-unreachable: branch ✅ | main (8b4f052) ❌ | latest (v1.26.0) ❌

main (8b4f052):

acceptance_test.go:1213: Substituted DATABRICKS_CLI_GITHUB_API_URL "$DATABRICKS_HOST" -> "http://127.0.0.1:35807" ("[DATABRICKS_HOST]")
    acceptance_test.go:1213: Substituted DATABRICKS_SKILLS_BASE_URL "$DATABRICKS_HOST" -> "http://127.0.0.1:35807" ("[DATABRICKS_HOST]")
    acceptance_test.go:1345: 
        	Error Trace:	/home/runner/work/cli/cli/libs/testdiff/testdiff.go:22
        	            				/home/runner/work/cli/cli/acceptance/acceptance_test.go:1345
        	            				/home/runner/work/cli/cli/acceptance/acceptance_test.go:1086
        	            				/home/runner/work/cli/cli/acceptance/acceptance_test.go:642
        	Error:      	Not equal: 
        	            	expected: "\n=== install: the background auth resolution prints nothing\n\n>>> [CLI] aitools install --skills-only --scope=global --agents=cursor\nInstalling Databricks skills for Cursor...\nUsing skills version test-ref\nFetching skills manifest...\nInstalled 1 skill.\n\n=== update: the background auth resolution prints nothing\n\n>>> [CLI] aitools update --scope=global\nAlready up to date.\n\n=== uninstall: the background auth resolution prints nothing\n\n>>> [CLI] aitools uninstall --scope=global\nUninstalled 1 skill.\n"
        	            	actual  : "\n=== install: the background auth resolution prints nothing\n\n>>> [CLI] aitools install --skills-only --scope=global --agents=cursor\nWarn: [hostmetadata] failed to fetch host metadata for https://unreachable.test, will skip for 1m0s\nInstalling Databricks skills for Cursor...\nUsing skills version test-ref\nFetching skills manifest...\nInstalled 1 skill.\n\n=== update: the background auth resolution prints nothing\n\n>>> [CLI] aitools update --scope=global\nAlready up to date.\n\n=== uninstall: the background auth resolution prints nothing\n\n>>> [CLI] aitools uninstall --scope=global\nUninstalled 1 skill.\n"
        	            	
        	            	Diff:
        	            	--- Expected
        	            	+++ Actual
        	            	@@ -4,2 +4,3 @@
        	            	 >>> [CLI] aitools install --skills-only --scope=global --agents=cursor
        	            	+Warn: [hostmetadata] failed to fetch host metadata for https://unreachable.test, will skip for 1m0s
        	            	 Installing Databricks skills for Cursor...
        	Test:       	TestAccept/cmd/aitools/telemetry/workspace-unreachable
        	Messages:   	cmd/aitools/telemetry/workspace-unreachable/output.txt vs /tmp/TestAcceptcmdaitoolstelemetryworkspace-unreachable222383468/002/output.txt

latest (v1.26.0):

acceptance_test.go:1213: Substituted DATABRICKS_CLI_GITHUB_API_URL "$DATABRICKS_HOST" -> "http://127.0.0.1:44631" ("[DATABRICKS_HOST]")
    acceptance_test.go:1213: Substituted DATABRICKS_SKILLS_BASE_URL "$DATABRICKS_HOST" -> "http://127.0.0.1:44631" ("[DATABRICKS_HOST]")
    acceptance_test.go:1345: 
        	Error Trace:	/home/runner/work/cli/cli/libs/testdiff/testdiff.go:22
        	            				/home/runner/work/cli/cli/acceptance/acceptance_test.go:1345
        	            				/home/runner/work/cli/cli/acceptance/acceptance_test.go:1086
        	            				/home/runner/work/cli/cli/acceptance/acceptance_test.go:642
        	Error:      	Not equal: 
        	            	expected: "\n=== install: the background auth resolution prints nothing\n\n>>> [CLI] aitools install --skills-only --scope=global --agents=cursor\nInstalling Databricks skills for Cursor...\nUsing skills version test-ref\nFetching skills manifest...\nInstalled 1 skill.\n\n=== update: the background auth resolution prints nothing\n\n>>> [CLI] aitools update --scope=global\nAlready up to date.\n\n=== uninstall: the background auth resolution prints nothing\n\n>>> [CLI] aitools uninstall --scope=global\nUninstalled 1 skill.\n"
        	            	actual  : "\n=== install: the background auth resolution prints nothing\n\n>>> [CLI] aitools install --skills-only --scope=global --agents=cursor\nWarn: [hostmetadata] failed to fetch host metadata for https://unreachable.test, will skip for 1m0s\nInstalling Databricks skills for Cursor...\nUsing skills version test-ref\nFetching skills manifest...\nInstalled 1 skill.\n\n=== update: the background auth resolution prints nothing\n\n>>> [CLI] aitools update --scope=global\nAlready up to date.\n\n=== uninstall: the background auth resolution prints nothing\n\n>>> [CLI] aitools uninstall --scope=global\nUninstalled 1 skill.\n"
        	            	
        	            	Diff:
        	            	--- Expected
        	            	+++ Actual
        	            	@@ -4,2 +4,3 @@
        	            	 >>> [CLI] aitools install --skills-only --scope=global --agents=cursor
        	            	+Warn: [hostmetadata] failed to fetch host metadata for https://unreachable.test, will skip for 1m0s
        	            	 Installing Databricks skills for Cursor...
        	Test:       	TestAccept/cmd/aitools/telemetry/workspace-unreachable
        	Messages:   	cmd/aitools/telemetry/workspace-unreachable/output.txt vs /tmp/TestAcceptcmdaitoolstelemetryworkspace-unreachable2787804329/002/output.txt

@eng-dev-ecosystem-bot

eng-dev-ecosystem-bot commented Nov 11, 2026 •

Copy link
Copy Markdown
Collaborator

✅ Integration test report

Commit: f379423

Run: 43200304035

Env 💚​RECOVERED ✅​pass 🙈​skip Time
💚​ aws linux-2core-8gb 1 270 26 12:39
💚​ aws-windows-latest-4core-16gb 1 272 24 9:46
💚​ azure linux-2core-8gb 1 269 26 8:26
💚​ azure-windows-latest-4core-16gb 1 271 24 8:22
💚​ gcp linux-2core-8gb 1 270 26 14:53
💚​ gcp-windows-latest-4core-16gb 1 272 24 14:24
Test Name aws linux-2core-8gb aws-windows-latest-4core-16gb azure linux-2core-8gb azure-windows-latest-4core-16gb gcp linux-2core-8gb gcp-windows-latest-4core-16gb
💚​ TestAccept 💚​R 💚​R 💚​R 💚​R 💚​R 💚​R
Top 13 slowest tests (at least 2 minutes):
duration env testname
3:54 azure linux-2core-8gb TestAccept
3:50 gcp linux-2core-8gb TestAccept
3:49 aws linux-2core-8gb TestAccept
3:42 gcp-windows-latest-4core-16gb TestAccept
3:38 azure-windows-latest-4core-16gb TestAccept
3:34 aws-windows-latest-4core-16gb TestAccept
2:41 gcp-windows-latest-4core-16gb TestFsCpFileToFileFileNotOverwritten/dbfs_to_dbfs
2:40 gcp-windows-latest-4core-16gb TestFsCpSourceIsDirectoryButTargetIsFile/dbfs_to_local
2:17 gcp-windows-latest-4core-16gb TestFsCpFileToDirFileNotOverwritten/dbfs_to_dbfs
2:12 gcp linux-2core-8gb TestFsCpDirToDirFileNotOverwritten/dbfs_to_dbfs
2:10 gcp linux-2core-8gb TestFsCpDirToDirWithOverwriteFlag/dbfs_to_dbfs
2:10 gcp-windows-latest-4core-16gb TestFilerRecursiveDelete/dbfs
2:08 gcp linux-2core-8gb TestFsCpDir/dbfs_to_dbfs

@simonfaltum simonfaltum left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Automated review from simonfaltum's review agent (pr-review skill) at 2b4b8c6. Each finding was verified against the code; simonfaltum may follow up.

Not approving yet: 2 open design questions.

This PR extends the aitools_command_event funnel from install to update, list, version and uninstall. It also adds installer results so that runs where every attempt only produced a warning are reported as FAILURE, and nothing-installed runs as NOTHING_TO_DO. The wiring is correct, error text and exit codes are unchanged, and CI is green; I found nothing blocking. Two questions are open: whether these four mostly local commands should pay the background auth and upload cost, and whether the installer refactor belongs in this PR. Separately, a few edge cases report the wrong outcome or category (untagged prompt errors, update --check without a manifest, unknown-agent records), and there are some test and changelog nits.

Questions

  1. version, list, update and uninstall now resolve workspace auth in the background on every run, only so their telemetry can upload. version and uninstall made no network calls before, and list is what cmd/quickstart/quickstart-agent.md tells agents to run. The cost is at least 3 s per run while the workspace is unreachable (up to about 6 s when it is slow), and these commands can now hit the abandoned OAuth refresh described in telemetry.go:85-90. Is the data from these four funnels worth that? Options: keep it as is; instrument only update and uninstall and leave the purely local version and list alone; or keep the funnel on install only.
  2. This PR combines the funnel wiring with installer API changes: UninstallSkillsWithResult/UninstallResult, the removeAllFn seam, removeSkillExposuresFromAgents returning results, sorted removal on full uninstall, and PluginUpdate.Err with failed plugins returned last. They exist to classify outcomes, but they change the installer API and behavior on their own. Should they stay here, or move into a separate refactor PR that this one builds on?

Comments outside the changed lines

  • [nit] libs/aitools/installer/uninstall.go:109: A plugin record for an agent this CLI does not know is skipped with a warning but never added to pluginFailures. UpdateInstalledPlugins does the same (update.go:455-458). Suppose the state holds only such a record, for example one written by a newer CLI. uninstall and update then act on nothing, record no failure, and finish SUCCESS. Record these skips: append an error to pluginFailures here, and return a PluginUpdate with Err set in UpdateInstalledPlugins.

Comment thread cmd/aitools/scope.go
return nil, errors.New("skills are installed in both global and project scopes; use --scope=global, --scope=project, or --scope=both to specify which to update")
return nil, withCategory(errors.New("skills are installed in both global and project scopes; use --scope=global, --scope=project, or --scope=both to specify which to update"), protos.AitoolsErrorCategoryInvalidFlags)
}
scopes, err := promptUpdateScopeSelection(ctx)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nice to have] update and uninstall now log the funnel, but their prompt errors are returned without promptError. That covers the scope pickers here and at line 260, and the confirm prompt (cmdio.AskYesOrNo via confirmUninstall, uninstall.go:123). If huh cannot open the terminal it returns an *fs.PathError, so the run finishes FAILURE:FILESYSTEM; that is the mislabel promptError exists to prevent. Ctrl-D at Proceed? [y/N] returns io.EOF and finishes FAILURE:UNCATEGORIZED. install reports both as PROMPT_FAILED. Wrap these three returns with promptError(err). A Ctrl-C abort keeps its CANCELLED outcome.

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.

Wrapped all three with promptError: the update and uninstall scope pickers (inside defaultPromptUpdateScopeSelection / defaultPromptUninstallScopeSelection, around the huh call only, as install's prompts now do, so a home-dir or cwd error is not tagged PROMPT_FAILED) and cmdio.AskYesOrNo in confirmUninstall (not the whole function, so a state-load error keeps its own category). Ctrl-C still finishes CANCELLED. TestUninstallConfirmPromptFailure covers EOF at the confirm: FAILURE:PROMPT_FAILED. bbc2b80

Comment thread cmd/aitools/update.go Outdated
if err != nil {
return err
}
updatedAnything = true

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nice to have] updatedAnything is set whenever updateSkillsFn returns nil. In --check mode, though, installer.UpdateSkills turns a manifest fetch failure into a warning and returns an empty result (libs/aitools/installer/update.go:105-107). Take aitools update --check with file skills installed, where the release ref resolves but the manifest fetch fails (for example, a pinned ref whose manifest host is unreachable). It finishes SUCCESS although nothing was checked, while the plugin path in the same situation already reports FAILURE. Carry the swallowed error back (for example, a field on UpdateResult) and treat it like firstPluginErr, keeping exit code 0.

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.

Added UpdateResult.CheckErr, set when a --check run only warns that the manifest could not be fetched. update treats it like a failed plugin (first error kept, exit code still 0), so a run where it is the only attempt finishes FAILURE with its category. The plugin check printed in the same run is work done too, so a --check run that checked recorded plugins still finishes SUCCESS. Two new cases in TestUpdateFunnelWhenPluginsFail cover both. bbc2b80

var failures []error
removed, failures = removeSkillExposuresFromAgents(ctx, name, canonicalDir, scope, cwd, state, opts.Agents)
skillFailures = append(skillFailures, failures...)
removeCanonical = !hasRemainingSkillExposure(ctx, name, canonicalDir, scope, cwd, opts.Agents)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] With --agents, hasRemainingSkillExposure skips every targeted agent, including one whose entry removal just failed. Say only Cursor has skill X and removing ~/.cursor/skills/X fails (for example, EACCES on a copied dir). The canonical dir is still deleted, removed becomes true, SkillsRemoved is 1, and the funnel reports SUCCESS while Cursor keeps an untracked copy. The deletion predates this PR, but the new counter should not report it as success. Either don't count the skill when a targeted entry failed, or keep the canonical dir when a targeted removal failed.

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.

A skill whose entry could not be removed from one of the targeted agents no longer counts in SkillsRemoved, so your Cursor example finishes FAILURE with the removal error's category. I left the canonical-dir deletion as is: it predates this PR, and changing what uninstall deletes is a separate fix. New test TestUninstallDoesNotCountSkillWithFailedTargetedRemoval (fails without the change). bbc2b80

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.

Still left as is in 8643a8e: deleting the canonical dir after a failed targeted removal predates this PR, and changing what uninstall --agents deletes is a behavior fix of its own, separate from the funnel. This PR only makes sure that case no longer counts as removed (it finishes FAILURE with the removal error's category).

Comment thread cmd/aitools/uninstall.go Outdated
}
// A plugin or skill that could not be removed is only a warning.
failures := slices.Concat(result.PluginFailures, result.SkillFailures)
if len(failures) > 0 && result.PluginsRemoved == 0 && result.SkillsRemoved == 0 {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] Consider aitools uninstall --agents cursor when only Claude Code has the skills. SkillsRemoved and PluginsRemoved are both 0 and there are no failures, so the run finishes SUCCESS; TestUninstallCountsRemovedSkills covers this state. NOTHING_TO_DO ("nothing installed to update or uninstall") seems to fit. If SUCCESS is intended, add a short comment saying so. Otherwise finish NOTHING_TO_DO when both counts are 0 and there are no failures.

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.

An uninstall that removed nothing and recorded no failure now finishes NOTHING_TO_DO (new funnelNothingToDo; exit code unchanged). Added a --agents=cursor case for it to TestUninstallFunnelWhenRemovalsFail. bbc2b80

Comment thread cmd/aitools/telemetry.go Outdated
// FINISHED (outcome from the returned error).
func withCommandFunnel(command protos.AitoolsCommand, runE func(cmd *cobra.Command, args []string) error) func(cmd *cobra.Command, args []string) error {
return func(cmd *cobra.Command, args []string) (err error) {
f := startFunnel(cmd.Context(), command, "")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] withCommandFunnel never calls setScope or setAgents. So update, uninstall and list events carry neither field, even for uninstall --scope=global --agents=cursor; the install-success golden has no scope key for uninstall --scope=global. Install's events carry both, so these funnels cannot be split the same way. Add a context helper like funnelCancelled (for example, funnelSetScope(ctx, scope)) and call it once update and uninstall resolve a single scope.

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.

Added funnelSetScope and funnelSetAgents. uninstall sets the scope once resolved and the agents from --agents; update sets the scope when it resolved to a single one (the enum has no "both", so --scope=both reports none). The install-success golden now shows "scope":"GLOBAL" on the update and uninstall FINISHED events, and the funnel tests assert scope and agents. bbc2b80

trace print_telemetry

title "list\n"
trace $CLI aitools list --scope=global > /dev/null

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] aitools list writes its table through cmdio.LogString to stderr, so this redirect discards nothing; the table is still in output.txt. Drop the redirect, or send the output to LOG.list as .agents/rules/testing.md asks for noisy output.

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.

Dropped the redirect. I then removed the list and version runs from this golden: they no longer wait for auth at exit (see the reply on Question 1), so whether their events upload against the mock server depends on timing. Their funnels are covered by unit tests. bbc2b80


# The golden pins the error text and exit code, which stay as before.
title "update and uninstall with nothing installed: FINISHED with NOTHING_TO_DO, no error category\n"
errcode trace $CLI aitools update --scope=global

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] The update and uninstall runs on lines 27 and 29 must fail. .agents/rules/testing.md says: "Assert an expected failure with musterr, not … a bare errcode." The golden would catch an unexpected success today, so this is about following the rule. Use musterr trace … and regenerate the golden. musterr does not print Exit code: 1, so if pinning the exact exit code matters, say so in a comment and keep errcode.

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.

Switched both to musterr trace. The exit code is still pinned through exit_code in the uploaded events. bbc2b80

func TestUninstallCountsRemovedSkills(t *testing.T) {
tmp := setupTestHome(t)
// Agent dirs derive from the temp HOME, not from the host's overrides.
for _, v := range []string{"CLAUDE_CONFIG_DIR", "PI_CODING_AGENT_DIR", "GEMINI_CLI_HOME", "GOOSE_PATH_ROOT", "APPDATA", "XDG_CONFIG_HOME"} {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] The six-variable list (CLAUDE_CONFIG_DIR, PI_CODING_AGENT_DIR, …) is repeated at line 477 and nearly matches isolateAgents in cmd/aitools/funnel_test.go. When an agent adds a config-dir override, every copy needs updating, and a missed one makes the test read the host's agent dirs. Move the list into one helper next to setupTestHome; .agents/rules/testing.md says to extract values that must change together.

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.

Moved the list into clearAgentDirOverrides next to setupTestHome in installer_test.go; both tests use it. isolateAgents in cmd/aitools is in another package and also pins the calling agent, so it keeps its own list. bbc2b80

require.NoError(t, os.MkdirAll(filepath.Join(tmp, ".claude"), 0o755))
require.NoError(t, os.MkdirAll(filepath.Join(tmp, ".cursor"), 0o755))

install := func(a string) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] install := func(a string) here and saveCopilotPlugin := func(t *testing.T) at plugin_test.go:470 are inline closures that only name a repeated step. .agents/rules/style-guide-go.md: "Do not declare inline function closures just to name a repeated operation. Extract it as a regular named function instead." Make them named helpers that take t/ctx explicitly.

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.

Both are named helpers now: installSQLSkillForAgent(t, ctx, name) in uninstall_test.go and saveCopilotPlugin(t) in plugin_test.go. bbc2b80

@@ -0,0 +1 @@
* `databricks aitools` commands now send usage telemetry on how each run went (started, steps reached, outcome, error category), and the CLI records when it recommends installing Databricks AI tools. To upload it, `aitools update`, `list`, `version` and `uninstall` now contact the configured workspace like `aitools install` does: auth resolves in the background, and at the end of a run the command waits for it (at most 3 seconds) and then for the upload (`DATABRICKS_CLI_TELEMETRY_TIMEOUT`, default 3 seconds), so a short run can take up to about 6 seconds longer on a slow workspace and lasts at least 3 seconds while connections to the workspace are refused or time out. Set `DATABRICKS_CLI_DISABLE_TELEMETRY=1` to skip both. ([#7467](https://github.com/databricks/cli/pull/7467), [#7469](https://github.com/databricks/cli/pull/7469), [#7470](https://github.com/databricks/cli/pull/7470))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] The entry is about 900 characters, roughly twice the next-longest fragment, and explains internals: background auth resolution and two separate timeouts. The pr-checklist asks for the user-facing effect, not the implementation. Something shorter would do, for example: "databricks aitools commands now send usage telemetry; update, list, version and uninstall contact the configured workspace for it and can take up to about 6 seconds longer when it is slow or unreachable. Set DATABRICKS_CLI_DISABLE_TELEMETRY=1 to opt out." Keep the PR links.

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.

Shortened to the user-facing effect (about 370 characters plus the three PR links). It also states the no-wait change from Question 1: install, update and uninstall can take up to about 6 seconds longer; list, version and runs that stop early never wait. bbc2b80

@anton-107
anton-107 force-pushed the aitools/install-funnel-telemetry branch 3 times, most recently from 8fcfd3e to d5ef5c6 Compare November 11, 2026 17:17
@anton-107
anton-107 force-pushed the aitools/command-funnel-telemetry branch from 2b4b8c6 to bbc2b80 Compare November 11, 2026 17:27
@anton-107

Copy link
Copy Markdown
Contributor Author

Thanks for the review. Replies to the parts outside the inline threads, all in bbc2b80:

Unknown-agent records (outside the diff): uninstall now appends unknown agent "<name>" in state to PluginFailures, and UpdateInstalledPlugins returns a PluginUpdate with that Err; the record is still kept. A state holding only such a record finishes FAILURE for both commands (TestPluginOperationsReportUnknownAgent).

Question 1: All four funnels stay, but the wait now depends on how far a run gets. list and version never wait for the auth resolution at exit: their events upload only if it has already finished. So do update and uninstall runs that end before their work (flag and scope errors, a declined confirm), the same rule #7469 applies to install before its plan is confirmed. Only runs that reach their work (waitForTelemetryAuth) keep the bounded wait. With a refused host, the built CLI takes 19 ms for version, 23 ms for update --scope=bogus, and 3.0 s for update and uninstall with nothing installed. The background resolution no longer refreshes OAuth tokens (#7468), so the abandoned-refresh case no longer applies. Tests: TestInstrumentedCommandsDoNotWaitForTelemetryAuth and TestUpdateAndUninstallWorkWaitsForTelemetryAuth. The changelog fragment and Merge Danger describe this now.

Question 2: I kept the installer changes in this PR. They exist only to classify these funnel outcomes and have no other caller, so a separate PR would add one more to an already long stack without making either easier to review.

@anton-107
anton-107 force-pushed the aitools/install-funnel-telemetry branch from d5ef5c6 to 3b60b60 Compare November 11, 2026 19:27
@anton-107
anton-107 force-pushed the aitools/command-funnel-telemetry branch from bbc2b80 to 6cd41f6 Compare November 11, 2026 19:28

@simonfaltum simonfaltum left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Automated review from simonfaltum's review agent (pr-review skill) at 6cd41f6. Each finding was verified against the code; simonfaltum may follow up.

Not approving yet: 2 open design questions.

This PR adds the aitools_command_event funnel and best-effort background auth to aitools update, list, version and uninstall. It also adds installer results so that runs where every attempt only warned count as FAILURE, and runs with nothing installed count as NOTHING_TO_DO. The wiring is correct, error text and exit codes are unchanged, every fix from the previous round is in this head, and CI is green; nothing is blocking. The main open point: list and version can still wait up to 3 s for the upload once auth resolves from cached credentials, which the changelog says never happens. There are also two design questions (whether to instrument list/version, and where the installer API changes should live) and a few nits.

Questions

  1. list and version now start a background auth resolution on every run and upload their events only if it finishes before the command does. The acceptance script notes that this depends on timing. How often do you expect these two commands' events to arrive in practice, and is that enough to keep instrumenting them? Options: keep them as they are; keep them with a shorter upload budget for runs that do not wait; or instrument only install, update and uninstall.
  2. On keeping the installer changes in this PR: one smaller variant would change UninstallSkillsOpts's return type in place instead of replacing it with UninstallSkillsWithResult, which avoids touching about 20 test call sites. Would you take that variant, or keep the current rename? Separately, would you move the API changes into a small refactor PR under this one?

Comments outside the changed lines

  • [nice to have] cmd/aitools/telemetry.go:125: list and version skip only the wait for auth. Once the background resolution has finished, apply puts the config on the context, and root runs the upload with the default 3 s timeout. With a PAT or a valid cached OAuth token and host metadata in the 1 h disk cache, the resolution needs no network, so it is almost always done by the time list finishes its manifest fetch (about 0.8 s). On a slow or blackholed workspace (for example a private-link host with the VPN down), aitools list then exits up to 3 s later; before this PR it made no workspace request at all. That contradicts the changelog ("list, version and runs that stop early never wait for it") and the Merge Danger note that their events are dropped on a slow or unreachable workspace. Give runs that never called waitForTelemetryAuth a short upload budget (hintOnlyUploadTimeout = 1 s in libs/telemetry/logger.go is the precedent), or say in the changelog and Merge Danger that these commands can still take up to 3 s longer.

Agreeing with existing threads

  • [nit] Targeted uninstall still deletes the shared skill dir after a failed entry removal: #7470 (comment)

Comment thread cmd/aitools/uninstall.go Outdated
// From here the run removes files and runs agent CLIs, so it waits for
// the telemetry auth; the flag errors and the declined confirm above
// do not.
waitForTelemetryAuth(ctx)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] With nothing installed, aitools uninstall (and update --scope=global) reach waitForTelemetryAuth before the installer returns ErrNothingInstalled, so on a slow or unreachable workspace they take up to 3 s to print "no skills or plugins installed". --scope=project gets the same NOTHING_TO_DO outcome from withExplicitScopeCheck before the wait and exits at once. For uninstall, confirmUninstall/detectInstalledScopes already know the scope has no state. Skip the wait when the scope has no state, or say in the description and README that this early exit is meant to wait. Trade-off: those events would then upload only if auth had already resolved, and the workspace-unreachable golden would need a seeded state.

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.

update and uninstall now call waitForTelemetryAuth only once the scope they act on has state (update checks each scope's LoadState in its loop, and uninstall loads the state once, and confirmUninstall now takes that state instead of loading it again). With nothing installed (or only a legacy install) they exit at once and upload only if auth has already resolved. I seeded state in the workspace-unreachable golden by running install first, so update and uninstall still wait there. The nothing-installed section of install-success now depends on timing, so I removed it; TestNothingInstalledFinishesNothingToDo pins those events and errors. New cases in TestInstrumentedCommandsDoNotWaitForTelemetryAuth fail if the run always waits. 8643a8e

Comment thread acceptance/cmd/aitools/list/test.toml Outdated
Comment on lines +16 to +19
# aitools list uploads its telemetry events at exit.
[[Server]]
Pattern = "POST /telemetry-ext"
Response.Body = '{"errors": [], "numProtoSuccess": 100}'

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] libs/testserver/handlers.go:612 already models POST /telemetry-ext (it accepts every log and returns numProtoSuccess = len(protoLogs)), and the sibling acceptance/cmd/aitools/telemetry tests rely on it. This stub replaces it with a static numProtoSuccess: 100. .agents/rules/testing.md says to reserve [[Server]] for routes the testserver does not model. Drop the stub; the goldens do not depend on it, since print_requests.py filters /telemetry-ext out.

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.

Dropped the stub; the list tests now use the testserver's POST /telemetry-ext handler. The goldens are unchanged. 8643a8e

// act on" apart from a failure.
var ErrNothingInstalled = errors.New("no skills or plugins installed")

// SkillError reports that a skill named via --skills could not be resolved from

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] uninstall --skills foo now returns SkillError{Reason: ReasonSkillNotFound} for a skill missing from the install state. The type comment ("could not be resolved from the manifest") and ReasonSkillNotFound ("absent from the resolved manifest", line 26) still describe only the manifest case, so a reader would take SKILL_NOT_FOUND to always mean a manifest problem. Update both comments to cover "not installed".

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.

Updated both comments: SkillError now covers a skill "not resolved from the manifest or, for uninstall, not installed", and ReasonSkillNotFound covers "absent from the resolved manifest or, for uninstall, from the install state". 8643a8e

Comment on lines +385 to +389
func TestInstrumentedCommandsResolveAuth(t *testing.T) {
for _, cmd := range []*cobra.Command{NewInstallCmd(), NewUpdateCmd(), NewListCmd(), NewVersionCmd(), NewUninstallCmd()} {
assert.NotNil(t, cmd.PreRunE, cmd.Name())
}
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] Any PreRunE passes this test. If a later change replaced withTelemetryAuth's hook on list or version, their events would stop uploading and no test would fail; TestUpdateAndUninstallWorkWaitsForTelemetryAuth covers only update and uninstall. Run each command with a recording resolver (setTelemetryAuth) and assert the resolver was called.

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.

TestInstrumentedCommandsResolveAuth now runs install, update, list, version and uninstall with a recording resolver (setTelemetryAuth) and fails if the resolver never runs. Each run ends early (a flag error, or version itself), because the resolution starts in PreRunE before RunE. With PreRunE cleared in instrumentCommand, it fails for update, list, version and uninstall. 8643a8e

Comment thread cmd/aitools/telemetry.go Outdated
// funnelCancelled records a run the user cancelled at a prompt while the
// command still returns nil (e.g. answering "no" to uninstall's confirm), so
// withCommandFunnel does not report it as SUCCESS. No-op outside a funnel.
func funnelCancelled(ctx context.Context) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] funnelCancelled, funnelAllAttemptsFailed, funnelNothingToDo, funnelSetScope and funnelSetAgents each repeat the same ctx.Value(funnelKey{}) assertion plus a no-op branch. Every caller runs inside instrumentCommand, and only the NotPanics test (funnel_test.go:500) uses the fallback. AGENTS.md asks to avoid speculative fallbacks. Replace them with one funnelFrom(ctx) *commandFunnel accessor and call the existing methods (finishWith, setScope, setAgents) on it.

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.

Replaced the five helpers with one funnelFrom(ctx) *commandFunnel accessor. Callers use finishWith, setScope and setAgents on it directly, and uninstall builds the agentPlanItems in the loop that already collects the agent names. The no-op fallback and its NotPanics test are gone. 8643a8e

Comment thread libs/aitools/installer/uninstall.go Outdated
// We can't tear down an agent we don't know; keep the record (and
// the state file) rather than silently dropping a live plugin.
log.Warnf(ctx, "Leaving plugin record for unknown agent %q; remove its plugin manually", name)
pluginFailures = append(pluginFailures, fmt.Errorf("unknown agent %q in state", name))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] A plugin record for an agent this CLI does not know (for example one written by a newer CLI) is recorded as a plain fmt.Errorf("unknown agent %q in state"). A run whose only record is that one finishes FAILURE:UNCATEGORIZED, although UNKNOWN_AGENT exists. The same literal is repeated in UpdateInstalledPlugins (update.go:460). Add one typed or sentinel installer error for it, and map it to UNKNOWN_AGENT in classifyInstallError.

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.

Added installer.ErrUnknownAgent, which both UninstallSkillsWithResult and UpdateInstalledPlugins use through one unknownAgentInStateError(name) (same text: unknown agent "<name>" in state). classifyInstallError maps it to UNKNOWN_AGENT. It has a TestClassifyInstallError case and ErrorIs assertions in TestPluginOperationsReportUnknownAgent. 8643a8e

Comment thread cmd/aitools/update.go Outdated
}
// The scope enum has no "both"; a run over both scopes reports none.
if len(scopes) == 1 {
funnelSetScope(ctx, scopes[0])

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] funnelSetScope runs only after resolveScopeForUpdate/resolveScopeForUninstall succeed. update --scope=project with no project install returns nothingInstalledInScopeError first, so its FINISHED NOTHING_TO_DO has no scope, while update --scope=global in the same situation reports GLOBAL. Splitting nothing-to-do runs by scope therefore undercounts project. list never sets a scope even when --scope picks one. Set the scope from an explicit --scope/--project/--global before resolving, and in list once it picks one.

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.

update and uninstall now record a single scope chosen by --scope/--project/--global right after parseScopeFlag, before resolving, and then the resolved scope. So update --scope=project with no project install reports PROJECT (asserted in TestNothingInstalledFinishesNothingToDo). list records the scope it picks. The single-scope switch that list had is now explicitScope(projectFlag, globalFlag), which all three commands share; TestListFunnelFailure asserts GLOBAL for --scope=global. 8643a8e

Comment thread cmd/aitools/telemetry_auth_test.go Outdated
Comment on lines +302 to +304
// runWithBlockedTelemetryAuth runs cmd with args, with telemetry enabled and
// its events buffered, while the auth resolution is blocked, and returns its error, how long it took and the context it ended
// with.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] The runWithBlockedTelemetryAuth comment has a line about 130 characters long followed by a one-word line ("with."). Rewrap it to match the surrounding comments.

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.

Rewrapped. The helper is now runWithTelemetry, since the resolve-auth test also uses it without a blocked resolver. 8643a8e

@anton-107
anton-107 force-pushed the aitools/command-funnel-telemetry branch from 6cd41f6 to 8643a8e Compare November 11, 2026 23:50
@anton-107

Copy link
Copy Markdown
Contributor Author

Replies to the review body, in 8643a8e:

Outside the diff (telemetry.go:125): Right: a run that does not wait for auth still uploads, with the upload's own timeout, when the resolution has already finished. I kept that behavior and fixed the text instead. The changelog fragment now says list, version and runs that stop early can take up to about 3 seconds longer on a slow or unreachable workspace. Merge Danger and libs/telemetry/protos/README.md say the same.

Question 1: I'm keeping list and version instrumented as they are now, with no wait. Their events arrive whenever auth has already resolved when the command finishes, for example with a PAT or a valid cached token and cached host metadata. That is the common case for users who have logged in. A separate, shorter upload budget for runs that do not wait can follow if the data shows it is needed.

Question 2: I'm keeping the current shape (UninstallSkillsWithResult) and keeping it in this PR. The new name makes the new result explicit at the call site, and the changes to the test call sites are mechanical.

@anton-107
anton-107 force-pushed the aitools/install-funnel-telemetry branch from 3b60b60 to 54514a6 Compare November 11, 2026 23:52
@anton-107
anton-107 force-pushed the aitools/command-funnel-telemetry branch from 8643a8e to 4653c00 Compare November 11, 2026 23:57
@anton-107
anton-107 force-pushed the aitools/install-funnel-telemetry branch from 54514a6 to f6a8dd1 Compare November 12, 2026 00:05
@anton-107
anton-107 force-pushed the aitools/command-funnel-telemetry branch from 4653c00 to cf15bb3 Compare November 12, 2026 00:05
@anton-107
anton-107 force-pushed the aitools/install-funnel-telemetry branch from f6a8dd1 to 8ac74ab Compare November 12, 2026 00:15
@anton-107
anton-107 force-pushed the aitools/command-funnel-telemetry branch 2 times, most recently from 9dac556 to e5b0edd Compare November 12, 2026 00:27

@simonfaltum simonfaltum left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Automated review from simonfaltum's review agent (pr-review skill) at e5b0edd. Each finding was verified against the code; simonfaltum may follow up.

Not approving yet: 2 open design questions.

This PR extends the aitools_command_event funnel and best-effort background auth from install to update, list, version and uninstall. It also adds installer results so that runs that only warned are reported as FAILURE and runs with nothing installed as NOTHING_TO_DO. Every fix from the earlier rounds is in this head, CI is green, and nothing is blocking. The main open point is that runs with no single scope send "scope":"TYPE_UNSPECIFIED" on FINISHED instead of omitting it; the rest are small outcome-classification and comment nits, and the two earlier design questions are restated for a final answer.

Questions

  1. aitools list and aitools version now start a background workspace auth resolution on every run. Before this PR neither contacted the workspace. Their events upload only when auth has already resolved, and in that case exit can take up to 3 s longer on a slow workspace. Your earlier replies keep both instrumented. Is that final for this PR, or would you drop the funnel from version, whose FINISHED event is almost always SUCCESS, or from both?
  2. Part of this diff changes the installer API: UninstallSkillsWithResult replaces UninstallSkills/UninstallSkillsOpts, and the PR adds PluginUpdate.Err, UpdateResult.CheckErr, the removeAllFn seam and a sorted full-uninstall order. All of these exist to classify funnel outcomes. Your earlier replies keep them here under the new name. Is that final, or would you move them into a small PR below this one?

Comments outside the changed lines

  • [nit] libs/telemetry/protos/aitools_events.go:146: The struct comment now covers all five commands, but Agents and Scope still say "Install only", and Scope says it is set "after the scope is resolved". uninstall now sets Agents from --agents. update, list and uninstall set Scope, and update and uninstall set it from the flag before resolving. Update both field comments.
  • [nit] libs/aitools/installer/plugin_test.go:244: UninstallSkillsOpts is gone, but TestUninstallSkillsOptsTearsDownPlugin (here), TestUninstallSkillsOptsTargetsPluginAgents (line 275) and TestUninstallSkillsOptsUsesRecordedClaudeScope (line 577) still carry its name. A search for the function therefore finds tests of something else. Rename them after UninstallSkillsWithResult, or drop the function name.
  • [nit] .github/PULL_REQUEST_TEMPLATE.md: The description uses ## Summary, ## Evidence and ## Merge Danger. .agents/skills/pr-checklist/SKILL.md asks for the template's ## Changes, ## Why and ## Tests headings, in that order, with no new sections; most merged PRs here, including #7442, use them. Move the content under those headings; the Merge Danger notes fit under ## Why, and the evidence under ## Tests.

Agreeing with existing threads

  • [nit] Targeted uninstall still deletes the canonical dir after a failed entry removal: #7470 (comment)

Comment thread cmd/aitools/list.go
scope = installer.ScopeGlobal
}
scope := explicitScope(projectFlag, globalFlag)
funnelFrom(cmd.Context()).setScope(scope)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nice to have] setScope("") stores scopeType(""), which is the non-empty sentinel TYPE_UNSPECIFIED, so omitempty keeps it. A plain aitools list or list --scope=both, an update over both scopes, and update/uninstall runs that fail before a scope resolves (update.go:74, uninstall.go:38) all send "scope":"TYPE_UNSPECIFIED" on FINISHED, while STARTED in the same run has no scope. Running the head with no flags gives {"command":"LIST","stage":"FINISHED","outcome":"SUCCESS","interactive":false,"scope":"TYPE_UNSPECIFIED"}. That contradicts the comment at update.go:80 ("reports none") and the way finishWith drops the matching error-category sentinel. Leave f.scope empty when scope == "" in setScope (telemetry.go:330), and add a plain-list case to the funnel tests.

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.

setScope("") now leaves the field unset, so plain list, list --scope=both, an update over both scopes and the runs that fail before a scope resolves omit scope on FINISHED, as STARTED does. TestListFunnelFailure now covers --scope=global (GLOBAL), no flag and --scope=both (no scope), and fails without the change. e1c95af

Comment thread libs/aitools/installer/uninstall.go Outdated
removeSymlinksFromAgents(ctx, name, canonicalDir, scope, cwd)
}
if removeCanonical {
// RemoveAll succeeds on a missing dir; only an existing one counts.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] On a full uninstall, a skill counts in SkillsRemoved only if its canonical dir existed before removeAllFn. Take a state that lists skills whose dirs are already gone (the TestUninstallHandlesMissingDirectories setup). The run removes their agent links and the state file and prints Uninstalled 2 skills., but aitools uninstall finishes NOTHING_TO_DO, because both counts are 0 and there are no failures. In the non-targeted path, count the skill whenever removeAllFn succeeds, since its state record is dropped either way.

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.

A full uninstall now counts a skill whenever removeAllFn succeeds, since its state record is dropped either way. With --agents only an existing canonical dir or entry counts, as before. The stale-state case in TestUninstallCountsRemovedSkills now expects 1. e1c95af

Comment thread cmd/aitools/uninstall.go
// confirm above do not, nor does a run that only reports that nothing
// (or a legacy install) is there.
if state != nil {
waitForTelemetryAuth(ctx)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] waitForTelemetryAuth is armed once the scope has state, before UninstallSkillsWithResult checks --skills against that state. So aitools uninstall --skills=typo with something installed waits for the auth budget (up to 3 s, plus up to 3 s for the upload if auth resolves late) before it prints skill "typo" is not installed. With a blocked resolver and a 2 s budget, the head takes 2.0 s. libs/telemetry/protos/README.md says runs that end at a flag error do not wait. Check that every opts.Skills name is in state.Skills before calling waitForTelemetryAuth; the installer still returns the error itself.

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.

uninstall now calls waitForTelemetryAuth only when every --skills name is in the loaded state (new skillsInstalled). The installer still returns the skill "typo" is not installed error itself. New case in TestInstrumentedCommandsDoNotWaitForTelemetryAuth with one skill recorded and --skills=typo; it fails without the check. e1c95af

Comment thread libs/aitools/installer/uninstall.go Outdated
if state == nil {
if scope == ScopeGlobal && hasLegacyInstall(ctx, baseDir) {
return errors.New("found skills from a previous install without state tracking; run 'databricks aitools install' first, then uninstall")
return nil, errors.New("found skills from a previous install without state tracking; run 'databricks aitools install' first, then uninstall")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] On a pre-state (legacy) install, update and uninstall return a plain errors.New here and at update.go:87. Their FINISHED events are therefore FAILURE/UNCATEGORIZED, which is the bucket for failures the CLI has not classified. This is a known, deterministic outcome that the new funnels will see often during migration, while the sibling no-state case got ErrNothingInstalled. Tag both errors with a category, for example withCategory(…, STATE_INVALID) (no code uses that category yet) or a sentinel the command layer maps.

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.

Added installer.ErrLegacyInstall, which both errors wrap (fmt.Errorf("%w; run ..."), same text). classifyInstallError maps it to STATE_INVALID, since the install state these commands need is missing. Covered by a TestClassifyInstallError case and EqualError + ErrorIs in TestUpdateLegacyInstallDetected and a new TestUninstallLegacyInstallDetected. e1c95af

Comment thread cmd/aitools/telemetry.go Outdated
// resolution within telemetryAuthBudget, as install does past its plan
// confirmation. No-op outside an instrumented command.
func waitForTelemetryAuth(ctx context.Context) {
if auth, ok := ctx.Value(telemetryAuthKey{}).(*telemetryAuth); ok {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] Both callers (update.go:116, uninstall.go:100) run inside instrumentCommand and have already called funnelFrom(ctx), which panics outside it, so the !ok path never runs. The last round removed the same no-op fallback from the funnel helpers, per AGENTS.md ("Avoid speculative fallbacks and default values"). Assert the type the way funnelFrom does, and drop "No-op outside an instrumented command" from the doc comment.

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.

waitForTelemetryAuth now asserts the type directly, like funnelFrom, and its doc says only an instrumented command's RunE calls it. e1c95af

Comment thread libs/aitools/installer/errors.go Outdated

// SkillError reports that a skill named via --skills could not be resolved from
// the manifest. Reason drives telemetry categorization (the command layer maps
// the manifest or, for uninstall, is not installed. Reason drives telemetry categorization (the command layer maps

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] The inserted text leaves this line at 115 characters in the middle of the paragraph. Rewrap the SkillError doc comment to the width of the lines around it.

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.

Rewrapped the SkillError doc comment to the width of the surrounding lines. e1c95af

@anton-107
anton-107 force-pushed the aitools/command-funnel-telemetry branch from e5b0edd to e1c95af Compare November 12, 2026 09:57
@anton-107
anton-107 force-pushed the aitools/install-funnel-telemetry branch from 8ac74ab to a8819ec Compare November 12, 2026 09:58
@anton-107

Copy link
Copy Markdown
Contributor Author

Replies to the review body, in e1c95af:

Question 1: Final for this PR: list and version stay instrumented, with no wait for auth. Their events arrive whenever auth has already resolved when they finish, for example with a cached token, which is the common case for users who have logged in. A separate, shorter upload budget for runs that do not wait can follow if the data shows it is needed.

Question 2: Final: the installer changes stay in this PR under the current names. UninstallSkillsWithResult makes the new result explicit at the call site, and the changes to the test call sites are mechanical.

Outside the diff:

  • aitools_events.go: the Agents comment now says install and uninstall (from --agents). The Scope comment now names install, update, list and uninstall, says the field is set once a scope flag selects one or the scope is resolved, and says it is omitted when none or both are selected.
  • plugin_test.go: the three tests are renamed TestUninstallSkillsWithResultTearsDownPlugin, ...TargetsPluginAgents and ...UsesRecordedClaudeScope; no UninstallSkillsOpts name is left.
  • PR template: I kept the current layout. All 16 PRs in this stack use the same Summary/Evidence/Merge Danger sections, so a reviewer finds the same things in the same place on each of them. The content maps one to one: Summary is the changes, Merge Danger is the why and the risk, Evidence is the tests.
  • Canonical dir after a failed targeted removal: unchanged, as answered in that thread. The deletion predates this PR, and this PR only stops counting that case as removed.

@anton-107
anton-107 force-pushed the aitools/install-funnel-telemetry branch from a8819ec to 9f73e89 Compare November 12, 2026 10:14
@anton-107
anton-107 force-pushed the aitools/command-funnel-telemetry branch from e1c95af to 1a67570 Compare November 12, 2026 10:20
@anton-107
anton-107 force-pushed the aitools/install-funnel-telemetry branch from 9f73e89 to 2241742 Compare November 12, 2026 10:23
`aitools update`, `list`, `version` and `uninstall` now log the same `aitools_command_event` funnel as `aitools install` (STARTED, then FINISHED with the outcome and error category) and resolve auth best-effort in the background so the events can upload at exit. `list` and `version` never wait for that resolution at exit, and neither do `update` and `uninstall` runs that end before their work (flag and scope errors, a declined confirm, nothing installed, a `--skills` name that is not installed), as for `install` before its plan is confirmed; their events upload only if auth has already resolved.

```diff
 NewUpdateCmd / NewListCmd / NewVersionCmd / NewUninstallCmd
-  return cmd
+  return instrumentCommand(cmd, protos.AitoolsCommand<X>)
+    cmd.RunE = withCommandFunnel(...)  // STARTED; FINISHED from RunE's error
+    withTelemetryAuth(cmd)             // background auth; no wait at exit unless
+                                       // RunE calls waitForTelemetryAuth (update and
+                                       // uninstall, once a scope has state)
```

```text
finish(err)
  nil                                  -> SUCCESS
  errors.Is(err, ErrNothingInstalled)  -> NOTHING_TO_DO  (still exits 1 with the same text)
  prompt aborted / no agents picked    -> CANCELLED
  otherwise                            -> FAILURE, classifyInstallError(err)
                                          (scope flag errors tagged INVALID_FLAGS, as in install)

RunE returns nil, but
  uninstall confirm answered "no"                      -> CANCELLED
  uninstall removed nothing and nothing failed         -> NOTHING_TO_DO
  every plugin/skill update or removal attempted       -> FAILURE with the first error's category
    failed and nothing else succeeded                     (still exits 0)
  (via funnelFrom(ctx).finishWith)
```

Installer additions: `ErrNothingInstalled` (same error text as before), `PluginUpdate.Err` (failed plugins are returned after the updated ones; `update` prints only the updated ones), `UpdateResult.CheckErr` (the manifest fetch error a `--check` run only warns about), and `UninstallSkillsWithResult` returning `UninstallResult{PluginsRemoved, SkillsRemoved, PluginFailures, SkillFailures}`, which replaces `UninstallSkills` and `UninstallSkillsOpts`. A plugin record for an agent this CLI does not know counts as a failed update or removal (`ErrUnknownAgent`, UNKNOWN_AGENT), and a pre-state (legacy) install returns `ErrLegacyInstall` (same text, STATE_INVALID). `uninstall --skills` with a skill that is not installed returns a `SkillError` (same text). Full uninstall now removes skills in sorted order.

Changelog: `.nextchanges/cli/aitools-telemetry-funnel.md`.

Co-authored-by: Isaac <no-reply@databricks.com>
@anton-107
anton-107 force-pushed the aitools/command-funnel-telemetry branch from 1a67570 to f379423 Compare November 12, 2026 10:23

@simonfaltum simonfaltum left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Approval from simonfaltum's review agent at f379423, on Simon's decision.

The design questions from the earlier automated reviews no longer block this PR: your answers are accepted, and any still-open ones are non-blocking. The changes since the last automated review (e5b0edd → f379423) address its comments.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ai-auto PRs made by AI loops & factories,

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants