diff --git a/.github/workflows/verify-aspire-skills-bundle.yml b/.github/workflows/verify-aspire-skills-bundle.yml
index db82170a3dc..294074a7a7b 100644
--- a/.github/workflows/verify-aspire-skills-bundle.yml
+++ b/.github/workflows/verify-aspire-skills-bundle.yml
@@ -5,7 +5,10 @@ on:
pull_request:
paths:
- 'src/Aspire.Cli/Agents/AspireSkills/Embedded/**'
+ - 'src/Aspire.Cli/Agents/Hooks/track-telemetry.sh'
+ - 'src/Aspire.Cli/Agents/Hooks/track-telemetry.ps1'
- 'eng/scripts/verify-aspire-skills-bundle.ps1'
+ - 'eng/scripts/aspire-skills-bundle.common.ps1'
- '.github/workflows/verify-aspire-skills-bundle.yml'
permissions:
diff --git a/eng/scripts/aspire-skills-bundle.common.ps1 b/eng/scripts/aspire-skills-bundle.common.ps1
new file mode 100644
index 00000000000..ab635e58e26
--- /dev/null
+++ b/eng/scripts/aspire-skills-bundle.common.ps1
@@ -0,0 +1,99 @@
+#!/usr/bin/env pwsh
+
+# Shared helpers for syncing and verifying the embedded Aspire telemetry hook scripts
+# (track-telemetry.sh / track-telemetry.ps1).
+#
+# The hook scripts live canonically in microsoft/aspire-skills under hooks/scripts/. They are SOURCE
+# files (not build outputs), so they are pinned to the immutable commit that an aspire-skills release
+# tag points at and fetched through the GitHub contents API. Hashing is always done over
+# LF-normalized UTF-8 (no BOM) content so the recorded hash is stable regardless of the checkout's
+# line-ending policy: .sh is `eol=lf` while .ps1 is `text=auto` (checked out CRLF on Windows).
+
+Set-StrictMode -Version Latest
+
+$script:AspireSkillsHookFileNames = @('track-telemetry.sh', 'track-telemetry.ps1')
+$script:AspireSkillsHookRepoDirectory = 'hooks/scripts'
+
+function Get-AspireSkillsHookFileNames {
+ return $script:AspireSkillsHookFileNames
+}
+
+function Invoke-AspireSkillsGitHubApi {
+ param([Parameter(Mandatory = $true)][string]$Endpoint)
+
+ $result = & gh api $Endpoint
+ if ($LASTEXITCODE -ne 0) {
+ throw "gh api $Endpoint failed with exit code $LASTEXITCODE."
+ }
+
+ return $result
+}
+
+function ConvertTo-LfUtf8Bytes {
+ param([Parameter(Mandatory = $true)][AllowEmptyCollection()][byte[]]$Bytes)
+
+ # Strip a UTF-8 BOM if present, then normalize CRLF/CR to LF. A BOM or CRLF in track-telemetry.sh
+ # would break the shebang/shell on POSIX hosts, and normalizing makes the recorded hash independent
+ # of how git checked the file out.
+ $text = [System.Text.UTF8Encoding]::new($false).GetString($Bytes)
+ if ($text.Length -gt 0 -and $text[0] -eq [char]0xFEFF) {
+ $text = $text.Substring(1)
+ }
+
+ $text = $text -replace "`r`n", "`n" -replace "`r", "`n"
+ return [System.Text.UTF8Encoding]::new($false).GetBytes($text)
+}
+
+function Get-AspireSkillsSha256Hex {
+ param([Parameter(Mandatory = $true)][AllowEmptyCollection()][byte[]]$Bytes)
+
+ $sha = [System.Security.Cryptography.SHA256]::Create()
+ try {
+ return [System.BitConverter]::ToString($sha.ComputeHash($Bytes)).Replace('-', '').ToLowerInvariant()
+ }
+ finally {
+ $sha.Dispose()
+ }
+}
+
+function Get-AspireSkillsReleaseCommitSha {
+ param(
+ [Parameter(Mandatory = $true)][string]$Repository,
+ [Parameter(Mandatory = $true)][string]$Tag
+ )
+
+ # Resolve the tag to the commit it points at (dereferences annotated tags). Pinning to the commit
+ # rather than the tag means a force-moved tag cannot silently change what gets synced or verified.
+ $sha = (Invoke-AspireSkillsGitHubApi "repos/$Repository/commits/$Tag" | ConvertFrom-Json).sha
+ if ([string]::IsNullOrWhiteSpace($sha)) {
+ throw "Could not resolve a commit SHA for tag '$Tag' in '$Repository'."
+ }
+
+ return $sha
+}
+
+function Get-AspireSkillsHookContent {
+ # Fetch a single hook script from aspire-skills at an immutable commit and return its
+ # LF-normalized UTF-8 bytes.
+ param(
+ [Parameter(Mandatory = $true)][string]$Repository,
+ [Parameter(Mandatory = $true)][string]$CommitSha,
+ [Parameter(Mandatory = $true)][string]$FileName
+ )
+
+ $path = "$script:AspireSkillsHookRepoDirectory/$FileName"
+ $endpoint = "repos/$Repository/contents/$path" + "?ref=$CommitSha"
+ $response = Invoke-AspireSkillsGitHubApi $endpoint | ConvertFrom-Json
+
+ if ($response.type -ne 'file') {
+ throw "Aspire skills hook '$path' at commit '$CommitSha' is not a file (type '$($response.type)')."
+ }
+
+ if ($response.name -ne $FileName) {
+ throw "Aspire skills hook response name '$($response.name)' did not match expected '$FileName'."
+ }
+
+ # The contents API returns base64 with embedded newlines; strip all whitespace before decoding.
+ $rawBytes = [System.Convert]::FromBase64String(($response.content -replace '\s', ''))
+ return ConvertTo-LfUtf8Bytes -Bytes $rawBytes
+}
diff --git a/eng/scripts/update-aspire-skills-bundle.ps1 b/eng/scripts/update-aspire-skills-bundle.ps1
index 94c2212c641..73eeda08c15 100644
--- a/eng/scripts/update-aspire-skills-bundle.ps1
+++ b/eng/scripts/update-aspire-skills-bundle.ps1
@@ -16,6 +16,9 @@ $embeddedDir = Join-Path $repoRoot 'src\Aspire.Cli\Agents\AspireSkills\Embedded'
$metadataPath = Join-Path $embeddedDir 'aspire-skills.metadata.json'
$installerPath = Join-Path $repoRoot 'src\Aspire.Cli\Agents\AspireSkills\AspireSkillsInstaller.cs'
$cliProjectPath = Join-Path $repoRoot 'src\Aspire.Cli\Aspire.Cli.csproj'
+$hooksDir = Join-Path $repoRoot 'src\Aspire.Cli\Agents\Hooks'
+
+. (Join-Path $scriptDir 'aspire-skills-bundle.common.ps1')
function Invoke-GitHubCli {
param(
@@ -132,6 +135,33 @@ try {
Copy-Item -Path $archivePath -Destination $targetArchivePath -Force
+ # Sync the telemetry hook scripts from the same release. Hooks are SOURCE files in aspire-skills
+ # (hooks/scripts/track-telemetry.{sh,ps1}), so they are pinned to the immutable commit the release
+ # tag points at and fetched via the contents API (see aspire-skills-bundle.common.ps1). Releases
+ # that predate the telemetry hooks feature do not contain hooks/scripts/*, so a missing hook is a
+ # warning + skip during the transition rather than a hard failure of the whole bundle update;
+ # verification only enforces hooks once they are recorded in metadata.
+ New-Item -ItemType Directory -Force -Path $hooksDir | Out-Null
+ $hookMetadata = $null
+ try {
+ $hookCommitSha = Get-AspireSkillsReleaseCommitSha -Repository $Repository -Tag $release.tagName
+ $hookHashes = [ordered]@{}
+ foreach ($hookFileName in Get-AspireSkillsHookFileNames) {
+ Write-Host "Syncing hook script '$hookFileName' from '$Repository' at commit '$hookCommitSha'..."
+ $hookBytes = Get-AspireSkillsHookContent -Repository $Repository -CommitSha $hookCommitSha -FileName $hookFileName
+ [System.IO.File]::WriteAllBytes((Join-Path $hooksDir $hookFileName), $hookBytes)
+ $hookHashes[$hookFileName] = Get-AspireSkillsSha256Hex -Bytes $hookBytes
+ }
+
+ $hookMetadata = [ordered]@{
+ commitSha = $hookCommitSha
+ files = $hookHashes
+ }
+ }
+ catch {
+ Write-Warning "Skipping telemetry hook sync for release '$($release.tagName)': $($_.Exception.Message)"
+ }
+
$metadata = [ordered]@{
version = $normalizedVersion
repository = $Repository
@@ -139,7 +169,10 @@ try {
assetName = $asset.name
sha256 = $hash
}
- Set-TextFile -Path $metadataPath -Content ($metadata | ConvertTo-Json)
+ if ($null -ne $hookMetadata) {
+ $metadata['hooks'] = $hookMetadata
+ }
+ Set-TextFile -Path $metadataPath -Content ($metadata | ConvertTo-Json -Depth 10)
$installerContent = Get-Content -Raw -Path $installerPath
$installerContent = [regex]::Replace(
diff --git a/eng/scripts/verify-aspire-skills-bundle.ps1 b/eng/scripts/verify-aspire-skills-bundle.ps1
index 550a5e34b03..a06c3d0df7f 100644
--- a/eng/scripts/verify-aspire-skills-bundle.ps1
+++ b/eng/scripts/verify-aspire-skills-bundle.ps1
@@ -13,6 +13,9 @@ $scriptDir = $PSScriptRoot
$repoRoot = (Resolve-Path (Join-Path $scriptDir '..\..')).Path
$embeddedDir = Join-Path $repoRoot 'src\Aspire.Cli\Agents\AspireSkills\Embedded'
$metadataPath = Join-Path $embeddedDir 'aspire-skills.metadata.json'
+$hooksDir = Join-Path $repoRoot 'src\Aspire.Cli\Agents\Hooks'
+
+. (Join-Path $scriptDir 'aspire-skills-bundle.common.ps1')
if (-not (Get-Command gh -ErrorAction SilentlyContinue)) {
throw "The GitHub CLI ('gh') is required to verify the embedded Aspire skills bundle."
@@ -61,3 +64,46 @@ gh attestation verify $archivePath `
--cert-oidc-issuer 'https://token.actions.githubusercontent.com'
Write-Host "Embedded Aspire skills bundle '$($metadata.assetName)' verified against GitHub artifact attestation."
+
+# Verify the embedded telemetry hook scripts when the bundle records them. The hooks block is only
+# present once update-aspire-skills-bundle.ps1 has synced hooks from a release that contains them, so
+# older bundles (which predate the feature) skip this check. When present, cross-check both that the
+# embedded file matches the recorded hash AND that the recorded hash matches the canonical source at
+# the pinned aspire-skills commit, so a hand-edit that also updates the metadata hash cannot pass.
+if ($metadata.PSObject.Properties.Name -contains 'hooks') {
+ $hooks = $metadata.hooks
+
+ if ([string]::IsNullOrWhiteSpace($hooks.commitSha)) {
+ throw "Embedded Aspire skills metadata 'hooks' block must specify the aspire-skills commit SHA the hooks were pinned to."
+ }
+
+ if (-not ($hooks.PSObject.Properties.Name -contains 'files')) {
+ throw "Embedded Aspire skills metadata 'hooks' block must record a 'files' map of hook hashes."
+ }
+
+ foreach ($hookFileName in Get-AspireSkillsHookFileNames) {
+ if (-not ($hooks.files.PSObject.Properties.Name -contains $hookFileName)) {
+ throw "Embedded Aspire skills metadata 'hooks' block is missing a recorded hash for '$hookFileName'."
+ }
+
+ $recordedHash = $hooks.files.$hookFileName
+
+ $embeddedHookPath = Join-Path $hooksDir $hookFileName
+ if (-not (Test-Path $embeddedHookPath)) {
+ throw "Embedded telemetry hook script was not found at '$embeddedHookPath'."
+ }
+
+ # Hash over LF-normalized bytes so .ps1 (text=auto) checked out with CRLF on Windows matches.
+ $embeddedHash = Get-AspireSkillsSha256Hex -Bytes (ConvertTo-LfUtf8Bytes -Bytes ([System.IO.File]::ReadAllBytes($embeddedHookPath)))
+ if ($embeddedHash -ne $recordedHash) {
+ throw "Embedded telemetry hook '$hookFileName' SHA-256 mismatch. Expected '$recordedHash', got '$embeddedHash'. Re-run update-aspire-skills-bundle.ps1."
+ }
+
+ $sourceHash = Get-AspireSkillsSha256Hex -Bytes (Get-AspireSkillsHookContent -Repository $metadata.repository -CommitSha $hooks.commitSha -FileName $hookFileName)
+ if ($sourceHash -ne $recordedHash) {
+ throw "Telemetry hook '$hookFileName' does not match '$($metadata.repository)' at commit '$($hooks.commitSha)'. Expected '$recordedHash', got '$sourceHash'."
+ }
+ }
+
+ Write-Host "Embedded telemetry hook scripts verified against '$($metadata.repository)' at commit '$($hooks.commitSha)'."
+}
diff --git a/src/Aspire.Cli/Agents/AgentClientKind.cs b/src/Aspire.Cli/Agents/AgentClientKind.cs
new file mode 100644
index 00000000000..711b1bdce74
--- /dev/null
+++ b/src/Aspire.Cli/Agents/AgentClientKind.cs
@@ -0,0 +1,22 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Aspire.Cli.Agents;
+
+///
+/// Identifies an agent client (CLI/editor) that Aspire can configure during aspire agent init.
+///
+internal enum AgentClientKind
+{
+ /// GitHub Copilot CLI.
+ CopilotCli,
+
+ /// Anthropic Claude Code.
+ ClaudeCode,
+
+ /// Visual Studio Code.
+ VsCode,
+
+ /// OpenCode.
+ OpenCode,
+}
diff --git a/src/Aspire.Cli/Agents/AgentEnvironmentScanContext.cs b/src/Aspire.Cli/Agents/AgentEnvironmentScanContext.cs
index efc8f887b0f..64b7c0200e5 100644
--- a/src/Aspire.Cli/Agents/AgentEnvironmentScanContext.cs
+++ b/src/Aspire.Cli/Agents/AgentEnvironmentScanContext.cs
@@ -10,6 +10,7 @@ internal sealed class AgentEnvironmentScanContext
{
private readonly List _applicators = [];
private readonly HashSet _skillBaseDirectories = new(StringComparer.OrdinalIgnoreCase);
+ private readonly HashSet _detectedClients = [];
///
/// Gets the working directory being scanned.
@@ -58,4 +59,20 @@ public void AddSkillBaseDirectory(string relativeSkillBaseDir)
/// Gets the registered skill base directories for all detected agent environments.
///
public IReadOnlyCollection SkillBaseDirectories => _skillBaseDirectories;
+
+ ///
+ /// Records that an agent client was detected as present in the environment. Used to scope
+ /// telemetry hook registration to the clients the user actually has, independent of whether the
+ /// Aspire MCP server still needs configuring.
+ ///
+ /// The detected agent client.
+ public void AddDetectedClient(AgentClientKind client)
+ {
+ _detectedClients.Add(client);
+ }
+
+ ///
+ /// Gets the set of agent clients detected as present in the environment.
+ ///
+ public IReadOnlyCollection DetectedClients => _detectedClients;
}
diff --git a/src/Aspire.Cli/Agents/ClaudeCode/ClaudeCodeAgentEnvironmentScanner.cs b/src/Aspire.Cli/Agents/ClaudeCode/ClaudeCodeAgentEnvironmentScanner.cs
index b0a63edbad9..dff74645cea 100644
--- a/src/Aspire.Cli/Agents/ClaudeCode/ClaudeCodeAgentEnvironmentScanner.cs
+++ b/src/Aspire.Cli/Agents/ClaudeCode/ClaudeCodeAgentEnvironmentScanner.cs
@@ -55,6 +55,8 @@ public async Task ScanAsync(AgentEnvironmentScanContext context, CancellationTok
if (claudeCodeFolder is not null)
{
+ context.AddDetectedClient(AgentClientKind.ClaudeCode);
+
// If .claude folder is found, override the workspace root with its parent directory
var workspaceRoot = claudeCodeFolder.Parent ?? context.RepositoryRoot;
_logger.LogDebug("Inferred workspace root from .claude folder parent: {WorkspaceRoot}", workspaceRoot.FullName);
@@ -85,6 +87,8 @@ public async Task ScanAsync(AgentEnvironmentScanContext context, CancellationTok
{
_logger.LogDebug("Found Claude Code CLI version: {Version}", claudeCodeVersion);
+ context.AddDetectedClient(AgentClientKind.ClaudeCode);
+
// Claude Code is installed - offer to create config at workspace root
if (!HasAspireServerConfigured(context.RepositoryRoot))
{
diff --git a/src/Aspire.Cli/Agents/CopilotCli/CopilotCliAgentEnvironmentScanner.cs b/src/Aspire.Cli/Agents/CopilotCli/CopilotCliAgentEnvironmentScanner.cs
index f71f0fedfb8..3857f9a89c1 100644
--- a/src/Aspire.Cli/Agents/CopilotCli/CopilotCliAgentEnvironmentScanner.cs
+++ b/src/Aspire.Cli/Agents/CopilotCli/CopilotCliAgentEnvironmentScanner.cs
@@ -57,6 +57,8 @@ public async Task ScanAsync(AgentEnvironmentScanContext context, CancellationTok
{
_logger.LogDebug("Detected VSCode terminal environment. Assuming GitHub Copilot CLI is available to avoid potential hangs from interactive installation prompts.");
+ context.AddDetectedClient(AgentClientKind.CopilotCli);
+
// Check if the aspire server is already configured in the global config
_logger.LogDebug("Checking if Aspire MCP server is already configured in Copilot CLI global config...");
if (!HasAspireServerConfigured(homeDirectory))
@@ -89,6 +91,8 @@ public async Task ScanAsync(AgentEnvironmentScanContext context, CancellationTok
_logger.LogDebug("Found GitHub Copilot CLI version: {Version}", copilotVersion);
+ context.AddDetectedClient(AgentClientKind.CopilotCli);
+
// Check if the aspire server is already configured in the global config
_logger.LogDebug("Checking if Aspire MCP server is already configured in Copilot CLI global config...");
if (!HasAspireServerConfigured(homeDirectory))
diff --git a/src/Aspire.Cli/Agents/Hooks/HookCommandFormatter.cs b/src/Aspire.Cli/Agents/Hooks/HookCommandFormatter.cs
new file mode 100644
index 00000000000..e584053b1a3
--- /dev/null
+++ b/src/Aspire.Cli/Agents/Hooks/HookCommandFormatter.cs
@@ -0,0 +1,46 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Aspire.Cli.Agents.Hooks;
+
+///
+/// Builds the shell-form command strings used in the GitHub Copilot CLI hook configuration, with correct
+/// quoting for paths that may contain spaces or apostrophes. Claude Code hooks instead use exec form
+/// (command + args), which passes the script path as a discrete argument and needs no quoting.
+///
+internal static class HookCommandFormatter
+{
+ ///
+ /// Quotes a path as a single bash argument. Bash single quotes are literal, so an embedded
+ /// apostrophe is closed, escaped, and reopened: ' becomes '\''.
+ /// Example: /home/o'brien/x.sh → '/home/o'\''brien/x.sh'.
+ ///
+ public static string QuoteForBash(string path)
+ => $"'{path.Replace("'", "'\\''")}'";
+
+ ///
+ /// Quotes a path as a single PowerShell literal string. PowerShell single quotes are literal and
+ /// an embedded apostrophe is doubled: ' becomes ''.
+ /// Example: C:\Users\o'brien\x.ps1 → 'C:\Users\o''brien\x.ps1'.
+ ///
+ public static string QuoteForPowerShell(string path)
+ => $"'{path.Replace("'", "''")}'";
+
+ ///
+ /// Builds the Unix command that runs the shell hook script via bash. Running through bash
+ /// (rather than executing the path directly) avoids depending on the executable bit surviving.
+ ///
+ public static string BuildBashCommand(string shellScriptPath)
+ => $"bash {QuoteForBash(shellScriptPath)}";
+
+ ///
+ /// Builds the Windows command that runs the PowerShell hook script with PowerShell 7+ (pwsh).
+ /// The GitHub Copilot CLI hooks reference makes pwsh a hard Windows prerequisite, so the Copilot
+ /// hook must invoke it rather than Windows PowerShell; see
+ /// https://docs.github.com/en/copilot/how-tos/copilot-cli/customize-copilot/use-hooks. It is spawned
+ /// with an explicit bypass policy so the script runs regardless of the ambient execution policy, and
+ /// -NoProfile avoids profile side effects and startup cost.
+ ///
+ public static string BuildPwshCommand(string powerShellScriptPath)
+ => $"pwsh -NoProfile -ExecutionPolicy Bypass -File {QuoteForPowerShell(powerShellScriptPath)}";
+}
diff --git a/src/Aspire.Cli/Agents/Hooks/ITelemetryHookConfigurator.cs b/src/Aspire.Cli/Agents/Hooks/ITelemetryHookConfigurator.cs
new file mode 100644
index 00000000000..d9d129bfd28
--- /dev/null
+++ b/src/Aspire.Cli/Agents/Hooks/ITelemetryHookConfigurator.cs
@@ -0,0 +1,54 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Aspire.Cli.Agents.Hooks;
+
+///
+/// Registers the Aspire agent telemetry PostToolUse hooks into the user-level configuration of
+/// each detected agent client, mirroring the behavior of the azure-skills plugin hooks. Whether
+/// telemetry is actually transmitted remains gated by the telemetry opt-out environment variables;
+/// this only wires the hooks up.
+///
+internal interface ITelemetryHookConfigurator
+{
+ ///
+ /// Materializes the hook scripts and registers them for each supported, detected agent client.
+ ///
+ /// The agent clients detected during the environment scan.
+ /// A token to cancel the operation.
+ /// A summary of which clients were configured and which were skipped (and why).
+ Task ConfigureAsync(
+ IReadOnlyCollection detectedClients,
+ CancellationToken cancellationToken);
+}
+
+///
+/// The reason a telemetry hook could not be registered for a client.
+///
+internal enum TelemetryHookSkipReason
+{
+ /// The client's existing configuration file contained malformed JSON.
+ MalformedConfig,
+
+ /// The client's existing configuration had a hooks shape Aspire does not recognize.
+ UnexpectedConfigShape,
+
+ /// Writing the client's configuration failed.
+ WriteFailed,
+}
+
+///
+/// Describes a client whose telemetry hook registration was skipped.
+///
+/// The client that was skipped.
+/// Why registration was skipped.
+internal sealed record TelemetryHookSkip(AgentClientKind Client, TelemetryHookSkipReason Reason);
+
+///
+/// The outcome of .
+///
+/// Clients whose telemetry hook was registered or refreshed.
+/// Clients whose registration was skipped, with the reason.
+internal sealed record TelemetryHookConfigurationResult(
+ IReadOnlyList ConfiguredClients,
+ IReadOnlyList Skipped);
diff --git a/src/Aspire.Cli/Agents/Hooks/ITelemetryHookInstaller.cs b/src/Aspire.Cli/Agents/Hooks/ITelemetryHookInstaller.cs
new file mode 100644
index 00000000000..5314e53eb41
--- /dev/null
+++ b/src/Aspire.Cli/Agents/Hooks/ITelemetryHookInstaller.cs
@@ -0,0 +1,30 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Aspire.Cli.Agents.Hooks;
+
+///
+/// Materializes the embedded agent telemetry hook scripts to a stable location on disk so that
+/// per-client hook configuration written by aspire agent init can reference them by an
+/// absolute path.
+///
+internal interface ITelemetryHookInstaller
+{
+ ///
+ /// Ensures the track-telemetry.sh and track-telemetry.ps1 scripts exist under the
+ /// stable hooks directory (~/.aspire/hooks) with up-to-date content, and returns their
+ /// absolute paths. The shell script is written with LF line endings and the executable bit set
+ /// on non-Windows platforms. Re-running refreshes the content so an upgraded CLI updates the
+ /// scripts in place.
+ ///
+ /// A token to cancel the operation.
+ /// The resolved absolute paths to the materialized scripts.
+ Task EnsureInstalledAsync(CancellationToken cancellationToken);
+}
+
+///
+/// The absolute paths to the materialized agent telemetry hook scripts.
+///
+/// Absolute path to the materialized track-telemetry.sh.
+/// Absolute path to the materialized track-telemetry.ps1.
+internal sealed record TelemetryHookScripts(string ShellScriptPath, string PowerShellScriptPath);
diff --git a/src/Aspire.Cli/Agents/Hooks/TelemetryHookConfigurator.cs b/src/Aspire.Cli/Agents/Hooks/TelemetryHookConfigurator.cs
new file mode 100644
index 00000000000..f29ed802669
--- /dev/null
+++ b/src/Aspire.Cli/Agents/Hooks/TelemetryHookConfigurator.cs
@@ -0,0 +1,385 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using Microsoft.Extensions.Logging;
+
+namespace Aspire.Cli.Agents.Hooks;
+
+///
+/// Default . Materializes the hook scripts once and writes the
+/// PostToolUse hook into each supported client's user-level configuration.
+///
+///
+/// Only user-level configuration is ever written. The GitHub Copilot CLI hooks reference confirms that
+/// Copilot reads cross-tool .claude/settings.json only at the repository level (never ~/.claude),
+/// so the Copilot user hook (~/.copilot/hooks/aspire-telemetry.json) and the Claude user hook
+/// (~/.claude/settings.json) cannot both fire for the same event — the hook is registered exactly
+/// once per client by construction.
+/// See https://docs.github.com/en/copilot/reference/hooks-reference.
+///
+internal sealed class TelemetryHookConfigurator : ITelemetryHookConfigurator
+{
+ private const string CopilotFolderName = ".copilot";
+ private const string CopilotHooksDirectoryName = "hooks";
+ private const string CopilotHookFileName = "aspire-telemetry.json";
+ private const string CopilotHomeEnvironmentVariable = "COPILOT_HOME";
+
+ private const string ClaudeFolderName = ".claude";
+ private const string ClaudeSettingsFileName = "settings.json";
+ private const string ClaudePostToolUseKey = "PostToolUse";
+
+ private const int HookTimeoutSeconds = 30;
+
+ private readonly ITelemetryHookInstaller _installer;
+ private readonly CliExecutionContext _executionContext;
+ private readonly ILogger _logger;
+
+ public TelemetryHookConfigurator(
+ ITelemetryHookInstaller installer,
+ CliExecutionContext executionContext,
+ ILogger logger)
+ {
+ ArgumentNullException.ThrowIfNull(installer);
+ ArgumentNullException.ThrowIfNull(executionContext);
+ ArgumentNullException.ThrowIfNull(logger);
+ _installer = installer;
+ _executionContext = executionContext;
+ _logger = logger;
+ }
+
+ ///
+ public async Task ConfigureAsync(
+ IReadOnlyCollection detectedClients,
+ CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(detectedClients);
+
+ var configured = new List();
+ var skipped = new List();
+
+ // VS Code and OpenCode hook schemas are not yet verified, so they are intentionally not
+ // configured here even though they are detected/marked. Only configure once per client kind.
+ var supported = detectedClients
+ .Where(static c => c is AgentClientKind.CopilotCli or AgentClientKind.ClaudeCode)
+ .Distinct()
+ .ToList();
+
+ if (supported.Count == 0)
+ {
+ return new TelemetryHookConfigurationResult(configured, skipped);
+ }
+
+ // Materialize the scripts once; every supported client references the same absolute paths.
+ var scripts = await _installer.EnsureInstalledAsync(cancellationToken);
+
+ foreach (var client in supported)
+ {
+ switch (client)
+ {
+ case AgentClientKind.CopilotCli:
+ if (await TryConfigureCopilotAsync(scripts, cancellationToken))
+ {
+ configured.Add(client);
+ }
+ else
+ {
+ skipped.Add(new TelemetryHookSkip(client, TelemetryHookSkipReason.WriteFailed));
+ }
+ break;
+
+ case AgentClientKind.ClaudeCode:
+ var claudeSkipReason = await ConfigureClaudeAsync(scripts, cancellationToken);
+ if (claudeSkipReason is { } reason)
+ {
+ skipped.Add(new TelemetryHookSkip(client, reason));
+ }
+ else
+ {
+ configured.Add(client);
+ }
+ break;
+ }
+ }
+
+ return new TelemetryHookConfigurationResult(configured, skipped);
+ }
+
+ private async Task TryConfigureCopilotAsync(TelemetryHookScripts scripts, CancellationToken cancellationToken)
+ {
+ try
+ {
+ var hooksDirectory = ResolveCopilotHooksDirectory();
+ Directory.CreateDirectory(hooksDirectory);
+
+ var filePath = Path.Combine(hooksDirectory, CopilotHookFileName);
+
+ // Owned file: a full overwrite is trivially idempotent. The Copilot CLI hooks reference
+ // (https://docs.github.com/en/copilot/reference/hooks-reference) defines `bash` and
+ // `powershell` as keys whose values are shell command strings. The `powershell` value
+ // invokes `pwsh` (PowerShell 7+) because that is the documented Windows prerequisite for
+ // Copilot CLI hooks.
+ var config = new JsonObject
+ {
+ ["version"] = 1,
+ ["hooks"] = new JsonObject
+ {
+ ["postToolUse"] = new JsonArray(
+ new JsonObject
+ {
+ ["type"] = "command",
+ ["bash"] = HookCommandFormatter.BuildBashCommand(scripts.ShellScriptPath),
+ ["powershell"] = HookCommandFormatter.BuildPwshCommand(scripts.PowerShellScriptPath),
+ ["timeoutSec"] = HookTimeoutSeconds,
+ }),
+ },
+ };
+
+ await WriteJsonAtomicAsync(filePath, config, cancellationToken);
+ return true;
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ _logger.LogDebug(ex, "Failed to write Copilot CLI telemetry hook configuration.");
+ return false;
+ }
+ }
+
+ private async Task ConfigureClaudeAsync(TelemetryHookScripts scripts, CancellationToken cancellationToken)
+ {
+ var claudeDirectory = Path.Combine(_executionContext.HomeDirectory.FullName, ClaudeFolderName);
+ var settingsPath = Path.Combine(claudeDirectory, ClaudeSettingsFileName);
+
+ JsonObject settings;
+ if (File.Exists(settingsPath))
+ {
+ string content;
+ try
+ {
+ content = await File.ReadAllTextAsync(settingsPath, cancellationToken);
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ _logger.LogDebug(ex, "Failed to read Claude settings at {Path}.", settingsPath);
+ return TelemetryHookSkipReason.WriteFailed;
+ }
+
+ JsonNode? parsed;
+ try
+ {
+ parsed = JsonNode.Parse(content);
+ }
+ catch (JsonException ex)
+ {
+ // Never clobber a file we can't understand; leave it untouched and report the skip.
+ _logger.LogDebug(ex, "Claude settings at {Path} contained malformed JSON; skipping hook registration.", settingsPath);
+ return TelemetryHookSkipReason.MalformedConfig;
+ }
+
+ switch (parsed)
+ {
+ // An empty file or a literal `null` document: start from a fresh object.
+ case null:
+ settings = new JsonObject();
+ break;
+ case JsonObject existing:
+ settings = existing;
+ break;
+ // Root is valid JSON but not an object (array/string/number/bool): another tool owns this
+ // file in a shape we don't recognize. AsObject() would throw InvalidOperationException (not
+ // JsonException), escape the best-effort callers, and crash `agent init`. Skip instead.
+ default:
+ return TelemetryHookSkipReason.UnexpectedConfigShape;
+ }
+ }
+ else
+ {
+ settings = new JsonObject();
+ }
+
+ // `hooks` and its `PostToolUse` child must have the documented shapes; an unexpected shape means
+ // another tool owns the file, so skip rather than risk corrupting it.
+ JsonObject hooks;
+ if (settings.TryGetPropertyValue("hooks", out var hooksNode))
+ {
+ if (hooksNode is not JsonObject hooksObject)
+ {
+ return TelemetryHookSkipReason.UnexpectedConfigShape;
+ }
+
+ hooks = hooksObject;
+ }
+ else
+ {
+ hooks = new JsonObject();
+ settings["hooks"] = hooks;
+ }
+
+ JsonArray postToolUse;
+ if (hooks.TryGetPropertyValue(ClaudePostToolUseKey, out var postToolUseNode))
+ {
+ if (postToolUseNode is not JsonArray postToolUseArray)
+ {
+ return TelemetryHookSkipReason.UnexpectedConfigShape;
+ }
+
+ postToolUse = postToolUseArray;
+ }
+ else
+ {
+ postToolUse = new JsonArray();
+ hooks[ClaudePostToolUseKey] = postToolUse;
+ }
+
+ // Idempotent: drop any previously written Aspire entry before adding exactly one. This also
+ // refreshes the command if the script path changed across CLI upgrades.
+ RemoveExistingAspireEntries(postToolUse);
+
+ // Claude Code runs a path-referencing hook best in exec form (`command` + `args`): the executable
+ // is spawned directly with no shell, so the script path passes through verbatim with no quoting.
+ // Shell form is avoided because on Windows Claude runs the command line through Git Bash (or
+ // PowerShell only when Git Bash is absent), which would mismatch PowerShell-style path quoting. The
+ // Claude hooks reference recommends exec form for any hook that references a script path; see the
+ // "Exec form and shell form" / "Reference scripts by path" sections in
+ // https://docs.claude.com/en/docs/claude-code/hooks.
+ string command;
+ JsonArray commandArgs;
+ if (OperatingSystem.IsWindows())
+ {
+ // Use modern PowerShell 7+ (pwsh), consistent with the Copilot hook. pwsh is the documented
+ // Windows prerequisite for agent hooks; if it is absent the hook simply does not run, the same
+ // as Copilot. `-ExecutionPolicy Bypass` is passed straight to the process (exec form has no
+ // shell) so the local script runs regardless of the machine policy; `-NoProfile` avoids profile
+ // side effects and startup cost.
+ command = "pwsh";
+ commandArgs = new JsonArray("-NoProfile", "-ExecutionPolicy", "Bypass", "-File", scripts.PowerShellScriptPath);
+ }
+ else
+ {
+ command = "bash";
+ commandArgs = new JsonArray(scripts.ShellScriptPath);
+ }
+
+ postToolUse.Add((JsonNode?)new JsonObject
+ {
+ ["matcher"] = "*",
+ ["hooks"] = new JsonArray(
+ new JsonObject
+ {
+ ["type"] = "command",
+ ["command"] = command,
+ ["args"] = commandArgs,
+ // Bound the hook so a stuck telemetry call can never stall a Claude session. The shell
+ // scripts also self-limit, but Claude's own timeout is the reliable backstop.
+ ["timeout"] = HookTimeoutSeconds,
+ }),
+ });
+
+ try
+ {
+ Directory.CreateDirectory(claudeDirectory);
+ await WriteJsonAtomicAsync(settingsPath, settings, cancellationToken);
+ return null;
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ _logger.LogDebug(ex, "Failed to write Claude settings at {Path}.", settingsPath);
+ return TelemetryHookSkipReason.WriteFailed;
+ }
+ }
+
+ private string ResolveCopilotHooksDirectory()
+ {
+ // The Copilot CLI hooks reference resolves the user-level hooks directory from COPILOT_HOME when
+ // set, otherwise ~/.copilot/hooks. Mirror that so the hook lands where Copilot actually reads it.
+ var copilotHome = _executionContext.GetEnvironmentVariable(CopilotHomeEnvironmentVariable);
+ if (!string.IsNullOrEmpty(copilotHome))
+ {
+ return Path.Combine(copilotHome, CopilotHooksDirectoryName);
+ }
+
+ return Path.Combine(_executionContext.HomeDirectory.FullName, CopilotFolderName, CopilotHooksDirectoryName);
+ }
+
+ private static void RemoveExistingAspireEntries(JsonArray postToolUse)
+ {
+ // Iterate in reverse so removals don't shift indices we still need to visit. Remove individual
+ // Aspire hook entries (not whole groups) so a user-authored hook sharing a matcher group survives,
+ // then drop any group left empty by that removal.
+ for (var groupIndex = postToolUse.Count - 1; groupIndex >= 0; groupIndex--)
+ {
+ if (postToolUse[groupIndex] is not JsonObject group
+ || !group.TryGetPropertyValue("hooks", out var innerNode)
+ || innerNode is not JsonArray innerHooks)
+ {
+ continue;
+ }
+
+ for (var hookIndex = innerHooks.Count - 1; hookIndex >= 0; hookIndex--)
+ {
+ if (IsAspireHook(innerHooks[hookIndex]))
+ {
+ innerHooks.RemoveAt(hookIndex);
+ }
+ }
+
+ if (innerHooks.Count == 0)
+ {
+ postToolUse.RemoveAt(groupIndex);
+ }
+ }
+ }
+
+ private static bool IsAspireHook(JsonNode? node)
+ {
+ // Match the distinctive script file name (track-telemetry.sh/.ps1) wherever the entry carries the
+ // path: an `args` element of the current exec-form entry, or the `command` string of an earlier
+ // shell-form entry (this feature shipped only as exec form, but an in-progress dev build may have
+ // written shell form, so checking both keeps re-init idempotent). Matching the file name rather than
+ // just "aspire" avoids removing an unrelated user hook.
+ if (node is not JsonObject hook)
+ {
+ return false;
+ }
+
+ if (hook.TryGetPropertyValue("command", out var commandNode)
+ && commandNode is JsonValue commandValue
+ && commandValue.TryGetValue(out var command)
+ && ReferencesTelemetryScript(command))
+ {
+ return true;
+ }
+
+ if (hook.TryGetPropertyValue("args", out var argsNode) && argsNode is JsonArray args)
+ {
+ foreach (var arg in args)
+ {
+ if (arg is JsonValue argValue
+ && argValue.TryGetValue(out var argString)
+ && ReferencesTelemetryScript(argString))
+ {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ private static bool ReferencesTelemetryScript(string? value)
+ => value is not null
+ && (value.Contains("track-telemetry.sh", StringComparison.OrdinalIgnoreCase)
+ || value.Contains("track-telemetry.ps1", StringComparison.OrdinalIgnoreCase));
+
+ private static async Task WriteJsonAtomicAsync(string path, JsonObject config, CancellationToken cancellationToken)
+ {
+ var json = JsonSerializer.Serialize(config, JsonSourceGenerationContext.Default.JsonObject);
+
+ // Write to a sibling temp file then move into place so a concurrently firing hook never reads a
+ // half-written config.
+ var tempPath = path + ".tmp-" + Guid.NewGuid().ToString("N");
+ await File.WriteAllTextAsync(tempPath, json, cancellationToken);
+ File.Move(tempPath, path, overwrite: true);
+ }
+}
diff --git a/src/Aspire.Cli/Agents/Hooks/TelemetryHookInstaller.cs b/src/Aspire.Cli/Agents/Hooks/TelemetryHookInstaller.cs
new file mode 100644
index 00000000000..9635e91a785
--- /dev/null
+++ b/src/Aspire.Cli/Agents/Hooks/TelemetryHookInstaller.cs
@@ -0,0 +1,116 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Text;
+using Microsoft.Extensions.Logging;
+
+namespace Aspire.Cli.Agents.Hooks;
+
+///
+/// Default that materializes the hook scripts embedded in the
+/// CLI assembly to ~/.aspire/hooks.
+///
+internal sealed class TelemetryHookInstaller : ITelemetryHookInstaller
+{
+ // LogicalNames declared in Aspire.Cli.csproj.
+ private const string ShellResourceName = "track-telemetry.sh";
+ private const string PowerShellResourceName = "track-telemetry.ps1";
+
+ private const string HooksDirectoryName = "hooks";
+
+ // UTF-8 without a BOM: the shell script must not begin with a BOM or the shebang is ignored,
+ // and the PowerShell script is ASCII so a BOM is unnecessary.
+ private static readonly UTF8Encoding s_utf8NoBom = new(encoderShouldEmitUTF8Identifier: false);
+
+ private readonly CliExecutionContext _executionContext;
+ private readonly ILogger _logger;
+
+ public TelemetryHookInstaller(CliExecutionContext executionContext, ILogger logger)
+ {
+ ArgumentNullException.ThrowIfNull(executionContext);
+ ArgumentNullException.ThrowIfNull(logger);
+ _executionContext = executionContext;
+ _logger = logger;
+ }
+
+ ///
+ public async Task EnsureInstalledAsync(CancellationToken cancellationToken)
+ {
+ var hooksDirectory = Path.Combine(_executionContext.AspireHomeDirectory.FullName, HooksDirectoryName);
+ Directory.CreateDirectory(hooksDirectory);
+
+ var shellPath = Path.Combine(hooksDirectory, ShellResourceName);
+ var powerShellPath = Path.Combine(hooksDirectory, PowerShellResourceName);
+
+ // The shell script must use LF endings even on Windows because the agent may run it under a
+ // POSIX shell (WSL/Git bash); CRLF would surface as `$'\r': command not found` errors.
+ var shellContent = NormalizeToLf(ReadEmbeddedText(ShellResourceName));
+ var powerShellContent = ReadEmbeddedText(PowerShellResourceName);
+
+ await WriteFileIfChangedAsync(shellPath, shellContent, cancellationToken);
+ await WriteFileIfChangedAsync(powerShellPath, powerShellContent, cancellationToken);
+
+ // Ensure the shell script is executable so a `bash ` (or direct exec) hook entry works.
+ // Spawning `chmod` would add PATH/shell failure modes, so use the platform API directly.
+ TrySetExecutable(shellPath);
+
+ return new TelemetryHookScripts(shellPath, powerShellPath);
+ }
+
+ private static string NormalizeToLf(string content)
+ => content.Replace("\r\n", "\n").Replace("\r", "\n");
+
+ private static string ReadEmbeddedText(string resourceName)
+ {
+ using var stream = typeof(TelemetryHookInstaller).Assembly.GetManifestResourceStream(resourceName)
+ ?? throw new InvalidOperationException($"Embedded telemetry hook script '{resourceName}' was not found in the CLI assembly.");
+ using var reader = new StreamReader(stream, s_utf8NoBom);
+ return reader.ReadToEnd();
+ }
+
+ private async Task WriteFileIfChangedAsync(string path, string content, CancellationToken cancellationToken)
+ {
+ // Skip the write when the content already matches so a running hook isn't disturbed and the
+ // file mtime stays stable across repeated `agent init` runs.
+ if (File.Exists(path))
+ {
+ try
+ {
+ var existing = await File.ReadAllTextAsync(path, s_utf8NoBom, cancellationToken);
+ if (string.Equals(existing, content, StringComparison.Ordinal))
+ {
+ return;
+ }
+ }
+ catch (IOException ex)
+ {
+ _logger.LogDebug(ex, "Could not read existing telemetry hook script at {Path}; it will be rewritten.", path);
+ }
+ }
+
+ // Write to a sibling temp file then atomically move into place so a concurrently executing
+ // hook never observes a partially written script.
+ var tempPath = path + ".tmp-" + Guid.NewGuid().ToString("N");
+ await File.WriteAllTextAsync(tempPath, content, s_utf8NoBom, cancellationToken);
+ File.Move(tempPath, path, overwrite: true);
+ }
+
+ private void TrySetExecutable(string path)
+ {
+ if (OperatingSystem.IsWindows())
+ {
+ return;
+ }
+
+ try
+ {
+ var mode = File.GetUnixFileMode(path);
+ File.SetUnixFileMode(path, mode | UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute);
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or PlatformNotSupportedException)
+ {
+ // Non-fatal: a `bash ` hook entry still runs the script without the executable bit.
+ _logger.LogDebug(ex, "Could not set executable bit on telemetry hook script at {Path}.", path);
+ }
+ }
+}
diff --git a/src/Aspire.Cli/Agents/Hooks/track-telemetry.ps1 b/src/Aspire.Cli/Agents/Hooks/track-telemetry.ps1
new file mode 100644
index 00000000000..0d34a76b6bd
--- /dev/null
+++ b/src/Aspire.Cli/Agents/Hooks/track-telemetry.ps1
@@ -0,0 +1,184 @@
+# Telemetry tracking hook for Aspire Skills.
+#
+# Runs on every agent PostToolUse event. Reads the hook JSON from stdin, detects when an
+# Aspire skill, Aspire MCP tool, or Aspire skill reference file was used, and forwards a
+# low-cardinality usage event to `aspire agent telemetry`. The Aspire CLI command owns the
+# actual opt-out + publishing logic; this script only classifies the event and shells out.
+#
+# Hook contract: a PostToolUse hook MUST always print a single JSON object to stdout and exit
+# 0, otherwise it can break the agent session. Every code path here ends in Write-Success.
+#
+# Compatible with Windows PowerShell 5.1 and PowerShell 7+. See track-telemetry.sh for the
+# full client-format / event-type / privacy notes (the logic here mirrors that script).
+
+$ErrorActionPreference = "SilentlyContinue"
+
+# Allowlist of Aspire-owned skill names (keep in sync with github.com/microsoft/aspire-skills).
+# A shared .agents/skills directory can also contain third-party skills, so a path/name is only
+# treated as Aspire when its skill segment is one of these.
+$AspireSkills = @('aspire', 'aspire-init', 'aspireify', 'aspire-orchestration', 'aspire-deployment', 'aspire-monitoring')
+
+function Write-Success {
+ Write-Output '{"continue":true}'
+ exit 0
+}
+
+function Test-OptOut([string] $value) {
+ return $value -eq '1' -or $value -ieq 'true'
+}
+
+# Opt out when the Aspire CLI telemetry switch is set. This is the single opt-out that also
+# gates the `aspire agent telemetry` command path, so honoring it here avoids spawning the CLI
+# at all for opted-out users.
+if (Test-OptOut $env:ASPIRE_CLI_TELEMETRY_OPTOUT) {
+ Write-Success
+}
+
+# Read the entire payload from stdin (one complete JSON object per hook invocation).
+try {
+ $rawInput = [Console]::In.ReadToEnd()
+} catch {
+ Write-Success
+}
+
+if ([string]::IsNullOrWhiteSpace($rawInput)) {
+ Write-Success
+}
+
+# Parse-fail -> skip telemetry, never guess.
+try {
+ $data = $rawInput | ConvertFrom-Json -ErrorAction Stop
+} catch {
+ Write-Success
+}
+
+# Copilot CLI camelCase vs Claude/VS Code snake_case.
+$toolName = $data.toolName
+if (-not $toolName) { $toolName = $data.tool_name }
+
+$sessionId = $data.sessionId
+if (-not $sessionId) { $sessionId = $data.session_id }
+
+$toolInput = $data.toolArgs
+if (-not $toolInput) { $toolInput = $data.tool_input }
+
+$timestamp = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
+
+# Detect the client (used only for a low-cardinality client-name tag).
+$propertyNames = @()
+if ($data.PSObject -and $data.PSObject.Properties) { $propertyNames = $data.PSObject.Properties.Name }
+$hasHookEventName = $propertyNames -contains 'hook_event_name'
+$hasToolArgs = $propertyNames -contains 'toolArgs'
+
+if ($env:COPILOT_CLI -eq '1') {
+ $clientName = 'copilot-cli'
+} elseif ($hasHookEventName) {
+ $toolUseId = [string]$data.tool_use_id
+ $transcriptPath = ([string]$data.transcript_path) -replace '\\', '/'
+ if ($toolUseId -match '__vscode' -or $transcriptPath -match '/Code( - Insiders)?/') {
+ $clientName = 'vscode'
+ } else {
+ $clientName = 'claude-code'
+ }
+} elseif ($hasToolArgs) {
+ $clientName = 'copilot-cli'
+} else {
+ $clientName = 'unknown'
+}
+
+if (-not $toolName) {
+ Write-Success
+}
+
+function Get-ToolInputPath($inputObject) {
+ if (-not $inputObject) { return $null }
+ if ($inputObject.path) { return [string]$inputObject.path }
+ if ($inputObject.filePath) { return [string]$inputObject.filePath }
+ if ($inputObject.file_path) { return [string]$inputObject.file_path }
+ return $null
+}
+
+function Test-AspireSkill([string] $candidate) {
+ return $AspireSkills -contains $candidate
+}
+
+$shouldTrack = $false
+$eventType = $null
+$skillName = $null
+$mcpToolName = $null
+$fileReference = $null
+
+# --- skill_invocation via the skill/Skill tool ---
+if ($toolName -eq 'skill' -or $toolName -eq 'Skill') {
+ $candidate = [string]$toolInput.skill
+ # Claude prefixes plugin skill names, e.g. "aspire:aspire-deployment".
+ if ($candidate.StartsWith('aspire:')) { $candidate = $candidate.Substring(7) }
+ if (Test-AspireSkill $candidate) {
+ $skillName = $candidate
+ $eventType = 'skill_invocation'
+ $shouldTrack = $true
+ }
+}
+
+# --- skill_invocation / reference_file_read via a file read tool ---
+if ($toolName -eq 'view' -or $toolName -eq 'Read' -or $toolName -eq 'read_file') {
+ $pathToCheck = Get-ToolInputPath $toolInput
+ if ($pathToCheck) {
+ # Normalize separators and collapse duplicate slashes.
+ $normalized = ($pathToCheck -replace '\\', '/') -replace '/+', '/'
+ # Capture the skill segment after skills/ and the remainder.
+ $skillSegment = $null
+ $remainder = $null
+ if ($normalized -match '(?:^|/)skills/([^/]+)/(.+)$') {
+ $skillSegment = $Matches[1]
+ $remainder = $Matches[2]
+ }
+ if ($skillSegment -and (Test-AspireSkill $skillSegment)) {
+ if ($remainder -imatch '(^|/)skill\.md$') {
+ # A SKILL.md read is a skill invocation, not a reference-file read.
+ if (-not $shouldTrack) {
+ $skillName = $skillSegment
+ $eventType = 'skill_invocation'
+ $shouldTrack = $true
+ }
+ } elseif (-not $shouldTrack -and $remainder) {
+ # Forward only the relative path after skills/ (e.g. aspire/references/deploy.md).
+ $fileReference = "$skillSegment/$remainder"
+ $eventType = 'reference_file_read'
+ $shouldTrack = $true
+ }
+ }
+ }
+}
+
+# --- tool_invocation via an Aspire MCP tool prefix ---
+# Conservative exact prefixes:
+# Copilot: aspire- Claude: mcp__aspire__ VS Code: mcp_aspire_
+if ($toolName.StartsWith('aspire-') -or $toolName.StartsWith('mcp__aspire__') -or $toolName.StartsWith('mcp_aspire_')) {
+ $mcpToolName = $toolName
+ $eventType = 'tool_invocation'
+ $shouldTrack = $true
+}
+
+if (-not $shouldTrack) {
+ Write-Success
+}
+
+# Resolve the Aspire CLI. ASPIRE_CLI_COMMAND lets tests substitute a recording stub.
+$aspireCmd = $env:ASPIRE_CLI_COMMAND
+if (-not $aspireCmd) { $aspireCmd = 'aspire' }
+
+# Build the argument vector explicitly so untrusted hook values are passed as discrete args.
+$cmdArgs = @('agent', 'telemetry', '--event-type', $eventType, '--client-name', $clientName, '--timestamp', $timestamp)
+if ($sessionId) { $cmdArgs += @('--session-id', [string]$sessionId) }
+if ($skillName) { $cmdArgs += @('--skill-name', $skillName) }
+if ($mcpToolName) { $cmdArgs += @('--tool-name', $mcpToolName) }
+if ($fileReference) { $cmdArgs += @('--file-reference', $fileReference) }
+
+# Redirect all child output to null so a banner/log line can never contaminate hook stdout;
+# swallow every failure so the hook still returns success.
+try {
+ & $aspireCmd @cmdArgs *> $null
+} catch { }
+
+Write-Success
diff --git a/src/Aspire.Cli/Agents/Hooks/track-telemetry.sh b/src/Aspire.Cli/Agents/Hooks/track-telemetry.sh
new file mode 100644
index 00000000000..4789281e88c
--- /dev/null
+++ b/src/Aspire.Cli/Agents/Hooks/track-telemetry.sh
@@ -0,0 +1,242 @@
+#!/bin/bash
+
+# Telemetry tracking hook for Aspire Skills.
+#
+# Runs on every agent PostToolUse event. Reads the hook JSON from stdin, detects when an
+# Aspire skill, Aspire MCP tool, or Aspire skill reference file was used, and forwards a
+# low-cardinality usage event to `aspire agent telemetry`. The Aspire CLI command owns the
+# actual opt-out + publishing logic; this script only classifies the event and shells out.
+#
+# Hook contract: a PostToolUse hook MUST always print a single JSON object to stdout and exit
+# 0, otherwise it can break the agent session. Every code path here ends in return_success.
+#
+# === Client format reference ===
+#
+# Copilot CLI:
+# - Field names: camelCase (toolName, sessionId, toolArgs) when the hook event is configured
+# in camelCase (postToolUse); snake_case (tool_name, ...) when configured in PascalCase
+# (PostToolUse, "VS Code compatible" payload). We handle both.
+# - Tool names: lowercase (skill, view)
+# - Aspire MCP prefix: aspire- (e.g. aspire-list_resources)
+# - Detection: COPILOT_CLI=1, or a "toolArgs" field present
+#
+# Claude Code:
+# - Field names: snake_case (tool_name, session_id, tool_input, hook_event_name)
+# - Tool names: PascalCase (Skill, Read, Edit)
+# - Aspire MCP prefix: mcp__aspire__ (server named "aspire" in .mcp.json)
+# - Skill prefix: aspire: (plugin install) — stripped before allowlist match
+# - Detection: has "hook_event_name", tool_use_id does NOT contain "__vscode"
+#
+# VS Code:
+# - Field names: snake_case (tool_name, session_id, tool_input, hook_event_name)
+# - Tool names: snake_case (read_file)
+# - Aspire MCP prefix: mcp_aspire_
+# - Detection: has "hook_event_name", tool_use_id contains "__vscode" or transcript_path has /Code/
+#
+# === Event types emitted ===
+#
+# 1. skill_invocation - the skill/Skill tool ran with an Aspire skill name, OR a SKILL.md
+# under .../skills//SKILL.md was read. (--skill-name)
+# 2. tool_invocation - a tool matching an Aspire MCP prefix ran. (--tool-name)
+# 3. reference_file_read - a non-SKILL.md file under .../skills// was read.
+# (--file-reference)
+#
+# Privacy: only Aspire-owned identifiers are forwarded. Skill/tool names are matched against an
+# allowlist of the skills shipped by github.com/microsoft/aspire-skills, and reference files are
+# only forwarded as the repo-relative path *after* skills// — never absolute paths, repo
+# names, or user names. The Aspire CLI command independently re-validates and drops anything else.
+
+# Never abort the agent: failures must be silent and we must still emit {"continue":true}.
+set +e
+
+# Allowlist of Aspire-owned skill names (must stay in sync with the skills shipped by
+# github.com/microsoft/aspire-skills). A shared .agents/skills directory can also contain
+# third-party skills (dotnet-inspect, playwright, ...), so a path/name is only treated as
+# Aspire when its skill segment is one of these.
+ASPIRE_SKILLS="aspire aspire-init aspireify aspire-orchestration aspire-deployment aspire-monitoring"
+
+return_success() {
+ echo '{"continue":true}'
+ exit 0
+}
+
+# Opt out when the Aspire CLI telemetry switch is set. This is the single opt-out that also
+# gates the `aspire agent telemetry` command path, so honoring it here avoids spawning the CLI
+# at all for opted-out users.
+case "${ASPIRE_CLI_TELEMETRY_OPTOUT}" in
+ 1|true|TRUE|True) return_success ;;
+esac
+
+# Extract a top-level string field, e.g. "toolName": "view" -> view
+# Uses sed (portable; no jq/grep -P dependency).
+extract_json_field() {
+ printf '%s' "$1" | sed -n "s/.*\"$2\"[[:space:]]*:[[:space:]]*\"\([^\"]*\)\".*/\1/p" | head -n 1
+}
+
+# Extract a nested string field from toolArgs (Copilot) or tool_input (Claude/VS Code).
+extract_nested_field() {
+ local json="$1" field="$2" value=""
+ value=$(printf '%s' "$json" | sed -n "s/.*\"toolArgs\"[[:space:]]*:[[:space:]]*{[^}]*\"$field\"[[:space:]]*:[[:space:]]*\"\([^\"]*\)\".*/\1/p" | head -n 1)
+ if [ -z "$value" ]; then
+ value=$(printf '%s' "$json" | sed -n "s/.*\"tool_input\"[[:space:]]*:[[:space:]]*{[^}]*\"$field\"[[:space:]]*:[[:space:]]*\"\([^\"]*\)\".*/\1/p" | head -n 1)
+ fi
+ printf '%s' "$value"
+}
+
+# Extract a file path from tool input, trying the documented field names in order.
+extract_nested_path() {
+ local json="$1" value=""
+ for field in path filePath file_path; do
+ value=$(extract_nested_field "$json" "$field")
+ if [ -n "$value" ]; then
+ break
+ fi
+ done
+ printf '%s' "$value"
+}
+
+# Return 0 when $1 is an allowlisted Aspire skill name.
+is_aspire_skill() {
+ local candidate="$1" name
+ for name in $ASPIRE_SKILLS; do
+ if [ "$candidate" = "$name" ]; then
+ return 0
+ fi
+ done
+ return 1
+}
+
+# No stdin (interactive) means nothing to track.
+if [ -t 0 ]; then
+ return_success
+fi
+
+rawInput=$(cat)
+if [ -z "$rawInput" ]; then
+ return_success
+fi
+
+toolName=$(extract_json_field "$rawInput" "toolName")
+[ -z "$toolName" ] && toolName=$(extract_json_field "$rawInput" "tool_name")
+
+sessionId=$(extract_json_field "$rawInput" "sessionId")
+[ -z "$sessionId" ] && sessionId=$(extract_json_field "$rawInput" "session_id")
+
+timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
+
+# Detect the client (used only for a low-cardinality client-name tag).
+if [ "$COPILOT_CLI" = "1" ]; then
+ clientName="copilot-cli"
+elif printf '%s' "$rawInput" | grep -q '"hook_event_name"'; then
+ toolUseId=$(extract_json_field "$rawInput" "tool_use_id")
+ transcriptPath=$(extract_json_field "$rawInput" "transcript_path")
+ transcriptPathNorm=$(printf '%s' "$transcriptPath" | tr '\\' '/')
+ case "$toolUseId$transcriptPathNorm" in
+ *__vscode*|*/Code/*|*/Code\ -\ Insiders/*) clientName="vscode" ;;
+ *) clientName="claude-code" ;;
+ esac
+elif printf '%s' "$rawInput" | grep -q '"toolArgs"'; then
+ clientName="copilot-cli"
+else
+ clientName="unknown"
+fi
+
+# Nothing to classify without a tool name.
+if [ -z "$toolName" ]; then
+ return_success
+fi
+
+shouldTrack=false
+eventType=""
+skillName=""
+mcpToolName=""
+fileReference=""
+
+# --- skill_invocation via the skill/Skill tool ---
+if [ "$toolName" = "skill" ] || [ "$toolName" = "Skill" ]; then
+ candidate=$(extract_nested_field "$rawInput" "skill")
+ # Claude prefixes plugin skill names, e.g. "aspire:aspire-deployment".
+ candidate="${candidate#aspire:}"
+ if is_aspire_skill "$candidate"; then
+ skillName="$candidate"
+ eventType="skill_invocation"
+ shouldTrack=true
+ fi
+fi
+
+# --- skill_invocation / reference_file_read via a file read tool ---
+# Copilot CLI: view, Claude Code: Read, VS Code: read_file.
+if [ "$toolName" = "view" ] || [ "$toolName" = "Read" ] || [ "$toolName" = "read_file" ]; then
+ pathToCheck=$(extract_nested_path "$rawInput")
+ if [ -n "$pathToCheck" ]; then
+ # Normalize separators and collapse duplicate slashes. Example inputs:
+ # .agents/skills/aspire/SKILL.md
+ # /home/me/proj/.github/skills/aspire-deployment/references/deploy.md
+ # C:\src\.claude\skills\aspireify\SKILL.md
+ normalized=$(printf '%s' "$pathToCheck" | tr '\\' '/' | sed 's|//*|/|g')
+ # Capture the skill segment after skills/. We only honor allowlisted Aspire skills.
+ skillSegment=$(printf '%s' "$normalized" | sed -n 's|.*/skills/\([^/]*\)/.*|\1|p')
+ if [ -z "$skillSegment" ]; then
+ # Handles a leading "skills//..." with no parent directory.
+ skillSegment=$(printf '%s' "$normalized" | sed -n 's|^skills/\([^/]*\)/.*|\1|p')
+ fi
+ if [ -n "$skillSegment" ] && is_aspire_skill "$skillSegment"; then
+ remainder=$(printf '%s' "$normalized" | sed -n 's|.*/skills/||p')
+ [ -z "$remainder" ] && remainder=$(printf '%s' "$normalized" | sed -n 's|^skills/||p')
+ case "$remainder" in
+ */SKILL.md|SKILL.md|*/skill.md|skill.md)
+ # A SKILL.md read is a skill invocation, not a reference-file read.
+ if [ "$shouldTrack" = false ]; then
+ skillName="$skillSegment"
+ eventType="skill_invocation"
+ shouldTrack=true
+ fi
+ ;;
+ *)
+ if [ "$shouldTrack" = false ] && [ -n "$remainder" ]; then
+ # Forward only the relative path after skills/ (e.g. aspire/references/deploy.md).
+ fileReference="$remainder"
+ eventType="reference_file_read"
+ shouldTrack=true
+ fi
+ ;;
+ esac
+ fi
+ fi
+fi
+
+# --- tool_invocation via an Aspire MCP tool prefix ---
+# Conservative exact prefixes (avoid matching arbitrary "*aspire*" tools):
+# Copilot: aspire- Claude: mcp__aspire__ VS Code: mcp_aspire_
+case "$toolName" in
+ aspire-*|mcp__aspire__*|mcp_aspire_*)
+ mcpToolName="$toolName"
+ eventType="tool_invocation"
+ shouldTrack=true
+ ;;
+esac
+
+if [ "$shouldTrack" != true ]; then
+ return_success
+fi
+
+# Resolve the Aspire CLI. ASPIRE_CLI_COMMAND lets tests substitute a recording stub.
+aspireCmd="${ASPIRE_CLI_COMMAND:-aspire}"
+
+# Build the argument vector explicitly so untrusted hook values are passed as discrete args
+# (never concatenated into a shell string).
+args=(agent telemetry --event-type "$eventType" --client-name "$clientName" --timestamp "$timestamp")
+[ -n "$sessionId" ] && args+=(--session-id "$sessionId")
+[ -n "$skillName" ] && args+=(--skill-name "$skillName")
+[ -n "$mcpToolName" ] && args+=(--tool-name "$mcpToolName")
+[ -n "$fileReference" ] && args+=(--file-reference "$fileReference")
+
+# Redirect all child output to null so a banner/log line can never contaminate hook stdout.
+# Bound the call so a hung CLI can't stall the agent; swallow every failure.
+if command -v timeout >/dev/null 2>&1; then
+ timeout 10 "$aspireCmd" "${args[@]}" >/dev/null 2>&1
+else
+ "$aspireCmd" "${args[@]}" >/dev/null 2>&1
+fi
+
+return_success
diff --git a/src/Aspire.Cli/Agents/OpenCode/OpenCodeAgentEnvironmentScanner.cs b/src/Aspire.Cli/Agents/OpenCode/OpenCodeAgentEnvironmentScanner.cs
index 0ce35875dc7..8131da22942 100644
--- a/src/Aspire.Cli/Agents/OpenCode/OpenCodeAgentEnvironmentScanner.cs
+++ b/src/Aspire.Cli/Agents/OpenCode/OpenCodeAgentEnvironmentScanner.cs
@@ -53,6 +53,8 @@ public async Task ScanAsync(AgentEnvironmentScanContext context, CancellationTok
{
_logger.LogDebug("Found existing opencode.jsonc at: {ConfigFilePath}", configFilePath);
+ context.AddDetectedClient(AgentClientKind.OpenCode);
+
// Check if aspire is already configured
_logger.LogDebug("Checking if Aspire MCP server is already configured in opencode.jsonc...");
if (!HasAspireServerConfigured(configFilePath))
@@ -78,6 +80,9 @@ public async Task ScanAsync(AgentEnvironmentScanContext context, CancellationTok
if (openCodeVersion is not null)
{
_logger.LogDebug("Found OpenCode CLI version: {Version}", openCodeVersion);
+
+ context.AddDetectedClient(AgentClientKind.OpenCode);
+
// OpenCode is installed - offer to create config
_logger.LogDebug("Adding OpenCode applicator to create new opencode.jsonc at: {ConfigDirectory}", configDirectory.FullName);
context.AddApplicator(CreateApplicator(configDirectory));
diff --git a/src/Aspire.Cli/Agents/VsCode/VsCodeAgentEnvironmentScanner.cs b/src/Aspire.Cli/Agents/VsCode/VsCodeAgentEnvironmentScanner.cs
index 9924a961d3a..f648ade089f 100644
--- a/src/Aspire.Cli/Agents/VsCode/VsCodeAgentEnvironmentScanner.cs
+++ b/src/Aspire.Cli/Agents/VsCode/VsCodeAgentEnvironmentScanner.cs
@@ -56,6 +56,8 @@ public async Task ScanAsync(AgentEnvironmentScanContext context, CancellationTok
{
_logger.LogDebug("Found .vscode folder at: {VsCodeFolder}", vsCodeFolder.FullName);
+ context.AddDetectedClient(AgentClientKind.VsCode);
+
// Check if the aspire server is already configured
if (!HasAspireServerConfigured(vsCodeFolder))
{
@@ -74,6 +76,9 @@ public async Task ScanAsync(AgentEnvironmentScanContext context, CancellationTok
else if (await IsVsCodeAvailableAsync(cancellationToken).ConfigureAwait(false))
{
_logger.LogDebug("No .vscode folder found, but VS Code is available on the system");
+
+ context.AddDetectedClient(AgentClientKind.VsCode);
+
// No .vscode folder found, but VS Code is available
// Use workspace root for new .vscode folder
var targetVsCodeFolder = new DirectoryInfo(Path.Combine(context.RepositoryRoot.FullName, VsCodeFolderName));
diff --git a/src/Aspire.Cli/Aspire.Cli.csproj b/src/Aspire.Cli/Aspire.Cli.csproj
index afd4bd73f5e..dcd0c106814 100644
--- a/src/Aspire.Cli/Aspire.Cli.csproj
+++ b/src/Aspire.Cli/Aspire.Cli.csproj
@@ -303,6 +303,15 @@
false
+
+
+ false
+
+
+ false
+
diff --git a/src/Aspire.Cli/Commands/AgentCommand.cs b/src/Aspire.Cli/Commands/AgentCommand.cs
index 6668b7b0eea..2a0506ff858 100644
--- a/src/Aspire.Cli/Commands/AgentCommand.cs
+++ b/src/Aspire.Cli/Commands/AgentCommand.cs
@@ -15,10 +15,12 @@ internal sealed class AgentCommand : ParentCommand
public AgentCommand(
AgentMcpCommand mcpCommand,
AgentInitCommand initCommand,
+ AgentTelemetryCommand telemetryCommand,
CommonCommandServices services)
: base("agent", AgentCommandStrings.Description, services)
{
Subcommands.Add(mcpCommand);
Subcommands.Add(initCommand);
+ Subcommands.Add(telemetryCommand);
}
}
diff --git a/src/Aspire.Cli/Commands/AgentInitCommand.cs b/src/Aspire.Cli/Commands/AgentInitCommand.cs
index 070136560a4..b720649070f 100644
--- a/src/Aspire.Cli/Commands/AgentInitCommand.cs
+++ b/src/Aspire.Cli/Commands/AgentInitCommand.cs
@@ -7,6 +7,7 @@
using System.Text.Json;
using Aspire.Cli.Agents;
using Aspire.Cli.Agents.AspireSkills;
+using Aspire.Cli.Agents.Hooks;
using Aspire.Cli.Agents.Playwright;
using Aspire.Cli.Git;
using Aspire.Cli.Interaction;
@@ -28,6 +29,7 @@ internal sealed class AgentInitCommand : BaseCommand, IPackageMetaPrefetchingCom
private readonly PlaywrightCliInstaller _playwrightCliInstaller;
private readonly IGitRepository _gitRepository;
private readonly ILanguageDiscovery _languageDiscovery;
+ private readonly ITelemetryHookConfigurator _telemetryHookConfigurator;
///
/// AgentInitCommand does not need template package metadata prefetching.
@@ -45,6 +47,7 @@ public AgentInitCommand(
PlaywrightCliInstaller playwrightCliInstaller,
IGitRepository gitRepository,
ILanguageDiscovery languageDiscovery,
+ ITelemetryHookConfigurator telemetryHookConfigurator,
CommonCommandServices services)
: base("init", AgentCommandStrings.InitCommand_Description, services)
{
@@ -53,6 +56,7 @@ public AgentInitCommand(
_playwrightCliInstaller = playwrightCliInstaller;
_gitRepository = gitRepository;
_languageDiscovery = languageDiscovery;
+ _telemetryHookConfigurator = telemetryHookConfigurator;
Options.Add(s_workspaceRootOption);
Options.Add(s_skillLocationsOption);
@@ -455,6 +459,12 @@ private async Task ExecuteAgentInitAsync(DirectoryInfo
}
}
+ // --- Phase 6: Install agent telemetry hooks (default-on, parity with azure-skills) ---
+ // Hooks are installed for every detected, supported client. Whether telemetry is actually
+ // transmitted stays gated by the single ASPIRE_CLI_TELEMETRY_OPTOUT opt-out, which both the
+ // hook scripts and the `aspire agent telemetry` command path re-check at runtime.
+ await ConfigureTelemetryHooksAsync(context, cancellationToken);
+
if (hasErrors)
{
InteractionService.DisplayMessage(KnownEmojis.Warning, AgentCommandStrings.ConfigurationCompletedWithErrors);
@@ -470,6 +480,54 @@ private async Task ExecuteAgentInitAsync(DirectoryInfo
selectedSkills);
}
+ private async Task ConfigureTelemetryHooksAsync(AgentEnvironmentScanContext context, CancellationToken cancellationToken)
+ {
+ TelemetryHookConfigurationResult result;
+ try
+ {
+ result = await _telemetryHookConfigurator.ConfigureAsync(context.DetectedClients, cancellationToken);
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ // Hook installation is best-effort transparency tooling; never fail `agent init` over it.
+ InteractionService.DisplaySubtleMessage(ex.Message);
+ return;
+ }
+
+ if (result.ConfiguredClients.Count > 0)
+ {
+ var clientNames = string.Join(", ", result.ConfiguredClients.Select(GetClientDisplayName));
+ InteractionService.DisplayMessage(
+ KnownEmojis.BarChart,
+ string.Format(CultureInfo.CurrentCulture, AgentCommandStrings.InitCommand_TelemetryHooksInstalled, clientNames));
+ }
+
+ foreach (var skip in result.Skipped)
+ {
+ var clientName = GetClientDisplayName(skip.Client);
+ var message = skip.Reason switch
+ {
+ TelemetryHookSkipReason.MalformedConfig => string.Format(CultureInfo.CurrentCulture, AgentCommandStrings.InitCommand_TelemetryHookSkippedMalformedConfig, clientName),
+ TelemetryHookSkipReason.UnexpectedConfigShape => string.Format(CultureInfo.CurrentCulture, AgentCommandStrings.InitCommand_TelemetryHookSkippedUnexpectedShape, clientName),
+ _ => string.Format(CultureInfo.CurrentCulture, AgentCommandStrings.InitCommand_TelemetryHookWriteFailed, clientName),
+ };
+
+ // Skips are surfaced to the user but never treated as command failures: a user-owned
+ // config we can't safely modify must not break `agent init`.
+ InteractionService.DisplaySubtleMessage(message);
+ }
+ }
+
+ private static string GetClientDisplayName(AgentClientKind client)
+ => client switch
+ {
+ AgentClientKind.CopilotCli => "GitHub Copilot CLI",
+ AgentClientKind.ClaudeCode => "Claude Code",
+ AgentClientKind.VsCode => "VS Code",
+ AgentClientKind.OpenCode => "OpenCode",
+ _ => client.ToString(),
+ };
+
private async Task<(IReadOnlyList Skills, AspireSkillsBundle? Bundle, string? FailureMessage)> ResolveAvailableSkillsAsync(LanguageId? detectedLanguage, CancellationToken cancellationToken)
{
var skills = new List();
diff --git a/src/Aspire.Cli/Commands/AgentTelemetryCommand.cs b/src/Aspire.Cli/Commands/AgentTelemetryCommand.cs
new file mode 100644
index 00000000000..f8abc63f59b
--- /dev/null
+++ b/src/Aspire.Cli/Commands/AgentTelemetryCommand.cs
@@ -0,0 +1,212 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.CommandLine;
+using System.Globalization;
+using Aspire.Cli.Resources;
+using Aspire.Cli.Telemetry;
+
+namespace Aspire.Cli.Commands;
+
+///
+/// Hidden, machine-facing command invoked by the agent telemetry hook scripts
+/// (track-telemetry.sh / track-telemetry.ps1) on each agent PostToolUse
+/// event. It records a single reported activity describing the Aspire skill, MCP tool, or
+/// reference-file usage that the hook detected.
+///
+///
+/// Hook-safety contract: this command must never throw and must always exit 0. A hook that fails
+/// or writes unexpected output can break the host agent's tool loop, so every operation is wrapped
+/// so that any failure degrades to a successful no-op. All options are optional and unvalidated,
+/// and unmatched tokens are ignored, so option binding can never fail before the handler runs and a
+/// newer hook script passing an unknown flag cannot break an older CLI.
+///
+/// The opt-out (ASPIRE_CLI_TELEMETRY_OPTOUT) and the suppression of the generic
+/// aspire/cli/main span for this command path are handled in
+/// and Program before the host is built. When telemetry is
+/// opted out no reported provider is created, so
+/// returns here and the command is a no-op.
+///
+internal sealed class AgentTelemetryCommand : BaseCommand
+{
+ // Defensive cap so a malformed or hostile hook payload cannot push oversized or
+ // high-cardinality values into the telemetry backend. Real values (skill names, tool names,
+ // skills-relative reference paths) are well under this length.
+ private const int MaxTagValueLength = 256;
+
+ // The only event types the hook scripts emit. Anything else is dropped so a script bug or a
+ // crafted argument cannot introduce arbitrary, high-cardinality event categories.
+ private static readonly string[] s_knownEventTypes = ["skill_invocation", "tool_invocation", "reference_file_read"];
+
+ private readonly Option _eventTypeOption = new("--event-type")
+ {
+ Description = AgentCommandStrings.AgentTelemetryCommand_EventTypeDescription
+ };
+
+ private readonly Option _clientNameOption = new("--client-name")
+ {
+ Description = AgentCommandStrings.AgentTelemetryCommand_ClientNameDescription
+ };
+
+ private readonly Option _sessionIdOption = new("--session-id")
+ {
+ Description = AgentCommandStrings.AgentTelemetryCommand_SessionIdDescription
+ };
+
+ private readonly Option _skillNameOption = new("--skill-name")
+ {
+ Description = AgentCommandStrings.AgentTelemetryCommand_SkillNameDescription
+ };
+
+ private readonly Option _toolNameOption = new("--tool-name")
+ {
+ Description = AgentCommandStrings.AgentTelemetryCommand_ToolNameDescription
+ };
+
+ private readonly Option _fileReferenceOption = new("--file-reference")
+ {
+ Description = AgentCommandStrings.AgentTelemetryCommand_FileReferenceDescription
+ };
+
+ private readonly Option _timestampOption = new("--timestamp")
+ {
+ Description = AgentCommandStrings.AgentTelemetryCommand_TimestampDescription
+ };
+
+ public AgentTelemetryCommand(CommonCommandServices services)
+ : base("telemetry", AgentCommandStrings.AgentTelemetryCommand_Description, services)
+ {
+ // This command is an implementation detail of the agent hook scripts, not a user-facing
+ // command, so keep it out of help output.
+ Hidden = true;
+
+ // Never fail the hook because a newer script passes a flag this CLI version does not know.
+ TreatUnmatchedTokensAsErrors = false;
+
+ Options.Add(_eventTypeOption);
+ Options.Add(_clientNameOption);
+ Options.Add(_sessionIdOption);
+ Options.Add(_skillNameOption);
+ Options.Add(_toolNameOption);
+ Options.Add(_fileReferenceOption);
+ Options.Add(_timestampOption);
+ }
+
+ protected override Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken)
+ {
+ try
+ {
+ // Validate every value up front. Invalid or oversized values are dropped (never recorded),
+ // so a parser bug in a hook script cannot leak an absolute path, user name, or other
+ // sensitive/high-cardinality data into telemetry.
+ var tags = CollectValidTags(parseResult);
+
+ // Nothing valid survived validation (for example a newer hook script paired with an older
+ // CLI dropped every field): emit no span rather than a tagless one.
+ if (tags.Count is 0)
+ {
+ return Task.FromResult(CommandResult.Success());
+ }
+
+ // Activity is null when telemetry is opted out (no reported provider) or no listener is
+ // attached; in that case this is a no-op, which is the desired behavior.
+ using var activity = Telemetry.StartReportedActivity(TelemetryConstants.Activities.AgentTelemetry);
+ if (activity is not null)
+ {
+ foreach (var (name, value) in tags)
+ {
+ activity.SetTag(name, value);
+ }
+ }
+ }
+ catch
+ {
+ // Telemetry must never break the calling agent's hook. Swallow everything and exit 0.
+ }
+
+ return Task.FromResult(CommandResult.Success());
+ }
+
+ private List<(string Name, string Value)> CollectValidTags(ParseResult parseResult)
+ {
+ var tags = new List<(string Name, string Value)>();
+
+ AddIfValid(tags, TelemetryConstants.Tags.AgentEventType, parseResult.GetValue(_eventTypeOption), static v => s_knownEventTypes.Contains(v, StringComparer.Ordinal));
+ AddIfValid(tags, TelemetryConstants.Tags.AgentClientName, parseResult.GetValue(_clientNameOption), static v => IsSafeIdentifier(v, maxLength: 64));
+ AddIfValid(tags, TelemetryConstants.Tags.AgentSessionId, parseResult.GetValue(_sessionIdOption), static v => IsSafeIdentifier(v, maxLength: 128));
+ AddIfValid(tags, TelemetryConstants.Tags.AgentSkillName, parseResult.GetValue(_skillNameOption), static v => IsSafeIdentifier(v, maxLength: 128));
+ AddIfValid(tags, TelemetryConstants.Tags.AgentToolName, parseResult.GetValue(_toolNameOption), static v => IsSafeIdentifier(v, maxLength: 128));
+ AddIfValid(tags, TelemetryConstants.Tags.AgentFileReference, parseResult.GetValue(_fileReferenceOption), IsSafeReference);
+ AddIfValid(tags, TelemetryConstants.Tags.AgentEventTimestamp, parseResult.GetValue(_timestampOption), IsValidTimestamp);
+
+ return tags;
+ }
+
+ private static void AddIfValid(List<(string Name, string Value)> tags, string name, string? value, Func isValid)
+ {
+ if (!string.IsNullOrWhiteSpace(value) && isValid(value))
+ {
+ tags.Add((name, value));
+ }
+ }
+
+ ///
+ /// Validates an opaque identifier/name value: a bounded length and a conservative ASCII charset
+ /// (letters, digits, '-', '_', '.'). This rejects whitespace, path separators, and other
+ /// characters that would indicate the value is not an Aspire-owned identifier.
+ ///
+ private static bool IsSafeIdentifier(string value, int maxLength)
+ {
+ if (value.Length > maxLength)
+ {
+ return false;
+ }
+
+ foreach (var c in value)
+ {
+ if (!char.IsAsciiLetterOrDigit(c) && c is not ('-' or '_' or '.'))
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ ///
+ /// Validates a skills-relative reference path. Only forward-slash relative paths within the
+ /// Aspire skills tree are recorded; absolute paths, drive letters, UNC paths, parent traversal,
+ /// home (~) references, and backslashes are rejected so no machine-specific or
+ /// user-identifying path can be captured.
+ ///
+ private static bool IsSafeReference(string value)
+ {
+ if (value.Length > MaxTagValueLength ||
+ value.StartsWith('/') ||
+ value.StartsWith('~') ||
+ value.Contains('\\') ||
+ value.Contains("..", StringComparison.Ordinal) ||
+ Path.IsPathRooted(value))
+ {
+ return false;
+ }
+
+ foreach (var c in value)
+ {
+ if (!char.IsAsciiLetterOrDigit(c) && c is not ('-' or '_' or '.' or '/'))
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ ///
+ /// Validates that the timestamp value parses as a round-trippable date/time so a free-form string
+ /// cannot be recorded under the timestamp tag.
+ ///
+ private static bool IsValidTimestamp(string value)
+ => value.Length <= MaxTagValueLength &&
+ DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out _);
+}
diff --git a/src/Aspire.Cli/Commands/McpInitCommand.cs b/src/Aspire.Cli/Commands/McpInitCommand.cs
index 6292fde460a..7a7542411e2 100644
--- a/src/Aspire.Cli/Commands/McpInitCommand.cs
+++ b/src/Aspire.Cli/Commands/McpInitCommand.cs
@@ -36,6 +36,7 @@ public McpInitCommand(
PlaywrightCliInstaller playwrightCliInstaller,
IGitRepository gitRepository,
ILanguageDiscovery languageDiscovery,
+ Aspire.Cli.Agents.Hooks.ITelemetryHookConfigurator telemetryHookConfigurator,
CommonCommandServices services)
: base("init", McpCommandStrings.InitCommand_Description, services)
{
@@ -46,6 +47,7 @@ public McpInitCommand(
playwrightCliInstaller,
gitRepository,
languageDiscovery,
+ telemetryHookConfigurator,
services);
}
diff --git a/src/Aspire.Cli/Interaction/KnownEmojis.cs b/src/Aspire.Cli/Interaction/KnownEmojis.cs
index 2cc7836df77..0154fbba62f 100644
--- a/src/Aspire.Cli/Interaction/KnownEmojis.cs
+++ b/src/Aspire.Cli/Interaction/KnownEmojis.cs
@@ -30,6 +30,7 @@ internal readonly struct KnownEmoji(string name, string? textColor = null)
internal static class KnownEmojis
{
public static readonly KnownEmoji Bug = new("bug", "red");
+ public static readonly KnownEmoji BarChart = new("bar_chart", "blue");
public static readonly KnownEmoji CheckMarkButton = new("check_mark_button", "green");
public static readonly KnownEmoji CrossMark = new("cross_mark", "red");
public static readonly KnownEmoji FileFolder = new("file_folder", "yellow");
diff --git a/src/Aspire.Cli/Program.cs b/src/Aspire.Cli/Program.cs
index 7dff076dc81..d4fe2e68046 100644
--- a/src/Aspire.Cli/Program.cs
+++ b/src/Aspire.Cli/Program.cs
@@ -487,6 +487,10 @@ internal static async Task BuildApplicationAsync(string[] args, CliStartu
builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton());
builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton());
+ // Agent telemetry hook installation/configuration.
+ builder.Services.AddSingleton();
+ builder.Services.AddSingleton();
+
// Template factories.
builder.Services.AddSingleton();
builder.Services.AddSingleton();
@@ -562,6 +566,7 @@ internal static async Task BuildApplicationAsync(string[] args, CliStartu
builder.Services.AddTransient();
builder.Services.AddTransient();
builder.Services.AddTransient();
+ builder.Services.AddTransient();
builder.Services.AddTransient();
builder.Services.AddTransient();
builder.Services.AddTransient();
@@ -857,8 +862,18 @@ public static async Task Main(string[] args)
// Log feature state at startup for diagnostics
app.Services.GetRequiredService().LogFeatureState();
+ // The agent telemetry command is invoked fire-and-forget by the agent telemetry hook
+ // scripts on every PostToolUse event. It emits its own dedicated reported span, so the
+ // generic aspire/cli/main span is suppressed below to avoid double-counting CLI usage, and
+ // the first-run telemetry notice is skipped so a background hook cannot silently consume the
+ // notice the user is meant to see on their first interactive command.
+ var isAgentTelemetryInvocation = AgentTelemetryInvocation.Matches(args);
+
// Display first run experience if this is the first time the CLI is run on this machine
- await DisplayFirstTimeUseNoticeIfNeededAsync(app.Services, args, cancellationManager.Token);
+ if (!isAgentTelemetryInvocation)
+ {
+ await DisplayFirstTimeUseNoticeIfNeededAsync(app.Services, args, cancellationManager.Token);
+ }
var rootCommand = app.Services.GetRequiredService();
var invokeConfig = new InvocationConfiguration()
@@ -870,7 +885,13 @@ public static async Task Main(string[] args)
};
app.Services.GetRequiredService();
- using var mainActivity = telemetry.StartReportedActivity(TelemetryConstants.Activities.Main, ActivityKind.Internal);
+
+ // Suppress the generic main span for the agent telemetry command path: that command emits
+ // its own aspire/cli/agent_telemetry span, and creating the main span too would record a
+ // second span (inflating ordinary CLI-usage metrics) for every hook event.
+ using var mainActivity = isAgentTelemetryInvocation
+ ? null
+ : telemetry.StartReportedActivity(TelemetryConstants.Activities.Main, ActivityKind.Internal);
ProfileCaptureService.ProfileCaptureSession? profileCaptureSession = null;
if (mainActivity != null)
@@ -944,6 +965,22 @@ public static async Task Main(string[] args)
mainActivity?.Stop();
}
+ // The agent telemetry command runs fire-and-forget from an agent hook and the process
+ // exits immediately after. The short Release shutdown flush window is not enough to
+ // reliably export the single just-created span, so force a bounded reported-provider
+ // flush here before returning. This is a no-op when telemetry is opted out (no provider).
+ if (isAgentTelemetryInvocation)
+ {
+ try
+ {
+ await telemetryManager.ForceFlushReportedAsync().ConfigureAwait(false);
+ }
+ catch
+ {
+ // A telemetry flush failure must never change the hook's exit code.
+ }
+ }
+
if (profileCaptureSession is not null)
{
try
diff --git a/src/Aspire.Cli/Resources/AgentCommandStrings.Designer.cs b/src/Aspire.Cli/Resources/AgentCommandStrings.Designer.cs
index 6088f0e343f..b2a9a29a65a 100644
--- a/src/Aspire.Cli/Resources/AgentCommandStrings.Designer.cs
+++ b/src/Aspire.Cli/Resources/AgentCommandStrings.Designer.cs
@@ -249,6 +249,42 @@ internal static string InitCommand_ConfiguresDetectedAgentEnvironments {
}
}
+ ///
+ /// Looks up a localized string similar to Installed Aspire agent telemetry hooks for: {0}. Only Aspire skill, MCP tool, and reference-file usage is recorded. Opt out anytime by setting ASPIRE_CLI_TELEMETRY_OPTOUT=true..
+ ///
+ internal static string InitCommand_TelemetryHooksInstalled {
+ get {
+ return ResourceManager.GetString("InitCommand_TelemetryHooksInstalled", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Skipped the telemetry hook for {0} because its configuration file contains malformed JSON..
+ ///
+ internal static string InitCommand_TelemetryHookSkippedMalformedConfig {
+ get {
+ return ResourceManager.GetString("InitCommand_TelemetryHookSkippedMalformedConfig", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Skipped the telemetry hook for {0} because its configuration has an unexpected shape..
+ ///
+ internal static string InitCommand_TelemetryHookSkippedUnexpectedShape {
+ get {
+ return ResourceManager.GetString("InitCommand_TelemetryHookSkippedUnexpectedShape", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Skipped the telemetry hook for {0} because its configuration could not be written..
+ ///
+ internal static string InitCommand_TelemetryHookWriteFailed {
+ get {
+ return ResourceManager.GetString("InitCommand_TelemetryHookWriteFailed", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Aspire CLI commands and workflows for distributed apps.
///
@@ -554,5 +590,77 @@ internal static string InitCommand_SkillsOptionDescription {
return ResourceManager.GetString("InitCommand_SkillsOptionDescription", resourceCulture);
}
}
+
+ ///
+ /// Looks up a localized string similar to Record AI agent skill and tool usage telemetry. Invoked by the Aspire agent telemetry hook scripts; not intended to be run directly..
+ ///
+ internal static string AgentTelemetryCommand_Description {
+ get {
+ return ResourceManager.GetString("AgentTelemetryCommand_Description", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to The telemetry event type (skill_invocation, tool_invocation, or reference_file_read).
+ ///
+ internal static string AgentTelemetryCommand_EventTypeDescription {
+ get {
+ return ResourceManager.GetString("AgentTelemetryCommand_EventTypeDescription", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to The AI agent client that produced the event (for example copilot-cli, claude-code, or vscode).
+ ///
+ internal static string AgentTelemetryCommand_ClientNameDescription {
+ get {
+ return ResourceManager.GetString("AgentTelemetryCommand_ClientNameDescription", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to The opaque AI agent session identifier.
+ ///
+ internal static string AgentTelemetryCommand_SessionIdDescription {
+ get {
+ return ResourceManager.GetString("AgentTelemetryCommand_SessionIdDescription", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to The Aspire skill name for a skill_invocation event.
+ ///
+ internal static string AgentTelemetryCommand_SkillNameDescription {
+ get {
+ return ResourceManager.GetString("AgentTelemetryCommand_SkillNameDescription", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to The Aspire MCP tool name for a tool_invocation event.
+ ///
+ internal static string AgentTelemetryCommand_ToolNameDescription {
+ get {
+ return ResourceManager.GetString("AgentTelemetryCommand_ToolNameDescription", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to The Aspire skills-relative reference file path for a reference_file_read event.
+ ///
+ internal static string AgentTelemetryCommand_FileReferenceDescription {
+ get {
+ return ResourceManager.GetString("AgentTelemetryCommand_FileReferenceDescription", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to The timestamp recorded for the event.
+ ///
+ internal static string AgentTelemetryCommand_TimestampDescription {
+ get {
+ return ResourceManager.GetString("AgentTelemetryCommand_TimestampDescription", resourceCulture);
+ }
+ }
}
}
diff --git a/src/Aspire.Cli/Resources/AgentCommandStrings.resx b/src/Aspire.Cli/Resources/AgentCommandStrings.resx
index bfa3fe12348..80676c69ede 100644
--- a/src/Aspire.Cli/Resources/AgentCommandStrings.resx
+++ b/src/Aspire.Cli/Resources/AgentCommandStrings.resx
@@ -123,6 +123,18 @@
(configures detected agent environments)
+
+ Installed Aspire agent telemetry hooks for: {0}. Only Aspire skill, MCP tool, and reference-file usage is recorded. Opt out anytime by setting ASPIRE_CLI_TELEMETRY_OPTOUT=true.
+
+
+ Skipped the telemetry hook for {0} because its configuration file contains malformed JSON.
+
+
+ Skipped the telemetry hook for {0} because its configuration has an unexpected shape.
+
+
+ Skipped the telemetry hook for {0} because its configuration could not be written.
+
Aspire CLI commands and workflows for distributed apps
@@ -225,4 +237,28 @@
Comma-separated list of skills to install. Bundle skills are loaded dynamically; CLI-provided skills include {0}. Use '{1}' or '{2}'
+
+ Record AI agent skill and tool usage telemetry. Invoked by the Aspire agent telemetry hook scripts; not intended to be run directly.
+
+
+ The telemetry event type (skill_invocation, tool_invocation, or reference_file_read)
+
+
+ The AI agent client that produced the event (for example copilot-cli, claude-code, or vscode)
+
+
+ The opaque AI agent session identifier
+
+
+ The Aspire skill name for a skill_invocation event
+
+
+ The Aspire MCP tool name for a tool_invocation event
+
+
+ The Aspire skills-relative reference file path for a reference_file_read event
+
+
+ The timestamp recorded for the event
+
diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.cs.xlf
index 944125143fc..75431dfff07 100644
--- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.cs.xlf
+++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.cs.xlf
@@ -2,6 +2,46 @@
+
+ The AI agent client that produced the event (for example copilot-cli, claude-code, or vscode)
+ The AI agent client that produced the event (for example copilot-cli, claude-code, or vscode)
+
+
+
+ Record AI agent skill and tool usage telemetry. Invoked by the Aspire agent telemetry hook scripts; not intended to be run directly.
+ Record AI agent skill and tool usage telemetry. Invoked by the Aspire agent telemetry hook scripts; not intended to be run directly.
+
+
+
+ The telemetry event type (skill_invocation, tool_invocation, or reference_file_read)
+ The telemetry event type (skill_invocation, tool_invocation, or reference_file_read)
+
+
+
+ The Aspire skills-relative reference file path for a reference_file_read event
+ The Aspire skills-relative reference file path for a reference_file_read event
+
+
+
+ The opaque AI agent session identifier
+ The opaque AI agent session identifier
+
+
+
+ The Aspire skill name for a skill_invocation event
+ The Aspire skill name for a skill_invocation event
+
+
+
+ The timestamp recorded for the event
+ The timestamp recorded for the event
+
+
+
+ The Aspire MCP tool name for a tool_invocation event
+ The Aspire MCP tool name for a tool_invocation event
+
+ Embedded Aspire skills archive failed SHA-256 verification. Expected '{0}', got '{1}'.Embedded Aspire skills archive failed SHA-256 verification. Expected '{0}', got '{1}'.
@@ -122,6 +162,26 @@
Comma-separated list of skills to install. Bundle skills are loaded dynamically; CLI-provided skills include {0}. Use '{1}' or '{2}'
+
+ Skipped the telemetry hook for {0} because its configuration file contains malformed JSON.
+ Skipped the telemetry hook for {0} because its configuration file contains malformed JSON.
+
+
+
+ Skipped the telemetry hook for {0} because its configuration has an unexpected shape.
+ Skipped the telemetry hook for {0} because its configuration has an unexpected shape.
+
+
+
+ Skipped the telemetry hook for {0} because its configuration could not be written.
+ Skipped the telemetry hook for {0} because its configuration could not be written.
+
+
+
+ Installed Aspire agent telemetry hooks for: {0}. Only Aspire skill, MCP tool, and reference-file usage is recorded. Opt out anytime by setting ASPIRE_CLI_TELEMETRY_OPTOUT=true.
+ Installed Aspire agent telemetry hooks for: {0}. Only Aspire skill, MCP tool, and reference-file usage is recorded. Opt out anytime by setting ASPIRE_CLI_TELEMETRY_OPTOUT=true.
+
+ Path to the workspace root directoryPath to the workspace root directory
diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.de.xlf
index 58acd82ef63..06a436d6891 100644
--- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.de.xlf
+++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.de.xlf
@@ -2,6 +2,46 @@
+
+ The AI agent client that produced the event (for example copilot-cli, claude-code, or vscode)
+ The AI agent client that produced the event (for example copilot-cli, claude-code, or vscode)
+
+
+
+ Record AI agent skill and tool usage telemetry. Invoked by the Aspire agent telemetry hook scripts; not intended to be run directly.
+ Record AI agent skill and tool usage telemetry. Invoked by the Aspire agent telemetry hook scripts; not intended to be run directly.
+
+
+
+ The telemetry event type (skill_invocation, tool_invocation, or reference_file_read)
+ The telemetry event type (skill_invocation, tool_invocation, or reference_file_read)
+
+
+
+ The Aspire skills-relative reference file path for a reference_file_read event
+ The Aspire skills-relative reference file path for a reference_file_read event
+
+
+
+ The opaque AI agent session identifier
+ The opaque AI agent session identifier
+
+
+
+ The Aspire skill name for a skill_invocation event
+ The Aspire skill name for a skill_invocation event
+
+
+
+ The timestamp recorded for the event
+ The timestamp recorded for the event
+
+
+
+ The Aspire MCP tool name for a tool_invocation event
+ The Aspire MCP tool name for a tool_invocation event
+
+ Embedded Aspire skills archive failed SHA-256 verification. Expected '{0}', got '{1}'.Embedded Aspire skills archive failed SHA-256 verification. Expected '{0}', got '{1}'.
@@ -122,6 +162,26 @@
Comma-separated list of skills to install. Bundle skills are loaded dynamically; CLI-provided skills include {0}. Use '{1}' or '{2}'
+
+ Skipped the telemetry hook for {0} because its configuration file contains malformed JSON.
+ Skipped the telemetry hook for {0} because its configuration file contains malformed JSON.
+
+
+
+ Skipped the telemetry hook for {0} because its configuration has an unexpected shape.
+ Skipped the telemetry hook for {0} because its configuration has an unexpected shape.
+
+
+
+ Skipped the telemetry hook for {0} because its configuration could not be written.
+ Skipped the telemetry hook for {0} because its configuration could not be written.
+
+
+
+ Installed Aspire agent telemetry hooks for: {0}. Only Aspire skill, MCP tool, and reference-file usage is recorded. Opt out anytime by setting ASPIRE_CLI_TELEMETRY_OPTOUT=true.
+ Installed Aspire agent telemetry hooks for: {0}. Only Aspire skill, MCP tool, and reference-file usage is recorded. Opt out anytime by setting ASPIRE_CLI_TELEMETRY_OPTOUT=true.
+
+ Path to the workspace root directoryPath to the workspace root directory
diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.es.xlf
index 7672aa0cec8..448d3443bf4 100644
--- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.es.xlf
+++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.es.xlf
@@ -2,6 +2,46 @@
+
+ The AI agent client that produced the event (for example copilot-cli, claude-code, or vscode)
+ The AI agent client that produced the event (for example copilot-cli, claude-code, or vscode)
+
+
+
+ Record AI agent skill and tool usage telemetry. Invoked by the Aspire agent telemetry hook scripts; not intended to be run directly.
+ Record AI agent skill and tool usage telemetry. Invoked by the Aspire agent telemetry hook scripts; not intended to be run directly.
+
+
+
+ The telemetry event type (skill_invocation, tool_invocation, or reference_file_read)
+ The telemetry event type (skill_invocation, tool_invocation, or reference_file_read)
+
+
+
+ The Aspire skills-relative reference file path for a reference_file_read event
+ The Aspire skills-relative reference file path for a reference_file_read event
+
+
+
+ The opaque AI agent session identifier
+ The opaque AI agent session identifier
+
+
+
+ The Aspire skill name for a skill_invocation event
+ The Aspire skill name for a skill_invocation event
+
+
+
+ The timestamp recorded for the event
+ The timestamp recorded for the event
+
+
+
+ The Aspire MCP tool name for a tool_invocation event
+ The Aspire MCP tool name for a tool_invocation event
+
+ Embedded Aspire skills archive failed SHA-256 verification. Expected '{0}', got '{1}'.Embedded Aspire skills archive failed SHA-256 verification. Expected '{0}', got '{1}'.
@@ -122,6 +162,26 @@
Comma-separated list of skills to install. Bundle skills are loaded dynamically; CLI-provided skills include {0}. Use '{1}' or '{2}'
+
+ Skipped the telemetry hook for {0} because its configuration file contains malformed JSON.
+ Skipped the telemetry hook for {0} because its configuration file contains malformed JSON.
+
+
+
+ Skipped the telemetry hook for {0} because its configuration has an unexpected shape.
+ Skipped the telemetry hook for {0} because its configuration has an unexpected shape.
+
+
+
+ Skipped the telemetry hook for {0} because its configuration could not be written.
+ Skipped the telemetry hook for {0} because its configuration could not be written.
+
+
+
+ Installed Aspire agent telemetry hooks for: {0}. Only Aspire skill, MCP tool, and reference-file usage is recorded. Opt out anytime by setting ASPIRE_CLI_TELEMETRY_OPTOUT=true.
+ Installed Aspire agent telemetry hooks for: {0}. Only Aspire skill, MCP tool, and reference-file usage is recorded. Opt out anytime by setting ASPIRE_CLI_TELEMETRY_OPTOUT=true.
+
+ Path to the workspace root directoryPath to the workspace root directory
diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.fr.xlf
index 3b9bdef6f43..1ec62b0e29c 100644
--- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.fr.xlf
+++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.fr.xlf
@@ -2,6 +2,46 @@
+
+ The AI agent client that produced the event (for example copilot-cli, claude-code, or vscode)
+ The AI agent client that produced the event (for example copilot-cli, claude-code, or vscode)
+
+
+
+ Record AI agent skill and tool usage telemetry. Invoked by the Aspire agent telemetry hook scripts; not intended to be run directly.
+ Record AI agent skill and tool usage telemetry. Invoked by the Aspire agent telemetry hook scripts; not intended to be run directly.
+
+
+
+ The telemetry event type (skill_invocation, tool_invocation, or reference_file_read)
+ The telemetry event type (skill_invocation, tool_invocation, or reference_file_read)
+
+
+
+ The Aspire skills-relative reference file path for a reference_file_read event
+ The Aspire skills-relative reference file path for a reference_file_read event
+
+
+
+ The opaque AI agent session identifier
+ The opaque AI agent session identifier
+
+
+
+ The Aspire skill name for a skill_invocation event
+ The Aspire skill name for a skill_invocation event
+
+
+
+ The timestamp recorded for the event
+ The timestamp recorded for the event
+
+
+
+ The Aspire MCP tool name for a tool_invocation event
+ The Aspire MCP tool name for a tool_invocation event
+
+ Embedded Aspire skills archive failed SHA-256 verification. Expected '{0}', got '{1}'.Embedded Aspire skills archive failed SHA-256 verification. Expected '{0}', got '{1}'.
@@ -122,6 +162,26 @@
Comma-separated list of skills to install. Bundle skills are loaded dynamically; CLI-provided skills include {0}. Use '{1}' or '{2}'
+
+ Skipped the telemetry hook for {0} because its configuration file contains malformed JSON.
+ Skipped the telemetry hook for {0} because its configuration file contains malformed JSON.
+
+
+
+ Skipped the telemetry hook for {0} because its configuration has an unexpected shape.
+ Skipped the telemetry hook for {0} because its configuration has an unexpected shape.
+
+
+
+ Skipped the telemetry hook for {0} because its configuration could not be written.
+ Skipped the telemetry hook for {0} because its configuration could not be written.
+
+
+
+ Installed Aspire agent telemetry hooks for: {0}. Only Aspire skill, MCP tool, and reference-file usage is recorded. Opt out anytime by setting ASPIRE_CLI_TELEMETRY_OPTOUT=true.
+ Installed Aspire agent telemetry hooks for: {0}. Only Aspire skill, MCP tool, and reference-file usage is recorded. Opt out anytime by setting ASPIRE_CLI_TELEMETRY_OPTOUT=true.
+
+ Path to the workspace root directoryPath to the workspace root directory
diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.it.xlf
index 54a5f018e32..10976038805 100644
--- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.it.xlf
+++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.it.xlf
@@ -2,6 +2,46 @@
+
+ The AI agent client that produced the event (for example copilot-cli, claude-code, or vscode)
+ The AI agent client that produced the event (for example copilot-cli, claude-code, or vscode)
+
+
+
+ Record AI agent skill and tool usage telemetry. Invoked by the Aspire agent telemetry hook scripts; not intended to be run directly.
+ Record AI agent skill and tool usage telemetry. Invoked by the Aspire agent telemetry hook scripts; not intended to be run directly.
+
+
+
+ The telemetry event type (skill_invocation, tool_invocation, or reference_file_read)
+ The telemetry event type (skill_invocation, tool_invocation, or reference_file_read)
+
+
+
+ The Aspire skills-relative reference file path for a reference_file_read event
+ The Aspire skills-relative reference file path for a reference_file_read event
+
+
+
+ The opaque AI agent session identifier
+ The opaque AI agent session identifier
+
+
+
+ The Aspire skill name for a skill_invocation event
+ The Aspire skill name for a skill_invocation event
+
+
+
+ The timestamp recorded for the event
+ The timestamp recorded for the event
+
+
+
+ The Aspire MCP tool name for a tool_invocation event
+ The Aspire MCP tool name for a tool_invocation event
+
+ Embedded Aspire skills archive failed SHA-256 verification. Expected '{0}', got '{1}'.Embedded Aspire skills archive failed SHA-256 verification. Expected '{0}', got '{1}'.
@@ -122,6 +162,26 @@
Comma-separated list of skills to install. Bundle skills are loaded dynamically; CLI-provided skills include {0}. Use '{1}' or '{2}'
+
+ Skipped the telemetry hook for {0} because its configuration file contains malformed JSON.
+ Skipped the telemetry hook for {0} because its configuration file contains malformed JSON.
+
+
+
+ Skipped the telemetry hook for {0} because its configuration has an unexpected shape.
+ Skipped the telemetry hook for {0} because its configuration has an unexpected shape.
+
+
+
+ Skipped the telemetry hook for {0} because its configuration could not be written.
+ Skipped the telemetry hook for {0} because its configuration could not be written.
+
+
+
+ Installed Aspire agent telemetry hooks for: {0}. Only Aspire skill, MCP tool, and reference-file usage is recorded. Opt out anytime by setting ASPIRE_CLI_TELEMETRY_OPTOUT=true.
+ Installed Aspire agent telemetry hooks for: {0}. Only Aspire skill, MCP tool, and reference-file usage is recorded. Opt out anytime by setting ASPIRE_CLI_TELEMETRY_OPTOUT=true.
+
+ Path to the workspace root directoryPath to the workspace root directory
diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ja.xlf
index 40bdd09d2b1..c9930ee5cef 100644
--- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ja.xlf
+++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ja.xlf
@@ -2,6 +2,46 @@
+
+ The AI agent client that produced the event (for example copilot-cli, claude-code, or vscode)
+ The AI agent client that produced the event (for example copilot-cli, claude-code, or vscode)
+
+
+
+ Record AI agent skill and tool usage telemetry. Invoked by the Aspire agent telemetry hook scripts; not intended to be run directly.
+ Record AI agent skill and tool usage telemetry. Invoked by the Aspire agent telemetry hook scripts; not intended to be run directly.
+
+
+
+ The telemetry event type (skill_invocation, tool_invocation, or reference_file_read)
+ The telemetry event type (skill_invocation, tool_invocation, or reference_file_read)
+
+
+
+ The Aspire skills-relative reference file path for a reference_file_read event
+ The Aspire skills-relative reference file path for a reference_file_read event
+
+
+
+ The opaque AI agent session identifier
+ The opaque AI agent session identifier
+
+
+
+ The Aspire skill name for a skill_invocation event
+ The Aspire skill name for a skill_invocation event
+
+
+
+ The timestamp recorded for the event
+ The timestamp recorded for the event
+
+
+
+ The Aspire MCP tool name for a tool_invocation event
+ The Aspire MCP tool name for a tool_invocation event
+
+ Embedded Aspire skills archive failed SHA-256 verification. Expected '{0}', got '{1}'.Embedded Aspire skills archive failed SHA-256 verification. Expected '{0}', got '{1}'.
@@ -122,6 +162,26 @@
Comma-separated list of skills to install. Bundle skills are loaded dynamically; CLI-provided skills include {0}. Use '{1}' or '{2}'
+
+ Skipped the telemetry hook for {0} because its configuration file contains malformed JSON.
+ Skipped the telemetry hook for {0} because its configuration file contains malformed JSON.
+
+
+
+ Skipped the telemetry hook for {0} because its configuration has an unexpected shape.
+ Skipped the telemetry hook for {0} because its configuration has an unexpected shape.
+
+
+
+ Skipped the telemetry hook for {0} because its configuration could not be written.
+ Skipped the telemetry hook for {0} because its configuration could not be written.
+
+
+
+ Installed Aspire agent telemetry hooks for: {0}. Only Aspire skill, MCP tool, and reference-file usage is recorded. Opt out anytime by setting ASPIRE_CLI_TELEMETRY_OPTOUT=true.
+ Installed Aspire agent telemetry hooks for: {0}. Only Aspire skill, MCP tool, and reference-file usage is recorded. Opt out anytime by setting ASPIRE_CLI_TELEMETRY_OPTOUT=true.
+
+ Path to the workspace root directoryPath to the workspace root directory
diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ko.xlf
index 3eb70b1259c..aca8dfc72a7 100644
--- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ko.xlf
+++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ko.xlf
@@ -2,6 +2,46 @@
+
+ The AI agent client that produced the event (for example copilot-cli, claude-code, or vscode)
+ The AI agent client that produced the event (for example copilot-cli, claude-code, or vscode)
+
+
+
+ Record AI agent skill and tool usage telemetry. Invoked by the Aspire agent telemetry hook scripts; not intended to be run directly.
+ Record AI agent skill and tool usage telemetry. Invoked by the Aspire agent telemetry hook scripts; not intended to be run directly.
+
+
+
+ The telemetry event type (skill_invocation, tool_invocation, or reference_file_read)
+ The telemetry event type (skill_invocation, tool_invocation, or reference_file_read)
+
+
+
+ The Aspire skills-relative reference file path for a reference_file_read event
+ The Aspire skills-relative reference file path for a reference_file_read event
+
+
+
+ The opaque AI agent session identifier
+ The opaque AI agent session identifier
+
+
+
+ The Aspire skill name for a skill_invocation event
+ The Aspire skill name for a skill_invocation event
+
+
+
+ The timestamp recorded for the event
+ The timestamp recorded for the event
+
+
+
+ The Aspire MCP tool name for a tool_invocation event
+ The Aspire MCP tool name for a tool_invocation event
+
+ Embedded Aspire skills archive failed SHA-256 verification. Expected '{0}', got '{1}'.Embedded Aspire skills archive failed SHA-256 verification. Expected '{0}', got '{1}'.
@@ -122,6 +162,26 @@
Comma-separated list of skills to install. Bundle skills are loaded dynamically; CLI-provided skills include {0}. Use '{1}' or '{2}'
+
+ Skipped the telemetry hook for {0} because its configuration file contains malformed JSON.
+ Skipped the telemetry hook for {0} because its configuration file contains malformed JSON.
+
+
+
+ Skipped the telemetry hook for {0} because its configuration has an unexpected shape.
+ Skipped the telemetry hook for {0} because its configuration has an unexpected shape.
+
+
+
+ Skipped the telemetry hook for {0} because its configuration could not be written.
+ Skipped the telemetry hook for {0} because its configuration could not be written.
+
+
+
+ Installed Aspire agent telemetry hooks for: {0}. Only Aspire skill, MCP tool, and reference-file usage is recorded. Opt out anytime by setting ASPIRE_CLI_TELEMETRY_OPTOUT=true.
+ Installed Aspire agent telemetry hooks for: {0}. Only Aspire skill, MCP tool, and reference-file usage is recorded. Opt out anytime by setting ASPIRE_CLI_TELEMETRY_OPTOUT=true.
+
+ Path to the workspace root directoryPath to the workspace root directory
diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.pl.xlf
index 0b36fcbab51..8fab62f5550 100644
--- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.pl.xlf
+++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.pl.xlf
@@ -2,6 +2,46 @@
+
+ The AI agent client that produced the event (for example copilot-cli, claude-code, or vscode)
+ The AI agent client that produced the event (for example copilot-cli, claude-code, or vscode)
+
+
+
+ Record AI agent skill and tool usage telemetry. Invoked by the Aspire agent telemetry hook scripts; not intended to be run directly.
+ Record AI agent skill and tool usage telemetry. Invoked by the Aspire agent telemetry hook scripts; not intended to be run directly.
+
+
+
+ The telemetry event type (skill_invocation, tool_invocation, or reference_file_read)
+ The telemetry event type (skill_invocation, tool_invocation, or reference_file_read)
+
+
+
+ The Aspire skills-relative reference file path for a reference_file_read event
+ The Aspire skills-relative reference file path for a reference_file_read event
+
+
+
+ The opaque AI agent session identifier
+ The opaque AI agent session identifier
+
+
+
+ The Aspire skill name for a skill_invocation event
+ The Aspire skill name for a skill_invocation event
+
+
+
+ The timestamp recorded for the event
+ The timestamp recorded for the event
+
+
+
+ The Aspire MCP tool name for a tool_invocation event
+ The Aspire MCP tool name for a tool_invocation event
+
+ Embedded Aspire skills archive failed SHA-256 verification. Expected '{0}', got '{1}'.Embedded Aspire skills archive failed SHA-256 verification. Expected '{0}', got '{1}'.
@@ -122,6 +162,26 @@
Comma-separated list of skills to install. Bundle skills are loaded dynamically; CLI-provided skills include {0}. Use '{1}' or '{2}'
+
+ Skipped the telemetry hook for {0} because its configuration file contains malformed JSON.
+ Skipped the telemetry hook for {0} because its configuration file contains malformed JSON.
+
+
+
+ Skipped the telemetry hook for {0} because its configuration has an unexpected shape.
+ Skipped the telemetry hook for {0} because its configuration has an unexpected shape.
+
+
+
+ Skipped the telemetry hook for {0} because its configuration could not be written.
+ Skipped the telemetry hook for {0} because its configuration could not be written.
+
+
+
+ Installed Aspire agent telemetry hooks for: {0}. Only Aspire skill, MCP tool, and reference-file usage is recorded. Opt out anytime by setting ASPIRE_CLI_TELEMETRY_OPTOUT=true.
+ Installed Aspire agent telemetry hooks for: {0}. Only Aspire skill, MCP tool, and reference-file usage is recorded. Opt out anytime by setting ASPIRE_CLI_TELEMETRY_OPTOUT=true.
+
+ Path to the workspace root directoryPath to the workspace root directory
diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.pt-BR.xlf
index da570f3fc2b..2d60226fef3 100644
--- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.pt-BR.xlf
+++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.pt-BR.xlf
@@ -2,6 +2,46 @@
+
+ The AI agent client that produced the event (for example copilot-cli, claude-code, or vscode)
+ The AI agent client that produced the event (for example copilot-cli, claude-code, or vscode)
+
+
+
+ Record AI agent skill and tool usage telemetry. Invoked by the Aspire agent telemetry hook scripts; not intended to be run directly.
+ Record AI agent skill and tool usage telemetry. Invoked by the Aspire agent telemetry hook scripts; not intended to be run directly.
+
+
+
+ The telemetry event type (skill_invocation, tool_invocation, or reference_file_read)
+ The telemetry event type (skill_invocation, tool_invocation, or reference_file_read)
+
+
+
+ The Aspire skills-relative reference file path for a reference_file_read event
+ The Aspire skills-relative reference file path for a reference_file_read event
+
+
+
+ The opaque AI agent session identifier
+ The opaque AI agent session identifier
+
+
+
+ The Aspire skill name for a skill_invocation event
+ The Aspire skill name for a skill_invocation event
+
+
+
+ The timestamp recorded for the event
+ The timestamp recorded for the event
+
+
+
+ The Aspire MCP tool name for a tool_invocation event
+ The Aspire MCP tool name for a tool_invocation event
+
+ Embedded Aspire skills archive failed SHA-256 verification. Expected '{0}', got '{1}'.Embedded Aspire skills archive failed SHA-256 verification. Expected '{0}', got '{1}'.
@@ -122,6 +162,26 @@
Comma-separated list of skills to install. Bundle skills are loaded dynamically; CLI-provided skills include {0}. Use '{1}' or '{2}'
+
+ Skipped the telemetry hook for {0} because its configuration file contains malformed JSON.
+ Skipped the telemetry hook for {0} because its configuration file contains malformed JSON.
+
+
+
+ Skipped the telemetry hook for {0} because its configuration has an unexpected shape.
+ Skipped the telemetry hook for {0} because its configuration has an unexpected shape.
+
+
+
+ Skipped the telemetry hook for {0} because its configuration could not be written.
+ Skipped the telemetry hook for {0} because its configuration could not be written.
+
+
+
+ Installed Aspire agent telemetry hooks for: {0}. Only Aspire skill, MCP tool, and reference-file usage is recorded. Opt out anytime by setting ASPIRE_CLI_TELEMETRY_OPTOUT=true.
+ Installed Aspire agent telemetry hooks for: {0}. Only Aspire skill, MCP tool, and reference-file usage is recorded. Opt out anytime by setting ASPIRE_CLI_TELEMETRY_OPTOUT=true.
+
+ Path to the workspace root directoryPath to the workspace root directory
diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ru.xlf
index c6a31b4ddf0..99877ccc6bc 100644
--- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ru.xlf
+++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ru.xlf
@@ -2,6 +2,46 @@
+
+ The AI agent client that produced the event (for example copilot-cli, claude-code, or vscode)
+ The AI agent client that produced the event (for example copilot-cli, claude-code, or vscode)
+
+
+
+ Record AI agent skill and tool usage telemetry. Invoked by the Aspire agent telemetry hook scripts; not intended to be run directly.
+ Record AI agent skill and tool usage telemetry. Invoked by the Aspire agent telemetry hook scripts; not intended to be run directly.
+
+
+
+ The telemetry event type (skill_invocation, tool_invocation, or reference_file_read)
+ The telemetry event type (skill_invocation, tool_invocation, or reference_file_read)
+
+
+
+ The Aspire skills-relative reference file path for a reference_file_read event
+ The Aspire skills-relative reference file path for a reference_file_read event
+
+
+
+ The opaque AI agent session identifier
+ The opaque AI agent session identifier
+
+
+
+ The Aspire skill name for a skill_invocation event
+ The Aspire skill name for a skill_invocation event
+
+
+
+ The timestamp recorded for the event
+ The timestamp recorded for the event
+
+
+
+ The Aspire MCP tool name for a tool_invocation event
+ The Aspire MCP tool name for a tool_invocation event
+
+ Embedded Aspire skills archive failed SHA-256 verification. Expected '{0}', got '{1}'.Embedded Aspire skills archive failed SHA-256 verification. Expected '{0}', got '{1}'.
@@ -122,6 +162,26 @@
Comma-separated list of skills to install. Bundle skills are loaded dynamically; CLI-provided skills include {0}. Use '{1}' or '{2}'
+
+ Skipped the telemetry hook for {0} because its configuration file contains malformed JSON.
+ Skipped the telemetry hook for {0} because its configuration file contains malformed JSON.
+
+
+
+ Skipped the telemetry hook for {0} because its configuration has an unexpected shape.
+ Skipped the telemetry hook for {0} because its configuration has an unexpected shape.
+
+
+
+ Skipped the telemetry hook for {0} because its configuration could not be written.
+ Skipped the telemetry hook for {0} because its configuration could not be written.
+
+
+
+ Installed Aspire agent telemetry hooks for: {0}. Only Aspire skill, MCP tool, and reference-file usage is recorded. Opt out anytime by setting ASPIRE_CLI_TELEMETRY_OPTOUT=true.
+ Installed Aspire agent telemetry hooks for: {0}. Only Aspire skill, MCP tool, and reference-file usage is recorded. Opt out anytime by setting ASPIRE_CLI_TELEMETRY_OPTOUT=true.
+
+ Path to the workspace root directoryPath to the workspace root directory
diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.tr.xlf
index 48ff8217db2..6acc6f2696c 100644
--- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.tr.xlf
+++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.tr.xlf
@@ -2,6 +2,46 @@
+
+ The AI agent client that produced the event (for example copilot-cli, claude-code, or vscode)
+ The AI agent client that produced the event (for example copilot-cli, claude-code, or vscode)
+
+
+
+ Record AI agent skill and tool usage telemetry. Invoked by the Aspire agent telemetry hook scripts; not intended to be run directly.
+ Record AI agent skill and tool usage telemetry. Invoked by the Aspire agent telemetry hook scripts; not intended to be run directly.
+
+
+
+ The telemetry event type (skill_invocation, tool_invocation, or reference_file_read)
+ The telemetry event type (skill_invocation, tool_invocation, or reference_file_read)
+
+
+
+ The Aspire skills-relative reference file path for a reference_file_read event
+ The Aspire skills-relative reference file path for a reference_file_read event
+
+
+
+ The opaque AI agent session identifier
+ The opaque AI agent session identifier
+
+
+
+ The Aspire skill name for a skill_invocation event
+ The Aspire skill name for a skill_invocation event
+
+
+
+ The timestamp recorded for the event
+ The timestamp recorded for the event
+
+
+
+ The Aspire MCP tool name for a tool_invocation event
+ The Aspire MCP tool name for a tool_invocation event
+
+ Embedded Aspire skills archive failed SHA-256 verification. Expected '{0}', got '{1}'.Embedded Aspire skills archive failed SHA-256 verification. Expected '{0}', got '{1}'.
@@ -122,6 +162,26 @@
Comma-separated list of skills to install. Bundle skills are loaded dynamically; CLI-provided skills include {0}. Use '{1}' or '{2}'
+
+ Skipped the telemetry hook for {0} because its configuration file contains malformed JSON.
+ Skipped the telemetry hook for {0} because its configuration file contains malformed JSON.
+
+
+
+ Skipped the telemetry hook for {0} because its configuration has an unexpected shape.
+ Skipped the telemetry hook for {0} because its configuration has an unexpected shape.
+
+
+
+ Skipped the telemetry hook for {0} because its configuration could not be written.
+ Skipped the telemetry hook for {0} because its configuration could not be written.
+
+
+
+ Installed Aspire agent telemetry hooks for: {0}. Only Aspire skill, MCP tool, and reference-file usage is recorded. Opt out anytime by setting ASPIRE_CLI_TELEMETRY_OPTOUT=true.
+ Installed Aspire agent telemetry hooks for: {0}. Only Aspire skill, MCP tool, and reference-file usage is recorded. Opt out anytime by setting ASPIRE_CLI_TELEMETRY_OPTOUT=true.
+
+ Path to the workspace root directoryPath to the workspace root directory
diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.zh-Hans.xlf
index febfd1c9f58..5d2e55cbf9a 100644
--- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.zh-Hans.xlf
+++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.zh-Hans.xlf
@@ -2,6 +2,46 @@
+
+ The AI agent client that produced the event (for example copilot-cli, claude-code, or vscode)
+ The AI agent client that produced the event (for example copilot-cli, claude-code, or vscode)
+
+
+
+ Record AI agent skill and tool usage telemetry. Invoked by the Aspire agent telemetry hook scripts; not intended to be run directly.
+ Record AI agent skill and tool usage telemetry. Invoked by the Aspire agent telemetry hook scripts; not intended to be run directly.
+
+
+
+ The telemetry event type (skill_invocation, tool_invocation, or reference_file_read)
+ The telemetry event type (skill_invocation, tool_invocation, or reference_file_read)
+
+
+
+ The Aspire skills-relative reference file path for a reference_file_read event
+ The Aspire skills-relative reference file path for a reference_file_read event
+
+
+
+ The opaque AI agent session identifier
+ The opaque AI agent session identifier
+
+
+
+ The Aspire skill name for a skill_invocation event
+ The Aspire skill name for a skill_invocation event
+
+
+
+ The timestamp recorded for the event
+ The timestamp recorded for the event
+
+
+
+ The Aspire MCP tool name for a tool_invocation event
+ The Aspire MCP tool name for a tool_invocation event
+
+ Embedded Aspire skills archive failed SHA-256 verification. Expected '{0}', got '{1}'.Embedded Aspire skills archive failed SHA-256 verification. Expected '{0}', got '{1}'.
@@ -122,6 +162,26 @@
Comma-separated list of skills to install. Bundle skills are loaded dynamically; CLI-provided skills include {0}. Use '{1}' or '{2}'
+
+ Skipped the telemetry hook for {0} because its configuration file contains malformed JSON.
+ Skipped the telemetry hook for {0} because its configuration file contains malformed JSON.
+
+
+
+ Skipped the telemetry hook for {0} because its configuration has an unexpected shape.
+ Skipped the telemetry hook for {0} because its configuration has an unexpected shape.
+
+
+
+ Skipped the telemetry hook for {0} because its configuration could not be written.
+ Skipped the telemetry hook for {0} because its configuration could not be written.
+
+
+
+ Installed Aspire agent telemetry hooks for: {0}. Only Aspire skill, MCP tool, and reference-file usage is recorded. Opt out anytime by setting ASPIRE_CLI_TELEMETRY_OPTOUT=true.
+ Installed Aspire agent telemetry hooks for: {0}. Only Aspire skill, MCP tool, and reference-file usage is recorded. Opt out anytime by setting ASPIRE_CLI_TELEMETRY_OPTOUT=true.
+
+ Path to the workspace root directoryPath to the workspace root directory
diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.zh-Hant.xlf
index 6c4d17d35cd..582bd17a16a 100644
--- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.zh-Hant.xlf
+++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.zh-Hant.xlf
@@ -2,6 +2,46 @@
+
+ The AI agent client that produced the event (for example copilot-cli, claude-code, or vscode)
+ The AI agent client that produced the event (for example copilot-cli, claude-code, or vscode)
+
+
+
+ Record AI agent skill and tool usage telemetry. Invoked by the Aspire agent telemetry hook scripts; not intended to be run directly.
+ Record AI agent skill and tool usage telemetry. Invoked by the Aspire agent telemetry hook scripts; not intended to be run directly.
+
+
+
+ The telemetry event type (skill_invocation, tool_invocation, or reference_file_read)
+ The telemetry event type (skill_invocation, tool_invocation, or reference_file_read)
+
+
+
+ The Aspire skills-relative reference file path for a reference_file_read event
+ The Aspire skills-relative reference file path for a reference_file_read event
+
+
+
+ The opaque AI agent session identifier
+ The opaque AI agent session identifier
+
+
+
+ The Aspire skill name for a skill_invocation event
+ The Aspire skill name for a skill_invocation event
+
+
+
+ The timestamp recorded for the event
+ The timestamp recorded for the event
+
+
+
+ The Aspire MCP tool name for a tool_invocation event
+ The Aspire MCP tool name for a tool_invocation event
+
+ Embedded Aspire skills archive failed SHA-256 verification. Expected '{0}', got '{1}'.Embedded Aspire skills archive failed SHA-256 verification. Expected '{0}', got '{1}'.
@@ -122,6 +162,26 @@
Comma-separated list of skills to install. Bundle skills are loaded dynamically; CLI-provided skills include {0}. Use '{1}' or '{2}'
+
+ Skipped the telemetry hook for {0} because its configuration file contains malformed JSON.
+ Skipped the telemetry hook for {0} because its configuration file contains malformed JSON.
+
+
+
+ Skipped the telemetry hook for {0} because its configuration has an unexpected shape.
+ Skipped the telemetry hook for {0} because its configuration has an unexpected shape.
+
+
+
+ Skipped the telemetry hook for {0} because its configuration could not be written.
+ Skipped the telemetry hook for {0} because its configuration could not be written.
+
+
+
+ Installed Aspire agent telemetry hooks for: {0}. Only Aspire skill, MCP tool, and reference-file usage is recorded. Opt out anytime by setting ASPIRE_CLI_TELEMETRY_OPTOUT=true.
+ Installed Aspire agent telemetry hooks for: {0}. Only Aspire skill, MCP tool, and reference-file usage is recorded. Opt out anytime by setting ASPIRE_CLI_TELEMETRY_OPTOUT=true.
+
+ Path to the workspace root directoryPath to the workspace root directory
diff --git a/src/Aspire.Cli/Telemetry/AgentTelemetryInvocation.cs b/src/Aspire.Cli/Telemetry/AgentTelemetryInvocation.cs
new file mode 100644
index 00000000000..24d51d777ed
--- /dev/null
+++ b/src/Aspire.Cli/Telemetry/AgentTelemetryInvocation.cs
@@ -0,0 +1,39 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Aspire.Cli.Telemetry;
+
+///
+/// Detects whether the current CLI invocation targets the hidden aspire agent telemetry
+/// command used by the agent telemetry hook scripts.
+///
+///
+/// This check runs against the raw command-line arguments before the host and telemetry
+/// pipeline are built, which lets the CLI suppress the generic aspire/cli/main reported
+/// span so a hook event emits only the single dedicated aspire/cli/agent_telemetry span
+/// rather than two spans.
+/// The hook scripts always invoke the command as aspire agent telemetry ... with no global
+/// options preceding the command path, so the detector requires agent and telemetry
+/// to be the first two arguments. Matching only the leading tokens (rather than anywhere in the
+/// argument list) avoids false positives where agent/telemetry appear as option or
+/// positional values of an unrelated command (for example aspire config set agent telemetry).
+/// A manual invocation that places a global option before the command path is intentionally not
+/// matched; the only consequence is that the generic main span is not suppressed for that rare case.
+///
+internal static class AgentTelemetryInvocation
+{
+ private const string AgentCommandName = "agent";
+ private const string TelemetryCommandName = "telemetry";
+
+ ///
+ /// Returns when represents an
+ /// aspire agent telemetry invocation.
+ ///
+ /// The raw command-line arguments passed to the CLI.
+ public static bool Matches(string[]? args)
+ {
+ return args is { Length: >= 2 } &&
+ string.Equals(args[0], AgentCommandName, StringComparison.Ordinal) &&
+ string.Equals(args[1], TelemetryCommandName, StringComparison.Ordinal);
+ }
+}
diff --git a/src/Aspire.Cli/Telemetry/TelemetryConstants.cs b/src/Aspire.Cli/Telemetry/TelemetryConstants.cs
index f52808223c8..075126b9eeb 100644
--- a/src/Aspire.Cli/Telemetry/TelemetryConstants.cs
+++ b/src/Aspire.Cli/Telemetry/TelemetryConstants.cs
@@ -129,6 +129,46 @@ internal static class Tags
/// Absence of this tag indicates success.
///
public const string ErrorType = "error.type";
+
+ ///
+ /// Tag for the AI agent telemetry event type forwarded by the hook scripts.
+ /// One of skill_invocation, tool_invocation, or reference_file_read.
+ ///
+ public const string AgentEventType = "aspire.cli.agent.event_type";
+
+ ///
+ /// Tag for the AI agent client that produced the event (for example copilot-cli,
+ /// claude-code, or vscode).
+ ///
+ public const string AgentClientName = "aspire.cli.agent.client_name";
+
+ ///
+ /// Tag for the AI agent session identifier. This is an opaque per-session GUID and does
+ /// not identify a user or machine.
+ ///
+ public const string AgentSessionId = "aspire.cli.agent.session_id";
+
+ ///
+ /// Tag for the Aspire skill name associated with a skill_invocation event.
+ ///
+ public const string AgentSkillName = "aspire.cli.agent.skill_name";
+
+ ///
+ /// Tag for the Aspire MCP tool name associated with a tool_invocation event.
+ ///
+ public const string AgentToolName = "aspire.cli.agent.tool_name";
+
+ ///
+ /// Tag for the Aspire skills-relative reference file path associated with a
+ /// reference_file_read event. Only the path after the skills/ segment is
+ /// recorded so that no absolute path, repository name, or user name is captured.
+ ///
+ public const string AgentFileReference = "aspire.cli.agent.file_reference";
+
+ ///
+ /// Tag for the timestamp the hook recorded for the AI agent event.
+ ///
+ public const string AgentEventTimestamp = "aspire.cli.agent.event_timestamp";
}
///
@@ -150,6 +190,12 @@ internal static class Activities
/// Activity name for running an app host.
///
public const string RunAppHost = "aspire/cli/run_apphost";
+
+ ///
+ /// Activity name for an AI agent skill/tool/reference telemetry event forwarded by the
+ /// agent telemetry hook scripts.
+ ///
+ public const string AgentTelemetry = "aspire/cli/agent_telemetry";
}
///
diff --git a/src/Aspire.Cli/Telemetry/TelemetryManager.cs b/src/Aspire.Cli/Telemetry/TelemetryManager.cs
index f23e33396f1..bb3bfb14f00 100644
--- a/src/Aspire.Cli/Telemetry/TelemetryManager.cs
+++ b/src/Aspire.Cli/Telemetry/TelemetryManager.cs
@@ -45,6 +45,12 @@ internal sealed class TelemetryManager : IDisposable
#endif
private const int ProfilingForceFlushTimeoutMilliseconds = 5000;
+ // The agent telemetry command runs fire-and-forget from an agent hook and exits immediately,
+ // so the short Release shutdown flush (200ms) is not enough to reliably export the single
+ // just-created span. The command path force-flushes the reported provider with this larger
+ // bound before exit so the event is not silently dropped.
+ private const int ReportedForceFlushTimeoutMilliseconds = 3000;
+
private readonly TracerProvider? _azureMonitorProvider;
private readonly TracerProvider? _profilingProvider;
private readonly TracerProvider? _debugDiagnosticProvider;
@@ -60,6 +66,7 @@ public TelemetryManager(IConfiguration configuration, string[]? args = null)
{
// Don't send telemetry for informational commands or if the user has opted out.
var hasOptOutArg = args?.Any(a => CommonOptionNames.InformationalOptionNames.Contains(a)) ?? false;
+
var telemetryOptOut = hasOptOutArg || configuration.GetBool(AspireCliTelemetry.TelemetryOptOutConfigKey, defaultValue: false);
var profilingEnabled =
@@ -176,6 +183,24 @@ public Task ForceFlushProfilingAsync()
});
}
+ ///
+ /// Flushes reported telemetry without shutting down other telemetry providers.
+ ///
+ ///
+ /// Used by the aspire agent telemetry command, which is invoked fire-and-forget from an
+ /// agent hook and exits immediately. The normal shutdown flush window is too short to reliably
+ /// drain a single just-created span, so this bounded flush ensures the event leaves the process.
+ ///
+ public Task ForceFlushReportedAsync()
+ {
+ // See ForceFlushProfilingAsync for why this runs the synchronous, bounded
+ // ForceFlush(int) on the thread pool rather than taking a CancellationToken.
+ return Task.Run(() =>
+ {
+ _azureMonitorProvider?.ForceFlush(ReportedForceFlushTimeoutMilliseconds);
+ });
+ }
+
///
/// Shuts down the telemetry providers, flushing any pending telemetry.
///
diff --git a/tests/Aspire.Cli.Tests/Agents/HookCommandFormatterTests.cs b/tests/Aspire.Cli.Tests/Agents/HookCommandFormatterTests.cs
new file mode 100644
index 00000000000..6fb179c32ea
--- /dev/null
+++ b/tests/Aspire.Cli.Tests/Agents/HookCommandFormatterTests.cs
@@ -0,0 +1,43 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Aspire.Cli.Agents.Hooks;
+
+namespace Aspire.Cli.Tests.Agents;
+
+public class HookCommandFormatterTests
+{
+ [Theory]
+ [InlineData("/home/user/.aspire/hooks/track-telemetry.sh", "'/home/user/.aspire/hooks/track-telemetry.sh'")]
+ [InlineData("/home/space dir/x.sh", "'/home/space dir/x.sh'")]
+ // bash single quotes are literal, so an apostrophe closes, escapes, and reopens: ' -> '\''
+ [InlineData("/home/o'brien/x.sh", "'/home/o'\\''brien/x.sh'")]
+ public void QuoteForBash_QuotesAndEscapesApostrophes(string input, string expected)
+ {
+ Assert.Equal(expected, HookCommandFormatter.QuoteForBash(input));
+ }
+
+ [Theory]
+ [InlineData(@"C:\Users\user\.aspire\hooks\track-telemetry.ps1", @"'C:\Users\user\.aspire\hooks\track-telemetry.ps1'")]
+ [InlineData(@"C:\Users\space dir\x.ps1", @"'C:\Users\space dir\x.ps1'")]
+ // PowerShell single quotes are literal, so an apostrophe is doubled: ' -> ''
+ [InlineData(@"C:\Users\o'brien\x.ps1", @"'C:\Users\o''brien\x.ps1'")]
+ public void QuoteForPowerShell_QuotesAndDoublesApostrophes(string input, string expected)
+ {
+ Assert.Equal(expected, HookCommandFormatter.QuoteForPowerShell(input));
+ }
+
+ [Fact]
+ public void BuildBashCommand_PrefixesBash()
+ {
+ Assert.Equal("bash '/x/y.sh'", HookCommandFormatter.BuildBashCommand("/x/y.sh"));
+ }
+
+ [Fact]
+ public void BuildPwshCommand_UsesPwshWithNoProfileBypassFile()
+ {
+ Assert.Equal(
+ @"pwsh -NoProfile -ExecutionPolicy Bypass -File 'C:\x\y.ps1'",
+ HookCommandFormatter.BuildPwshCommand(@"C:\x\y.ps1"));
+ }
+}
diff --git a/tests/Aspire.Cli.Tests/Agents/TelemetryHookConfiguratorTests.cs b/tests/Aspire.Cli.Tests/Agents/TelemetryHookConfiguratorTests.cs
new file mode 100644
index 00000000000..2cf5c900434
--- /dev/null
+++ b/tests/Aspire.Cli.Tests/Agents/TelemetryHookConfiguratorTests.cs
@@ -0,0 +1,289 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Text.Json.Nodes;
+using Aspire.Cli.Agents;
+using Aspire.Cli.Agents.Hooks;
+using Aspire.Cli.Tests.Utils;
+using Microsoft.AspNetCore.InternalTesting;
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace Aspire.Cli.Tests.Agents;
+
+public class TelemetryHookConfiguratorTests(ITestOutputHelper outputHelper)
+{
+ [Fact]
+ public async Task ConfigureAsync_WritesCopilotUserHook_WithExpectedShape()
+ {
+ using var workspace = TemporaryWorkspace.Create(outputHelper);
+ var home = workspace.CreateDirectory("home");
+ var configurator = CreateConfigurator(workspace, home);
+
+ var result = await configurator.ConfigureAsync([AgentClientKind.CopilotCli], CancellationToken.None).DefaultTimeout();
+
+ Assert.Contains(AgentClientKind.CopilotCli, result.ConfiguredClients);
+ Assert.Empty(result.Skipped);
+
+ var hookFile = Path.Combine(home.FullName, ".copilot", "hooks", "aspire-telemetry.json");
+ Assert.True(File.Exists(hookFile));
+
+ var root = JsonNode.Parse(await File.ReadAllTextAsync(hookFile).DefaultTimeout())!.AsObject();
+ Assert.Equal(1, (int)root["version"]!);
+
+ var entry = root["hooks"]!["postToolUse"]!.AsArray()[0]!.AsObject();
+ Assert.Equal("command", (string)entry["type"]!);
+ Assert.Equal(30, (int)entry["timeoutSec"]!);
+ Assert.Contains("track-telemetry.sh", (string)entry["bash"]!);
+ Assert.StartsWith("bash ", (string)entry["bash"]!);
+ Assert.Contains("track-telemetry.ps1", (string)entry["powershell"]!);
+ Assert.Contains("-File ", (string)entry["powershell"]!);
+ // Copilot CLI requires PowerShell 7+ on Windows, so the hook must invoke pwsh, not Windows PowerShell.
+ Assert.StartsWith("pwsh ", (string)entry["powershell"]!);
+ }
+
+ [Fact]
+ public async Task ConfigureAsync_HonorsCopilotHomeEnvironmentVariable()
+ {
+ using var workspace = TemporaryWorkspace.Create(outputHelper);
+ var home = workspace.CreateDirectory("home");
+ var copilotHome = workspace.CreateDirectory("custom-copilot");
+ var configurator = CreateConfigurator(workspace, home, new Dictionary
+ {
+ ["COPILOT_HOME"] = copilotHome.FullName,
+ });
+
+ await configurator.ConfigureAsync([AgentClientKind.CopilotCli], CancellationToken.None).DefaultTimeout();
+
+ Assert.True(File.Exists(Path.Combine(copilotHome.FullName, "hooks", "aspire-telemetry.json")));
+ Assert.False(Directory.Exists(Path.Combine(home.FullName, ".copilot")));
+ }
+
+ [Fact]
+ public async Task ConfigureAsync_WritesClaudeUserHook_WithTimeout()
+ {
+ using var workspace = TemporaryWorkspace.Create(outputHelper);
+ var home = workspace.CreateDirectory("home");
+ var configurator = CreateConfigurator(workspace, home);
+
+ var result = await configurator.ConfigureAsync([AgentClientKind.ClaudeCode], CancellationToken.None).DefaultTimeout();
+
+ Assert.Contains(AgentClientKind.ClaudeCode, result.ConfiguredClients);
+ Assert.Empty(result.Skipped);
+
+ var postToolUse = await ReadClaudePostToolUseAsync(home).DefaultTimeout();
+ var ourGroups = CountAspireGroups(postToolUse);
+ Assert.Equal(1, ourGroups);
+
+ var entry = FindAspireHook(postToolUse);
+ Assert.Equal("command", (string)entry["type"]!);
+ Assert.Equal(30, (int)entry["timeout"]!);
+
+ // Claude uses exec form (command + args): the executable is spawned directly and the script path is
+ // a discrete argument, not part of a shell command string.
+ var execCommand = (string)entry["command"]!;
+ var args = entry["args"]!.AsArray().Select(a => (string)a!).ToArray();
+ if (OperatingSystem.IsWindows())
+ {
+ Assert.Equal("pwsh", execCommand);
+ Assert.Contains("-File", args);
+ Assert.Contains(args, a => a.EndsWith("track-telemetry.ps1", StringComparison.OrdinalIgnoreCase));
+ }
+ else
+ {
+ Assert.Equal("bash", execCommand);
+ Assert.Contains(args, a => a.EndsWith("track-telemetry.sh", StringComparison.OrdinalIgnoreCase));
+ }
+ }
+
+ [Fact]
+ public async Task ConfigureAsync_IsIdempotent_ForClaude()
+ {
+ using var workspace = TemporaryWorkspace.Create(outputHelper);
+ var home = workspace.CreateDirectory("home");
+ var configurator = CreateConfigurator(workspace, home);
+
+ await configurator.ConfigureAsync([AgentClientKind.ClaudeCode], CancellationToken.None).DefaultTimeout();
+ await configurator.ConfigureAsync([AgentClientKind.ClaudeCode], CancellationToken.None).DefaultTimeout();
+
+ var postToolUse = await ReadClaudePostToolUseAsync(home).DefaultTimeout();
+ Assert.Equal(1, CountAspireGroups(postToolUse));
+ }
+
+ [Fact]
+ public async Task ConfigureAsync_PreservesExistingClaudeConfig()
+ {
+ using var workspace = TemporaryWorkspace.Create(outputHelper);
+ var home = workspace.CreateDirectory("home");
+ var claudeDirectory = Directory.CreateDirectory(Path.Combine(home.FullName, ".claude"));
+ var settingsPath = Path.Combine(claudeDirectory.FullName, "settings.json");
+
+ var existing = new JsonObject
+ {
+ ["model"] = "claude-opus",
+ ["hooks"] = new JsonObject
+ {
+ ["PostToolUse"] = new JsonArray(
+ new JsonObject
+ {
+ ["matcher"] = "Write",
+ ["hooks"] = new JsonArray(
+ new JsonObject
+ {
+ ["type"] = "command",
+ ["command"] = "echo existing",
+ }),
+ }),
+ },
+ };
+ await File.WriteAllTextAsync(settingsPath, existing.ToJsonString()).DefaultTimeout();
+
+ var configurator = CreateConfigurator(workspace, home);
+ await configurator.ConfigureAsync([AgentClientKind.ClaudeCode], CancellationToken.None).DefaultTimeout();
+
+ var root = JsonNode.Parse(await File.ReadAllTextAsync(settingsPath).DefaultTimeout())!.AsObject();
+ Assert.Equal("claude-opus", (string)root["model"]!);
+
+ var postToolUse = root["hooks"]!["PostToolUse"]!.AsArray();
+ Assert.Contains(postToolUse, group => GroupContainsCommand(group, "echo existing"));
+ Assert.Equal(1, CountAspireGroups(postToolUse));
+ }
+
+ [Fact]
+ public async Task ConfigureAsync_SkipsClaude_WhenSettingsAreMalformed()
+ {
+ using var workspace = TemporaryWorkspace.Create(outputHelper);
+ var home = workspace.CreateDirectory("home");
+ var claudeDirectory = Directory.CreateDirectory(Path.Combine(home.FullName, ".claude"));
+ var settingsPath = Path.Combine(claudeDirectory.FullName, "settings.json");
+ const string malformed = "{ this is not valid json";
+ await File.WriteAllTextAsync(settingsPath, malformed).DefaultTimeout();
+
+ var configurator = CreateConfigurator(workspace, home);
+ var result = await configurator.ConfigureAsync([AgentClientKind.ClaudeCode], CancellationToken.None).DefaultTimeout();
+
+ Assert.DoesNotContain(AgentClientKind.ClaudeCode, result.ConfiguredClients);
+ Assert.Contains(result.Skipped, s => s.Client == AgentClientKind.ClaudeCode && s.Reason == TelemetryHookSkipReason.MalformedConfig);
+ // The malformed file must be left untouched, never clobbered.
+ Assert.Equal(malformed, await File.ReadAllTextAsync(settingsPath).DefaultTimeout());
+ }
+
+ [Fact]
+ public async Task ConfigureAsync_SkipsClaude_WhenHooksShapeIsUnexpected()
+ {
+ using var workspace = TemporaryWorkspace.Create(outputHelper);
+ var home = workspace.CreateDirectory("home");
+ var claudeDirectory = Directory.CreateDirectory(Path.Combine(home.FullName, ".claude"));
+ var settingsPath = Path.Combine(claudeDirectory.FullName, "settings.json");
+ const string unexpected = "{\"hooks\":\"not-an-object\"}";
+ await File.WriteAllTextAsync(settingsPath, unexpected).DefaultTimeout();
+
+ var configurator = CreateConfigurator(workspace, home);
+ var result = await configurator.ConfigureAsync([AgentClientKind.ClaudeCode], CancellationToken.None).DefaultTimeout();
+
+ Assert.Contains(result.Skipped, s => s.Client == AgentClientKind.ClaudeCode && s.Reason == TelemetryHookSkipReason.UnexpectedConfigShape);
+ Assert.Equal(unexpected, await File.ReadAllTextAsync(settingsPath).DefaultTimeout());
+ }
+
+ [Fact]
+ public async Task ConfigureAsync_SkipsClaude_WhenSettingsRootIsNotAnObject()
+ {
+ using var workspace = TemporaryWorkspace.Create(outputHelper);
+ var home = workspace.CreateDirectory("home");
+ var claudeDirectory = Directory.CreateDirectory(Path.Combine(home.FullName, ".claude"));
+ var settingsPath = Path.Combine(claudeDirectory.FullName, "settings.json");
+ // Valid JSON, but the root is an array rather than an object. JsonNode.AsObject() throws
+ // InvalidOperationException on this input, so the configurator must skip it like any other
+ // unrecognized shape instead of letting that exception crash `agent init`.
+ const string nonObjectRoot = "[1, 2, 3]";
+ await File.WriteAllTextAsync(settingsPath, nonObjectRoot).DefaultTimeout();
+
+ var configurator = CreateConfigurator(workspace, home);
+ var result = await configurator.ConfigureAsync([AgentClientKind.ClaudeCode], CancellationToken.None).DefaultTimeout();
+
+ Assert.Contains(result.Skipped, s => s.Client == AgentClientKind.ClaudeCode && s.Reason == TelemetryHookSkipReason.UnexpectedConfigShape);
+ Assert.Equal(nonObjectRoot, await File.ReadAllTextAsync(settingsPath).DefaultTimeout());
+ }
+
+ [Fact]
+ public async Task ConfigureAsync_IsNoOp_ForUnsupportedClients()
+ {
+ using var workspace = TemporaryWorkspace.Create(outputHelper);
+ var home = workspace.CreateDirectory("home");
+ var configurator = CreateConfigurator(workspace, home);
+
+ var result = await configurator.ConfigureAsync(
+ [AgentClientKind.VsCode, AgentClientKind.OpenCode],
+ CancellationToken.None).DefaultTimeout();
+
+ Assert.Empty(result.ConfiguredClients);
+ Assert.Empty(result.Skipped);
+ // Nothing is materialized when no supported client is present.
+ Assert.False(Directory.Exists(Path.Combine(home.FullName, ".aspire", "hooks")));
+ Assert.False(Directory.Exists(Path.Combine(home.FullName, ".copilot")));
+ Assert.False(Directory.Exists(Path.Combine(home.FullName, ".claude")));
+ }
+
+ private static async Task ReadClaudePostToolUseAsync(DirectoryInfo home)
+ {
+ var settingsPath = Path.Combine(home.FullName, ".claude", "settings.json");
+ var root = JsonNode.Parse(await File.ReadAllTextAsync(settingsPath))!.AsObject();
+ return root["hooks"]!["PostToolUse"]!.AsArray();
+ }
+
+ private static int CountAspireGroups(JsonArray postToolUse)
+ => postToolUse.Count(GroupContainsAspireHook);
+
+ private static bool GroupContainsAspireHook(JsonNode? group)
+ => group is JsonObject obj
+ && obj["hooks"] is JsonArray hooks
+ && hooks.Any(HookReferencesTelemetryScript);
+
+ // The Aspire hook can carry the script path in the shell-form `command` string or, for Claude's exec
+ // form, in an `args` element. Check both so helpers locate the entry regardless of format.
+ private static bool HookReferencesTelemetryScript(JsonNode? hook)
+ => hook is JsonObject ho
+ && (JsonValueHasTelemetryScript(ho["command"])
+ || (ho["args"] is JsonArray args && args.Any(JsonValueHasTelemetryScript)));
+
+ private static bool JsonValueHasTelemetryScript(JsonNode? node)
+ => node is JsonValue v && v.ToString().Contains("track-telemetry", StringComparison.OrdinalIgnoreCase);
+
+ private static bool GroupContainsCommand(JsonNode? group, string command)
+ => group is JsonObject obj
+ && obj["hooks"] is JsonArray hooks
+ && hooks.Any(h => h is JsonObject ho
+ && ho["command"] is JsonValue v
+ && v.ToString() == command);
+
+ private static JsonObject FindAspireHook(JsonArray postToolUse)
+ {
+ foreach (var group in postToolUse)
+ {
+ if (group is JsonObject obj && obj["hooks"] is JsonArray hooks)
+ {
+ foreach (var hook in hooks)
+ {
+ if (hook is JsonObject ho && HookReferencesTelemetryScript(ho))
+ {
+ return ho;
+ }
+ }
+ }
+ }
+
+ throw new InvalidOperationException("No Aspire hook entry was found.");
+ }
+
+ private static TelemetryHookConfigurator CreateConfigurator(
+ TemporaryWorkspace workspace,
+ DirectoryInfo home,
+ IReadOnlyDictionary? environmentVariables = null)
+ {
+ var executionContext = TestExecutionContextHelper.CreateExecutionContext(
+ workspace.WorkspaceRoot,
+ homeDirectory: home,
+ environmentVariables: environmentVariables);
+ var installer = new TelemetryHookInstaller(executionContext, NullLogger.Instance);
+ return new TelemetryHookConfigurator(installer, executionContext, NullLogger.Instance);
+ }
+}
diff --git a/tests/Aspire.Cli.Tests/Agents/TelemetryHookInstallerTests.cs b/tests/Aspire.Cli.Tests/Agents/TelemetryHookInstallerTests.cs
new file mode 100644
index 00000000000..475fa71fec9
--- /dev/null
+++ b/tests/Aspire.Cli.Tests/Agents/TelemetryHookInstallerTests.cs
@@ -0,0 +1,106 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Aspire.Cli.Agents.Hooks;
+using Aspire.Cli.Tests.Utils;
+using Microsoft.AspNetCore.InternalTesting;
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace Aspire.Cli.Tests.Agents;
+
+public class TelemetryHookInstallerTests(ITestOutputHelper outputHelper)
+{
+ [Fact]
+ public async Task EnsureInstalledAsync_MaterializesBothScriptsUnderAspireHooksDirectory()
+ {
+ using var workspace = TemporaryWorkspace.Create(outputHelper);
+ var home = workspace.CreateDirectory("home");
+ var installer = CreateInstaller(workspace, home);
+
+ var scripts = await installer.EnsureInstalledAsync(CancellationToken.None).DefaultTimeout();
+
+ var expectedDirectory = Path.Combine(home.FullName, ".aspire", "hooks");
+ Assert.Equal(Path.Combine(expectedDirectory, "track-telemetry.sh"), scripts.ShellScriptPath);
+ Assert.Equal(Path.Combine(expectedDirectory, "track-telemetry.ps1"), scripts.PowerShellScriptPath);
+ Assert.True(File.Exists(scripts.ShellScriptPath));
+ Assert.True(File.Exists(scripts.PowerShellScriptPath));
+ }
+
+ [Fact]
+ public async Task EnsureInstalledAsync_ShellScriptUsesLfEndingsAndNoBom()
+ {
+ using var workspace = TemporaryWorkspace.Create(outputHelper);
+ var home = workspace.CreateDirectory("home");
+ var installer = CreateInstaller(workspace, home);
+
+ var scripts = await installer.EnsureInstalledAsync(CancellationToken.None).DefaultTimeout();
+
+ var bytes = await File.ReadAllBytesAsync(scripts.ShellScriptPath).DefaultTimeout();
+ // A UTF-8 BOM (EF BB BF) before the shebang stops the kernel from honoring `#!`.
+ Assert.False(bytes.Length >= 3 && bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF);
+ Assert.DoesNotContain((byte)'\r', bytes);
+
+ var content = await File.ReadAllTextAsync(scripts.ShellScriptPath).DefaultTimeout();
+ Assert.StartsWith("#!", content);
+ }
+
+ [Fact]
+ public async Task EnsureInstalledAsync_IsIdempotent_WhenContentUnchanged()
+ {
+ using var workspace = TemporaryWorkspace.Create(outputHelper);
+ var home = workspace.CreateDirectory("home");
+ var installer = CreateInstaller(workspace, home);
+
+ var first = await installer.EnsureInstalledAsync(CancellationToken.None).DefaultTimeout();
+ var firstShellContent = await File.ReadAllTextAsync(first.ShellScriptPath).DefaultTimeout();
+
+ var second = await installer.EnsureInstalledAsync(CancellationToken.None).DefaultTimeout();
+ var secondShellContent = await File.ReadAllTextAsync(second.ShellScriptPath).DefaultTimeout();
+
+ Assert.Equal(first.ShellScriptPath, second.ShellScriptPath);
+ Assert.Equal(firstShellContent, secondShellContent);
+ }
+
+ [Fact]
+ public async Task EnsureInstalledAsync_RewritesScript_WhenExistingContentDiffers()
+ {
+ using var workspace = TemporaryWorkspace.Create(outputHelper);
+ var home = workspace.CreateDirectory("home");
+ var installer = CreateInstaller(workspace, home);
+
+ var hooksDirectory = Path.Combine(home.FullName, ".aspire", "hooks");
+ Directory.CreateDirectory(hooksDirectory);
+ var shellPath = Path.Combine(hooksDirectory, "track-telemetry.sh");
+ await File.WriteAllTextAsync(shellPath, "stale-content").DefaultTimeout();
+
+ var scripts = await installer.EnsureInstalledAsync(CancellationToken.None).DefaultTimeout();
+
+ var content = await File.ReadAllTextAsync(scripts.ShellScriptPath).DefaultTimeout();
+ Assert.NotEqual("stale-content", content);
+ Assert.StartsWith("#!", content);
+ }
+
+ [Fact]
+ public async Task EnsureInstalledAsync_SetsExecutableBit_OnNonWindows()
+ {
+ if (OperatingSystem.IsWindows())
+ {
+ return;
+ }
+
+ using var workspace = TemporaryWorkspace.Create(outputHelper);
+ var home = workspace.CreateDirectory("home");
+ var installer = CreateInstaller(workspace, home);
+
+ var scripts = await installer.EnsureInstalledAsync(CancellationToken.None).DefaultTimeout();
+
+ var mode = File.GetUnixFileMode(scripts.ShellScriptPath);
+ Assert.True(mode.HasFlag(UnixFileMode.UserExecute));
+ }
+
+ private static TelemetryHookInstaller CreateInstaller(TemporaryWorkspace workspace, DirectoryInfo home)
+ {
+ var executionContext = TestExecutionContextHelper.CreateExecutionContext(workspace.WorkspaceRoot, homeDirectory: home);
+ return new TelemetryHookInstaller(executionContext, NullLogger.Instance);
+ }
+}
diff --git a/tests/Aspire.Cli.Tests/Agents/TelemetryHookScriptTests.cs b/tests/Aspire.Cli.Tests/Agents/TelemetryHookScriptTests.cs
new file mode 100644
index 00000000000..5da23efc21e
--- /dev/null
+++ b/tests/Aspire.Cli.Tests/Agents/TelemetryHookScriptTests.cs
@@ -0,0 +1,451 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Diagnostics;
+using Aspire.Cli.Agents.Hooks;
+using Aspire.Cli.Tests.Utils;
+using Aspire.TestUtilities;
+using Microsoft.AspNetCore.InternalTesting;
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace Aspire.Cli.Tests.Agents;
+
+///
+/// Behavior tests for the embedded telemetry hook scripts (track-telemetry.sh /
+/// track-telemetry.ps1). The scripts are materialized through the real
+/// so the shipped resources are exercised, and a recording stub
+/// substituted via the ASPIRE_CLI_COMMAND override captures the argument vector the script
+/// would pass to aspire agent telemetry — or records nothing when the script classifies the
+/// event as non-Aspire, opted out, or unparseable. Every case also asserts the hook prints exactly
+/// {"continue":true}, the contract a PostToolUse hook must honor.
+///
+public class TelemetryHookScriptTests(ITestOutputHelper outputHelper)
+{
+ private const string CaptureFileEnvName = "ASPIRE_HOOK_TEST_CAPTURE_FILE";
+ private const string ContinueResponse = """{"continue":true}""";
+
+ [Fact]
+ [RequiresTools(["bash"])]
+ [SkipOnPlatform(TestPlatforms.Windows, "The shell hook targets POSIX shells; the PowerShell hook covers Windows.")]
+ public async Task Bash_SkillInvocation_Copilot_ForwardsSkillName()
+ {
+ var run = await RunBashHookAsync(
+ """{"toolName":"skill","sessionId":"session-1","toolArgs":{"skill":"aspire"}}""",
+ new() { ["COPILOT_CLI"] = "1" });
+
+ AssertContinue(run);
+ var args = AssertInvoked(run);
+ AssertArg(args, "--event-type", "skill_invocation");
+ AssertArg(args, "--client-name", "copilot-cli");
+ AssertArg(args, "--skill-name", "aspire");
+ AssertArg(args, "--session-id", "session-1");
+ }
+
+ [Fact]
+ [RequiresTools(["bash"])]
+ [SkipOnPlatform(TestPlatforms.Windows, "The shell hook targets POSIX shells; the PowerShell hook covers Windows.")]
+ public async Task Bash_McpTool_Claude_ForwardsToolName()
+ {
+ var run = await RunBashHookAsync(
+ """{"hook_event_name":"PostToolUse","tool_name":"mcp__aspire__list_resources"}""");
+
+ AssertContinue(run);
+ var args = AssertInvoked(run);
+ AssertArg(args, "--event-type", "tool_invocation");
+ AssertArg(args, "--client-name", "claude-code");
+ AssertArg(args, "--tool-name", "mcp__aspire__list_resources");
+ }
+
+ [Fact]
+ [RequiresTools(["bash"])]
+ [SkipOnPlatform(TestPlatforms.Windows, "The shell hook targets POSIX shells; the PowerShell hook covers Windows.")]
+ public async Task Bash_ReferenceFileRead_ForwardsRelativePath()
+ {
+ var run = await RunBashHookAsync(
+ """{"hook_event_name":"PostToolUse","tool_name":"Read","tool_input":{"file_path":".agents/skills/aspire/references/deploy.md"}}""");
+
+ AssertContinue(run);
+ var args = AssertInvoked(run);
+ AssertArg(args, "--event-type", "reference_file_read");
+ // Only the repo-relative path after skills// is forwarded — never the absolute path.
+ AssertArg(args, "--file-reference", "aspire/references/deploy.md");
+ }
+
+ [Fact]
+ [RequiresTools(["bash"])]
+ [SkipOnPlatform(TestPlatforms.Windows, "The shell hook targets POSIX shells; the PowerShell hook covers Windows.")]
+ public async Task Bash_NonAspireToolOrFile_DoesNotInvokeCli()
+ {
+ var run = await RunBashHookAsync(
+ """{"hook_event_name":"PostToolUse","tool_name":"Read","tool_input":{"file_path":"/home/user/notes.txt"}}""");
+
+ AssertContinue(run);
+ AssertNotInvoked(run);
+ }
+
+ [Fact]
+ [RequiresTools(["bash"])]
+ [SkipOnPlatform(TestPlatforms.Windows, "The shell hook targets POSIX shells; the PowerShell hook covers Windows.")]
+ public async Task Bash_OptedOut_DoesNotInvokeCli()
+ {
+ var run = await RunBashHookAsync(
+ """{"toolName":"skill","toolArgs":{"skill":"aspire"}}""",
+ new() { ["COPILOT_CLI"] = "1", ["ASPIRE_CLI_TELEMETRY_OPTOUT"] = "1" });
+
+ AssertContinue(run);
+ AssertNotInvoked(run);
+ }
+
+ [Fact]
+ [RequiresTools(["bash"])]
+ [SkipOnPlatform(TestPlatforms.Windows, "The shell hook targets POSIX shells; the PowerShell hook covers Windows.")]
+ public async Task Bash_UnrecognizedPayload_DoesNotInvokeCli()
+ {
+ // No tool fields the sed extraction can recognize -> nothing to classify, hook stays silent.
+ var run = await RunBashHookAsync("not-json-at-all");
+
+ AssertContinue(run);
+ AssertNotInvoked(run);
+ }
+
+ [Fact]
+ [RequiresTools(["bash"])]
+ [SkipOnPlatform(TestPlatforms.Windows, "The shell hook targets POSIX shells; the PowerShell hook covers Windows.")]
+ public async Task Bash_SkillMdRead_ForwardsSkillName()
+ {
+ var run = await RunBashHookAsync(
+ """{"hook_event_name":"PostToolUse","tool_name":"Read","tool_input":{"file_path":".agents/skills/aspire/SKILL.md"}}""");
+
+ AssertContinue(run);
+ var args = AssertInvoked(run);
+ // Reading a skill's SKILL.md counts as using the skill, not a reference-file read.
+ AssertArg(args, "--event-type", "skill_invocation");
+ AssertArg(args, "--skill-name", "aspire");
+ }
+
+ [Fact]
+ [RequiresTools(["bash"])]
+ [SkipOnPlatform(TestPlatforms.Windows, "The shell hook targets POSIX shells; the PowerShell hook covers Windows.")]
+ public async Task Bash_SkillTool_Claude_StripsAspirePrefix()
+ {
+ var run = await RunBashHookAsync(
+ """{"hook_event_name":"PostToolUse","tool_name":"Skill","tool_input":{"skill":"aspire:aspire-deployment"}}""");
+
+ AssertContinue(run);
+ var args = AssertInvoked(run);
+ AssertArg(args, "--event-type", "skill_invocation");
+ AssertArg(args, "--client-name", "claude-code");
+ // Claude prefixes plugin skill names with "aspire:"; the hook strips it before the allowlist match.
+ AssertArg(args, "--skill-name", "aspire-deployment");
+ }
+
+ [Fact]
+ [RequiresTools(["bash"])]
+ [SkipOnPlatform(TestPlatforms.Windows, "The shell hook targets POSIX shells; the PowerShell hook covers Windows.")]
+ public async Task Bash_McpTool_VsCode_DetectsClient()
+ {
+ var run = await RunBashHookAsync(
+ """{"hook_event_name":"PostToolUse","tool_name":"mcp_aspire_list_resources","tool_use_id":"toolu_01__vscode"}""");
+
+ AssertContinue(run);
+ var args = AssertInvoked(run);
+ AssertArg(args, "--event-type", "tool_invocation");
+ // A __vscode marker in tool_use_id distinguishes the VS Code client from Claude Code.
+ AssertArg(args, "--client-name", "vscode");
+ AssertArg(args, "--tool-name", "mcp_aspire_list_resources");
+ }
+
+ [Fact]
+ [RequiresTools(["pwsh"])]
+ public async Task Pwsh_SkillInvocation_Copilot_ForwardsSkillName()
+ {
+ var run = await RunPwshHookAsync(
+ """{"toolName":"skill","sessionId":"session-1","toolArgs":{"skill":"aspire"}}""",
+ new() { ["COPILOT_CLI"] = "1" });
+
+ AssertContinue(run);
+ var args = AssertInvoked(run);
+ AssertArg(args, "--event-type", "skill_invocation");
+ AssertArg(args, "--client-name", "copilot-cli");
+ AssertArg(args, "--skill-name", "aspire");
+ AssertArg(args, "--session-id", "session-1");
+ }
+
+ [Fact]
+ [RequiresTools(["pwsh"])]
+ public async Task Pwsh_McpTool_Claude_ForwardsToolName()
+ {
+ var run = await RunPwshHookAsync(
+ """{"hook_event_name":"PostToolUse","tool_name":"mcp__aspire__list_resources"}""");
+
+ AssertContinue(run);
+ var args = AssertInvoked(run);
+ AssertArg(args, "--event-type", "tool_invocation");
+ AssertArg(args, "--client-name", "claude-code");
+ AssertArg(args, "--tool-name", "mcp__aspire__list_resources");
+ }
+
+ [Fact]
+ [RequiresTools(["pwsh"])]
+ public async Task Pwsh_NonAspireTool_DoesNotInvokeCli()
+ {
+ var run = await RunPwshHookAsync(
+ """{"hook_event_name":"PostToolUse","tool_name":"read_file","tool_input":{"path":"/tmp/notes.txt"}}""");
+
+ AssertContinue(run);
+ AssertNotInvoked(run);
+ }
+
+ [Fact]
+ [RequiresTools(["pwsh"])]
+ public async Task Pwsh_OptedOut_DoesNotInvokeCli()
+ {
+ var run = await RunPwshHookAsync(
+ """{"toolName":"skill","toolArgs":{"skill":"aspire"}}""",
+ new() { ["COPILOT_CLI"] = "1", ["ASPIRE_CLI_TELEMETRY_OPTOUT"] = "true" });
+
+ AssertContinue(run);
+ AssertNotInvoked(run);
+ }
+
+ [Fact]
+ [RequiresTools(["pwsh"])]
+ public async Task Pwsh_MalformedJson_DoesNotInvokeCli()
+ {
+ var run = await RunPwshHookAsync("{ this is not valid json");
+
+ AssertContinue(run);
+ AssertNotInvoked(run);
+ }
+
+ [Fact]
+ [RequiresTools(["pwsh"])]
+ public async Task Pwsh_ReferenceFileRead_ForwardsRelativePath()
+ {
+ var run = await RunPwshHookAsync(
+ """{"hook_event_name":"PostToolUse","tool_name":"Read","tool_input":{"file_path":".agents/skills/aspire/references/deploy.md"}}""");
+
+ AssertContinue(run);
+ var args = AssertInvoked(run);
+ AssertArg(args, "--event-type", "reference_file_read");
+ // Only the repo-relative path after skills// is forwarded — never the absolute path.
+ AssertArg(args, "--file-reference", "aspire/references/deploy.md");
+ }
+
+ [Fact]
+ [RequiresTools(["pwsh"])]
+ public async Task Pwsh_SkillMdRead_ForwardsSkillName()
+ {
+ var run = await RunPwshHookAsync(
+ """{"hook_event_name":"PostToolUse","tool_name":"Read","tool_input":{"file_path":".agents/skills/aspire/SKILL.md"}}""");
+
+ AssertContinue(run);
+ var args = AssertInvoked(run);
+ AssertArg(args, "--event-type", "skill_invocation");
+ AssertArg(args, "--skill-name", "aspire");
+ }
+
+ [Fact]
+ [RequiresTools(["pwsh"])]
+ public async Task Pwsh_SkillTool_Claude_StripsAspirePrefix()
+ {
+ var run = await RunPwshHookAsync(
+ """{"hook_event_name":"PostToolUse","tool_name":"Skill","tool_input":{"skill":"aspire:aspire-deployment"}}""");
+
+ AssertContinue(run);
+ var args = AssertInvoked(run);
+ AssertArg(args, "--event-type", "skill_invocation");
+ AssertArg(args, "--client-name", "claude-code");
+ // Claude prefixes plugin skill names with "aspire:"; the hook strips it before the allowlist match.
+ AssertArg(args, "--skill-name", "aspire-deployment");
+ }
+
+ [Fact]
+ [RequiresTools(["pwsh"])]
+ public async Task Pwsh_McpTool_VsCode_DetectsClient()
+ {
+ var run = await RunPwshHookAsync(
+ """{"hook_event_name":"PostToolUse","tool_name":"mcp_aspire_list_resources","tool_use_id":"toolu_01__vscode"}""");
+
+ AssertContinue(run);
+ var args = AssertInvoked(run);
+ AssertArg(args, "--event-type", "tool_invocation");
+ // A __vscode marker in tool_use_id distinguishes the VS Code client from Claude Code.
+ AssertArg(args, "--client-name", "vscode");
+ AssertArg(args, "--tool-name", "mcp_aspire_list_resources");
+ }
+
+ private async Task RunBashHookAsync(string payload, Dictionary? extraEnv = null)
+ {
+ using var workspace = TemporaryWorkspace.Create(outputHelper);
+ var scripts = await MaterializeScriptsAsync(workspace).DefaultTimeout();
+ var capturePath = Path.Combine(workspace.WorkspaceRoot.FullName, "capture.txt");
+ var recorderPath = CreateBashRecorder(workspace.WorkspaceRoot.FullName);
+
+ var result = RunProcess("bash", [scripts.ShellScriptPath], payload, BuildEnvironment(recorderPath, capturePath, extraEnv));
+
+ return new HookRun(result, ReadCapturedArgs(capturePath));
+ }
+
+ private async Task RunPwshHookAsync(string payload, Dictionary? extraEnv = null)
+ {
+ using var workspace = TemporaryWorkspace.Create(outputHelper);
+ var scripts = await MaterializeScriptsAsync(workspace).DefaultTimeout();
+ var capturePath = Path.Combine(workspace.WorkspaceRoot.FullName, "capture.txt");
+ var recorderPath = CreatePwshRecorder(workspace.WorkspaceRoot.FullName);
+
+ // -ExecutionPolicy Bypass so the locally created hook and recorder run on Windows agents.
+ var result = RunProcess("pwsh", ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", scripts.PowerShellScriptPath], payload, BuildEnvironment(recorderPath, capturePath, extraEnv));
+
+ return new HookRun(result, ReadCapturedArgs(capturePath));
+ }
+
+ private static async Task MaterializeScriptsAsync(TemporaryWorkspace workspace)
+ {
+ var home = workspace.CreateDirectory("home");
+ var executionContext = TestExecutionContextHelper.CreateExecutionContext(workspace.WorkspaceRoot, homeDirectory: home);
+ var installer = new TelemetryHookInstaller(executionContext, NullLogger.Instance);
+ return await installer.EnsureInstalledAsync(CancellationToken.None);
+ }
+
+ private static string CreateBashRecorder(string directory)
+ {
+ var path = Path.Combine(directory, "recorder.sh");
+ // Writes each received argument on its own line so assertions can match discrete tokens.
+ File.WriteAllText(path, "#!/bin/bash\nprintf '%s\\n' \"$@\" > \"$ASPIRE_HOOK_TEST_CAPTURE_FILE\"\n");
+ if (!OperatingSystem.IsWindows())
+ {
+ File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute
+ | UnixFileMode.GroupRead | UnixFileMode.GroupExecute | UnixFileMode.OtherRead | UnixFileMode.OtherExecute);
+ }
+
+ return path;
+ }
+
+ private static string CreatePwshRecorder(string directory)
+ {
+ var path = Path.Combine(directory, "recorder.ps1");
+ // A simple (non-advanced) script collects every argument, including --flag tokens, into $args.
+ File.WriteAllText(path, "$args | Set-Content -LiteralPath $env:ASPIRE_HOOK_TEST_CAPTURE_FILE\n");
+ return path;
+ }
+
+ private static Dictionary BuildEnvironment(string recorderPath, string capturePath, Dictionary? extraEnv)
+ {
+ var environment = new Dictionary(StringComparer.Ordinal)
+ {
+ ["ASPIRE_CLI_COMMAND"] = recorderPath,
+ [CaptureFileEnvName] = capturePath
+ };
+
+ if (extraEnv is not null)
+ {
+ foreach (var pair in extraEnv)
+ {
+ environment[pair.Key] = pair.Value;
+ }
+ }
+
+ return environment;
+ }
+
+ private static string[]? ReadCapturedArgs(string capturePath)
+ => File.Exists(capturePath)
+ ? File.ReadAllLines(capturePath).Where(line => line.Length > 0).ToArray()
+ : null;
+
+ private static void AssertContinue(HookRun run)
+ => Assert.Equal(ContinueResponse, run.Result.StdOut.Trim());
+
+ private static string[] AssertInvoked(HookRun run)
+ {
+ Assert.NotNull(run.CapturedArgs);
+ Assert.Equal("agent", run.CapturedArgs![0]);
+ Assert.Equal("telemetry", run.CapturedArgs[1]);
+ return run.CapturedArgs;
+ }
+
+ private static void AssertNotInvoked(HookRun run)
+ => Assert.Null(run.CapturedArgs);
+
+ private static void AssertArg(string[] args, string name, string value)
+ {
+ var index = Array.IndexOf(args, name);
+ Assert.True(index >= 0 && index + 1 < args.Length, $"Expected '{name} {value}' in [{string.Join(' ', args)}]");
+ Assert.Equal(value, args[index + 1]);
+ }
+
+ private static ProcessResult RunProcess(string fileName, IReadOnlyList arguments, string stdinPayload, Dictionary environment)
+ {
+ var psi = new ProcessStartInfo
+ {
+ FileName = fileName,
+ RedirectStandardInput = true,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ UseShellExecute = false
+ };
+
+ foreach (var argument in arguments)
+ {
+ psi.ArgumentList.Add(argument);
+ }
+
+ // Clear ambient values so the host environment can't change client detection or opt-out.
+ psi.Environment.Remove("COPILOT_CLI");
+ psi.Environment.Remove("ASPIRE_CLI_TELEMETRY_OPTOUT");
+ foreach (var pair in environment)
+ {
+ if (pair.Value is null)
+ {
+ psi.Environment.Remove(pair.Key);
+ }
+ else
+ {
+ psi.Environment[pair.Key] = pair.Value;
+ }
+ }
+
+ using var process = Process.Start(psi)!;
+
+ try
+ {
+ process.StandardInput.Write(stdinPayload);
+ process.StandardInput.Close();
+ }
+ catch (IOException)
+ {
+ // The hook may exit before reading stdin (for example on the opt-out path), which closes
+ // the pipe early. That is expected; the captured output below is what matters.
+ }
+
+ var stdoutTask = process.StandardOutput.ReadToEndAsync();
+ var stderrTask = process.StandardError.ReadToEndAsync();
+
+ using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30));
+ try
+ {
+ process.WaitForExitAsync(timeout.Token).GetAwaiter().GetResult();
+ }
+ catch (OperationCanceledException)
+ {
+ try
+ {
+ process.Kill(entireProcessTree: true);
+ }
+ catch (InvalidOperationException)
+ {
+ }
+
+ throw new TimeoutException($"Hook process '{fileName}' did not exit within 30 seconds.");
+ }
+
+ var stdout = stdoutTask.GetAwaiter().GetResult();
+ var stderr = stderrTask.GetAwaiter().GetResult();
+ return new ProcessResult(process.ExitCode, stdout, stderr);
+ }
+
+ private sealed record HookRun(ProcessResult Result, string[]? CapturedArgs);
+
+ private sealed record ProcessResult(int ExitCode, string StdOut, string StdErr);
+}
diff --git a/tests/Aspire.Cli.Tests/Commands/AgentInitCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/AgentInitCommandTests.cs
index 02dd72d738a..36c3433d9f4 100644
--- a/tests/Aspire.Cli.Tests/Commands/AgentInitCommandTests.cs
+++ b/tests/Aspire.Cli.Tests/Commands/AgentInitCommandTests.cs
@@ -841,10 +841,45 @@ private static string ComputeSha256(string path)
return Convert.ToHexString(SHA256.HashData(stream)).ToLowerInvariant();
}
+ [Fact]
+ public async Task AgentInitCommand_DefaultOn_InstallsTelemetryHook_ForDetectedClient()
+ {
+ using var workspace = TemporaryWorkspace.Create(outputHelper);
+ var homeDirectory = workspace.CreateDirectory("fake-home");
+ var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
+ {
+ options.CliExecutionContextFactory = _ => CreateExecutionContext(workspace.WorkspaceRoot, homeDirectory);
+ options.AgentEnvironmentDetectorFactory = _ => new FakeDetectingDetector(AgentClientKind.CopilotCli);
+ });
+
+ using var provider = services.BuildServiceProvider();
+ var command = provider.GetRequiredService();
+ var result = command.Parse($"agent init --workspace-root {workspace.WorkspaceRoot.FullName} --skill-locations none --skills none");
+
+ var exitCode = await result.InvokeAsync().DefaultTimeout();
+
+ Assert.Equal(CliExitCodes.Success, exitCode);
+ var hookFile = Path.Combine(homeDirectory.FullName, ".copilot", "hooks", "aspire-telemetry.json");
+ Assert.True(File.Exists(hookFile), $"Expected telemetry hook at {hookFile}");
+ }
+
private static CliExecutionContext CreateExecutionContext(DirectoryInfo workingDirectory, DirectoryInfo homeDirectory)
{
return TestExecutionContextHelper.CreateExecutionContext(
workingDirectory,
homeDirectory: homeDirectory);
}
+
+ ///
+ /// A detector that marks a single client as detected without contributing applicators, so the
+ /// telemetry hook wiring in agent init can be exercised without real client installations.
+ ///
+ private sealed class FakeDetectingDetector(AgentClientKind client) : IAgentEnvironmentDetector
+ {
+ public Task DetectAsync(AgentEnvironmentScanContext context, CancellationToken cancellationToken)
+ {
+ context.AddDetectedClient(client);
+ return Task.FromResult(Array.Empty());
+ }
+ }
}
diff --git a/tests/Aspire.Cli.Tests/Commands/AgentTelemetryCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/AgentTelemetryCommandTests.cs
new file mode 100644
index 00000000000..588b344af47
--- /dev/null
+++ b/tests/Aspire.Cli.Tests/Commands/AgentTelemetryCommandTests.cs
@@ -0,0 +1,273 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Diagnostics;
+using Aspire.Cli.Commands;
+using Aspire.Cli.Telemetry;
+using Aspire.Cli.Tests.Telemetry;
+using Aspire.Cli.Tests.Utils;
+using Microsoft.AspNetCore.InternalTesting;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Aspire.Cli.Tests.Commands;
+
+public class AgentTelemetryCommandTests(ITestOutputHelper outputHelper)
+{
+ [Fact]
+ public async Task AgentTelemetry_EmitsReportedActivityWithProvidedTags()
+ {
+ var (capturedActivities, listener) = CreateCapturingListener(out var reportedSourceName);
+ using (listener)
+ {
+ using var workspace = TemporaryWorkspace.Create(outputHelper);
+ var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
+ {
+ options.TelemetryFactory = _ => TestTelemetryHelper.CreateInitializedTelemetry(reportedSourceName, $"Diag.{Path.GetRandomFileName()}");
+ });
+ using var provider = services.BuildServiceProvider();
+
+ var command = provider.GetRequiredService();
+ var result = command.Parse("agent telemetry --event-type skill_invocation --client-name copilot-cli --session-id 11111111-1111-1111-1111-111111111111 --skill-name aspire --timestamp 2026-01-01T00:00:00Z");
+
+ var exitCode = await result.InvokeAsync().DefaultTimeout();
+
+ Assert.Equal(CliExitCodes.Success, exitCode);
+
+ var activity = Assert.Single(capturedActivities);
+ Assert.Equal(TelemetryConstants.Activities.AgentTelemetry, activity.OperationName);
+
+ var tags = activity.Tags.ToDictionary(t => t.Key, t => t.Value);
+ Assert.Equal("skill_invocation", tags[TelemetryConstants.Tags.AgentEventType]);
+ Assert.Equal("copilot-cli", tags[TelemetryConstants.Tags.AgentClientName]);
+ Assert.Equal("11111111-1111-1111-1111-111111111111", tags[TelemetryConstants.Tags.AgentSessionId]);
+ Assert.Equal("aspire", tags[TelemetryConstants.Tags.AgentSkillName]);
+ Assert.Equal("2026-01-01T00:00:00Z", tags[TelemetryConstants.Tags.AgentEventTimestamp]);
+ }
+ }
+
+ [Fact]
+ public async Task AgentTelemetry_DoesNotEmitTagsForMissingOptions()
+ {
+ var (capturedActivities, listener) = CreateCapturingListener(out var reportedSourceName);
+ using (listener)
+ {
+ using var workspace = TemporaryWorkspace.Create(outputHelper);
+ var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
+ {
+ options.TelemetryFactory = _ => TestTelemetryHelper.CreateInitializedTelemetry(reportedSourceName, $"Diag.{Path.GetRandomFileName()}");
+ });
+ using var provider = services.BuildServiceProvider();
+
+ var command = provider.GetRequiredService();
+ var result = command.Parse("agent telemetry --event-type tool_invocation --tool-name aspire-list_resources");
+
+ var exitCode = await result.InvokeAsync().DefaultTimeout();
+
+ Assert.Equal(CliExitCodes.Success, exitCode);
+
+ var activity = Assert.Single(capturedActivities);
+ var tags = activity.Tags.ToDictionary(t => t.Key, t => t.Value);
+ Assert.Equal("tool_invocation", tags[TelemetryConstants.Tags.AgentEventType]);
+ Assert.Equal("aspire-list_resources", tags[TelemetryConstants.Tags.AgentToolName]);
+ Assert.False(tags.ContainsKey(TelemetryConstants.Tags.AgentSkillName));
+ Assert.False(tags.ContainsKey(TelemetryConstants.Tags.AgentFileReference));
+ Assert.False(tags.ContainsKey(TelemetryConstants.Tags.AgentClientName));
+ }
+ }
+
+ [Fact]
+ public async Task AgentTelemetry_DropsOverlongAndUnsafeValues()
+ {
+ var (capturedActivities, listener) = CreateCapturingListener(out var reportedSourceName);
+ using (listener)
+ {
+ using var workspace = TemporaryWorkspace.Create(outputHelper);
+ var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
+ {
+ options.TelemetryFactory = _ => TestTelemetryHelper.CreateInitializedTelemetry(reportedSourceName, $"Diag.{Path.GetRandomFileName()}");
+ });
+ using var provider = services.BuildServiceProvider();
+
+ var longValue = new string('a', 1000);
+ var command = provider.GetRequiredService();
+ var result = command.Parse($"agent telemetry --event-type reference_file_read --file-reference {longValue}");
+
+ var exitCode = await result.InvokeAsync().DefaultTimeout();
+
+ Assert.Equal(CliExitCodes.Success, exitCode);
+
+ var activity = Assert.Single(capturedActivities);
+ var tags = activity.Tags.ToDictionary(t => t.Key, t => t.Value);
+ // An overlong file reference is dropped (not truncated) so oversized values never reach the backend.
+ Assert.False(tags.ContainsKey(TelemetryConstants.Tags.AgentFileReference));
+ Assert.Equal("reference_file_read", tags[TelemetryConstants.Tags.AgentEventType]);
+ }
+ }
+
+ [Theory]
+ [InlineData("/etc/passwd")]
+ [InlineData("C:\\Users\\someone\\secret.txt")]
+ [InlineData("../../etc/passwd")]
+ [InlineData("~/secret")]
+ public async Task AgentTelemetry_DropsUnsafeFileReferences(string fileReference)
+ {
+ var (capturedActivities, listener) = CreateCapturingListener(out var reportedSourceName);
+ using (listener)
+ {
+ using var workspace = TemporaryWorkspace.Create(outputHelper);
+ var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
+ {
+ options.TelemetryFactory = _ => TestTelemetryHelper.CreateInitializedTelemetry(reportedSourceName, $"Diag.{Path.GetRandomFileName()}");
+ });
+ using var provider = services.BuildServiceProvider();
+
+ var command = provider.GetRequiredService();
+ var result = command.Parse(["agent", "telemetry", "--event-type", "reference_file_read", "--file-reference", fileReference]);
+
+ var exitCode = await result.InvokeAsync().DefaultTimeout();
+
+ Assert.Equal(CliExitCodes.Success, exitCode);
+
+ var activity = Assert.Single(capturedActivities);
+ var tags = activity.Tags.ToDictionary(t => t.Key, t => t.Value);
+ Assert.False(tags.ContainsKey(TelemetryConstants.Tags.AgentFileReference));
+ }
+ }
+
+ [Fact]
+ public async Task AgentTelemetry_DropsUnknownEventType()
+ {
+ var (capturedActivities, listener) = CreateCapturingListener(out var reportedSourceName);
+ using (listener)
+ {
+ using var workspace = TemporaryWorkspace.Create(outputHelper);
+ var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
+ {
+ options.TelemetryFactory = _ => TestTelemetryHelper.CreateInitializedTelemetry(reportedSourceName, $"Diag.{Path.GetRandomFileName()}");
+ });
+ using var provider = services.BuildServiceProvider();
+
+ var command = provider.GetRequiredService();
+ var result = command.Parse("agent telemetry --event-type not_a_real_event --skill-name aspire");
+
+ var exitCode = await result.InvokeAsync().DefaultTimeout();
+
+ Assert.Equal(CliExitCodes.Success, exitCode);
+
+ var activity = Assert.Single(capturedActivities);
+ var tags = activity.Tags.ToDictionary(t => t.Key, t => t.Value);
+ Assert.False(tags.ContainsKey(TelemetryConstants.Tags.AgentEventType));
+ Assert.Equal("aspire", tags[TelemetryConstants.Tags.AgentSkillName]);
+ }
+ }
+
+ [Fact]
+ public async Task AgentTelemetry_EmitsNoActivity_WhenAllValuesInvalid()
+ {
+ var (capturedActivities, listener) = CreateCapturingListener(out var reportedSourceName);
+ using (listener)
+ {
+ using var workspace = TemporaryWorkspace.Create(outputHelper);
+ var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
+ {
+ options.TelemetryFactory = _ => TestTelemetryHelper.CreateInitializedTelemetry(reportedSourceName, $"Diag.{Path.GetRandomFileName()}");
+ });
+ using var provider = services.BuildServiceProvider();
+
+ var command = provider.GetRequiredService();
+ // Every value fails validation (unknown event type, identifier with a space, absolute path).
+ // When nothing survives, the command must emit no span at all rather than a tagless one.
+ var result = command.Parse(["agent", "telemetry", "--event-type", "not_a_real_event", "--skill-name", "bad name", "--file-reference", "/etc/passwd"]);
+
+ var exitCode = await result.InvokeAsync().DefaultTimeout();
+
+ Assert.Equal(CliExitCodes.Success, exitCode);
+ Assert.Empty(capturedActivities);
+ }
+ }
+
+ [Fact]
+ public async Task AgentTelemetry_ExitsZero_WithUnknownToken()
+ {
+ using var workspace = TemporaryWorkspace.Create(outputHelper);
+ var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
+ using var provider = services.BuildServiceProvider();
+
+ var command = provider.GetRequiredService();
+ // A newer hook script may pass a flag this CLI version does not understand; it must not fail.
+ var result = command.Parse("agent telemetry --event-type skill_invocation --some-future-flag value");
+
+ var exitCode = await result.InvokeAsync().DefaultTimeout();
+
+ Assert.Equal(CliExitCodes.Success, exitCode);
+ }
+
+ [Fact]
+ public async Task AgentTelemetry_ExitsZero_WithNoOptions()
+ {
+ using var workspace = TemporaryWorkspace.Create(outputHelper);
+ var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
+ using var provider = services.BuildServiceProvider();
+
+ var command = provider.GetRequiredService();
+ var result = command.Parse("agent telemetry");
+
+ var exitCode = await result.InvokeAsync().DefaultTimeout();
+
+ Assert.Equal(CliExitCodes.Success, exitCode);
+ }
+
+ [Fact]
+ public void AgentTelemetry_IsHidden()
+ {
+ using var workspace = TemporaryWorkspace.Create(outputHelper);
+ var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
+ using var provider = services.BuildServiceProvider();
+
+ var command = provider.GetRequiredService();
+ Assert.True(command.Hidden);
+ }
+
+ [Fact]
+ public async Task AgentTelemetry_RecordsValidRelativeFileReference()
+ {
+ var (capturedActivities, listener) = CreateCapturingListener(out var reportedSourceName);
+ using (listener)
+ {
+ using var workspace = TemporaryWorkspace.Create(outputHelper);
+ var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
+ {
+ options.TelemetryFactory = _ => TestTelemetryHelper.CreateInitializedTelemetry(reportedSourceName, $"Diag.{Path.GetRandomFileName()}");
+ });
+ using var provider = services.BuildServiceProvider();
+
+ var command = provider.GetRequiredService();
+ var result = command.Parse("agent telemetry --event-type reference_file_read --file-reference aspire/references/deploy.md");
+
+ var exitCode = await result.InvokeAsync().DefaultTimeout();
+
+ Assert.Equal(CliExitCodes.Success, exitCode);
+
+ var activity = Assert.Single(capturedActivities);
+ var tags = activity.Tags.ToDictionary(t => t.Key, t => t.Value);
+ Assert.Equal("aspire/references/deploy.md", tags[TelemetryConstants.Tags.AgentFileReference]);
+ }
+ }
+
+ private static (List Activities, ActivityListener Listener) CreateCapturingListener(out string reportedSourceName)
+ {
+ reportedSourceName = $"Test.{Path.GetRandomFileName()}";
+ var captured = new List();
+ var sourceName = reportedSourceName;
+
+ var listener = new ActivityListener
+ {
+ ShouldListenTo = source => source.Name == sourceName,
+ Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllDataAndRecorded,
+ ActivityStopped = activity => captured.Add(activity)
+ };
+ ActivitySource.AddActivityListener(listener);
+
+ return (captured, listener);
+ }
+}
diff --git a/tests/Aspire.Cli.Tests/Telemetry/AgentTelemetryInvocationTests.cs b/tests/Aspire.Cli.Tests/Telemetry/AgentTelemetryInvocationTests.cs
new file mode 100644
index 00000000000..c8d5aeb99f7
--- /dev/null
+++ b/tests/Aspire.Cli.Tests/Telemetry/AgentTelemetryInvocationTests.cs
@@ -0,0 +1,43 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Aspire.Cli.Telemetry;
+
+namespace Aspire.Cli.Tests.Telemetry;
+
+public class AgentTelemetryInvocationTests
+{
+ [Theory]
+ [InlineData("agent telemetry")]
+ [InlineData("agent telemetry --event-type skill_invocation")]
+ [InlineData("agent telemetry --skill-name aspire --timestamp 2026-01-01T00:00:00Z")]
+ public void Matches_ReturnsTrue_ForAgentTelemetryInvocation(string commandLine)
+ {
+ Assert.True(AgentTelemetryInvocation.Matches(commandLine.Split(' ')));
+ }
+
+ [Theory]
+ [InlineData("agent")]
+ [InlineData("agent mcp")]
+ [InlineData("agent init")]
+ [InlineData("telemetry")]
+ [InlineData("run")]
+ [InlineData("--debug agent telemetry")]
+ [InlineData("config set agent telemetry")]
+ public void Matches_ReturnsFalse_ForOtherInvocations(string commandLine)
+ {
+ Assert.False(AgentTelemetryInvocation.Matches(commandLine.Split(' ')));
+ }
+
+ [Fact]
+ public void Matches_ReturnsFalse_ForEmptyArgs()
+ {
+ Assert.False(AgentTelemetryInvocation.Matches([]));
+ }
+
+ [Fact]
+ public void Matches_ReturnsFalse_ForNullArgs()
+ {
+ Assert.False(AgentTelemetryInvocation.Matches(null));
+ }
+}
diff --git a/tests/Aspire.Cli.Tests/Telemetry/TelemetryConfigurationTests.cs b/tests/Aspire.Cli.Tests/Telemetry/TelemetryConfigurationTests.cs
index 6cacf4e4bc0..047c4a4d17c 100644
--- a/tests/Aspire.Cli.Tests/Telemetry/TelemetryConfigurationTests.cs
+++ b/tests/Aspire.Cli.Tests/Telemetry/TelemetryConfigurationTests.cs
@@ -207,6 +207,33 @@ public void AzureMonitor_Disabled_ForAllHelpFlags(string flag)
Assert.False(manager.HasAzureMonitor);
}
+ [Theory]
+ [InlineData("1")]
+ [InlineData("true")]
+ public void AzureMonitor_Disabled_ForAgentTelemetry_WhenGlobalOptOutSet(string optOutValue)
+ {
+ var configuration = new ConfigurationBuilder()
+ .AddInMemoryCollection(new Dictionary
+ {
+ [AspireCliTelemetry.TelemetryOptOutConfigKey] = optOutValue
+ })
+ .Build();
+
+ using var manager = new TelemetryManager(configuration, ["agent", "telemetry", "--event-type", "skill_invocation"]);
+
+ Assert.False(manager.HasAzureMonitor);
+ }
+
+ [Fact]
+ public void AzureMonitor_Enabled_ForAgentTelemetry_WhenNoOptOutSet()
+ {
+ var configuration = new ConfigurationBuilder().Build();
+
+ using var manager = new TelemetryManager(configuration, ["agent", "telemetry", "--event-type", "skill_invocation"]);
+
+ Assert.True(manager.HasAzureMonitor);
+ }
+
private static ActivityListener CreateActivityListener(string sourceName)
{
var listener = new ActivityListener
diff --git a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs
index 509b093cbd5..0d8a0209da3 100644
--- a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs
+++ b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs
@@ -150,6 +150,8 @@ public static IServiceCollection CreateServiceCollection(TemporaryWorkspace work
services.AddSingleton(options.AspireSkillsInstallerFactory);
services.AddSingleton(options.PlaywrightCliRunnerFactory);
services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
services.AddSingleton(options.ScaffoldingServiceFactory);
services.AddSingleton();
services.AddSingleton(options.AppHostServerSessionFactory);
@@ -251,6 +253,7 @@ public static IServiceCollection CreateServiceCollection(TemporaryWorkspace work
services.AddTransient();
services.AddTransient();
services.AddTransient();
+ services.AddTransient();
services.AddSingleton();
services.AddTransient();
services.AddTransient();