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 directory Path 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 directory Path 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 directory Path 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 directory Path 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 directory Path 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 directory Path 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 directory Path 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 directory Path 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 directory Path 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 directory Path 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 directory Path 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 directory Path 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 directory Path 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();