Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/verify-aspire-skills-bundle.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
99 changes: 99 additions & 0 deletions eng/scripts/aspire-skills-bundle.common.ps1
Original file line number Diff line number Diff line change
@@ -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
}
35 changes: 34 additions & 1 deletion eng/scripts/update-aspire-skills-bundle.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -132,14 +135,44 @@ 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
tag = $release.tagName
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(
Expand Down
46 changes: 46 additions & 0 deletions eng/scripts/verify-aspire-skills-bundle.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down Expand Up @@ -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)'."
}
22 changes: 22 additions & 0 deletions src/Aspire.Cli/Agents/AgentClientKind.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Identifies an agent client (CLI/editor) that Aspire can configure during <c>aspire agent init</c>.
/// </summary>
internal enum AgentClientKind
{
/// <summary>GitHub Copilot CLI.</summary>
CopilotCli,

/// <summary>Anthropic Claude Code.</summary>
ClaudeCode,

/// <summary>Visual Studio Code.</summary>
VsCode,

/// <summary>OpenCode.</summary>
OpenCode,
}
17 changes: 17 additions & 0 deletions src/Aspire.Cli/Agents/AgentEnvironmentScanContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ internal sealed class AgentEnvironmentScanContext
{
private readonly List<AgentEnvironmentApplicator> _applicators = [];
private readonly HashSet<string> _skillBaseDirectories = new(StringComparer.OrdinalIgnoreCase);
private readonly HashSet<AgentClientKind> _detectedClients = [];

/// <summary>
/// Gets the working directory being scanned.
Expand Down Expand Up @@ -58,4 +59,20 @@ public void AddSkillBaseDirectory(string relativeSkillBaseDir)
/// Gets the registered skill base directories for all detected agent environments.
/// </summary>
public IReadOnlyCollection<string> SkillBaseDirectories => _skillBaseDirectories;

/// <summary>
/// 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.
/// </summary>
/// <param name="client">The detected agent client.</param>
public void AddDetectedClient(AgentClientKind client)
{
_detectedClients.Add(client);
}

/// <summary>
/// Gets the set of agent clients detected as present in the environment.
/// </summary>
public IReadOnlyCollection<AgentClientKind> DetectedClients => _detectedClients;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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))
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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))
Expand Down
46 changes: 46 additions & 0 deletions src/Aspire.Cli/Agents/Hooks/HookCommandFormatter.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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
/// (<c>command</c> + <c>args</c>), which passes the script path as a discrete argument and needs no quoting.
/// </summary>
internal static class HookCommandFormatter
{
/// <summary>
/// Quotes a path as a single bash argument. Bash single quotes are literal, so an embedded
/// apostrophe is closed, escaped, and reopened: <c>'</c> becomes <c>'\''</c>.
/// Example: <c>/home/o'brien/x.sh</c> → <c>'/home/o'\''brien/x.sh'</c>.
/// </summary>
public static string QuoteForBash(string path)
=> $"'{path.Replace("'", "'\\''")}'";

/// <summary>
/// Quotes a path as a single PowerShell literal string. PowerShell single quotes are literal and
/// an embedded apostrophe is doubled: <c>'</c> becomes <c>''</c>.
/// Example: <c>C:\Users\o'brien\x.ps1</c> → <c>'C:\Users\o''brien\x.ps1'</c>.
/// </summary>
public static string QuoteForPowerShell(string path)
=> $"'{path.Replace("'", "''")}'";

/// <summary>
/// Builds the Unix command that runs the shell hook script via bash. Running through <c>bash</c>
/// (rather than executing the path directly) avoids depending on the executable bit surviving.
/// </summary>
public static string BuildBashCommand(string shellScriptPath)
=> $"bash {QuoteForBash(shellScriptPath)}";

/// <summary>
/// Builds the Windows command that runs the PowerShell hook script with PowerShell 7+ (<c>pwsh</c>).
/// The GitHub Copilot CLI hooks reference makes <c>pwsh</c> 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
/// <c>-NoProfile</c> avoids profile side effects and startup cost.
/// </summary>
public static string BuildPwshCommand(string powerShellScriptPath)
=> $"pwsh -NoProfile -ExecutionPolicy Bypass -File {QuoteForPowerShell(powerShellScriptPath)}";
}
Loading
Loading