Skip to content

[Automated] Update Aspire skills bundle - #19393

Draft
aspire-repo-bot[bot] wants to merge 1 commit into
mainfrom
update-aspire-skills-bundle
Draft

[Automated] Update Aspire skills bundle#19393
aspire-repo-bot[bot] wants to merge 1 commit into
mainfrom
update-aspire-skills-bundle

Conversation

@aspire-repo-bot

Copy link
Copy Markdown
Contributor

Auto-generated update to refresh the embedded Aspire skills bundle fallback used by the Aspire CLI.

Copilot AI balanced review requested due to automatic review settings August 14, 2026 17:22
@aspire-repo-bot aspire-repo-bot Bot added area-engineering-systems infrastructure helix infra engineering repo stuff area-cli labels Aug 14, 2026
@github-actions

Copy link
Copy Markdown
Contributor

🚀 Dogfood this PR with:

⚠️ WARNING: Do not do this without first carefully reviewing the code of this PR to satisfy yourself it is safe.

curl -fsSL https://raw.githubusercontent.com/microsoft/aspire/main/eng/scripts/get-aspire-cli-pr.sh | bash -s -- 19393

Or

  • Run remotely in PowerShell:
iex "& { $(irm https://raw.githubusercontent.com/microsoft/aspire/main/eng/scripts/get-aspire-cli-pr.ps1) } 19393"

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Note

Copilot could not run the full agentic suite for this review because it was automatically requested on a bot-authored pull request. Request a review from Copilot under Reviewers to retry with the full agentic suite. Improved support for bot-authored pull requests is coming soon.

Replaces the existing telemetry hook implementations with placeholder scripts and extends the embedded aspire-skills metadata to include hook provenance and file hashes.

Changes:

  • Reduced track-telemetry.sh and track-telemetry.ps1 from full telemetry classifiers to no-op placeholders.
  • Added hook commit/file hash metadata to aspire-skills.metadata.json.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.

File Description
src/Aspire.Cli/Agents/Hooks/track-telemetry.sh Replaced the full bash hook logic with a placeholder that currently just reads stdin and exits.
src/Aspire.Cli/Agents/Hooks/track-telemetry.ps1 Replaced the full PowerShell hook logic with a placeholder that currently just reads stdin and exits.
src/Aspire.Cli/Agents/AspireSkills/Embedded/aspire-skills.metadata.json Added hooks metadata (commit SHA + per-file hashes) for the telemetry hook scripts.

💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.

Comment on lines 1 to 15
#!/bin/bash
# Telemetry tracking hook for Aspire Skills
# Placeholder — will be implemented when telemetry requirements are defined
# Reads JSON input from stdin, tracks relevant skill invocation events

# 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. A single EXIT trap guarantees that response is
# emitted exactly once, however the script leaves.
#
# === 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-<tool> (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__<tool> (server named "aspire" in .mcp.json)
# - Skill prefix: aspire:<skill-name> (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_<tool>
# - 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/<aspire-skill>/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/<aspire-skill>/ 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/<skill>/ — never absolute paths, repo
# names, or user names. The Aspire CLI command independently re-validates and drops anything else.
# Read input from stdin
INPUT=$(cat)

# Never abort the agent: failures must be silent and we must still emit {"continue":true}.
set +e
# No-op for now — telemetry implementation TBD
# When implemented, this will track:
# - Which skills are invoked (aspire detection, guardrails, bridge)
# - Client type (Copilot CLI, Claude Code, VS Code, Gemini)
# - Session context (project type detected, guardrails triggered)

# Hook contract enforcement: always print exactly one {"continue":true} and exit 0, however the
# script leaves — normal completion, an early `exit 0`, or an unexpected failure under `set +e`.
# A single EXIT trap is the one guaranteed emit point, so every other path just calls `exit 0`
# and never prints the response itself.
_emitted=0
emit_continue() {
[ "$_emitted" -eq 0 ] || return 0
_emitted=1
printf '%s\n' '{"continue":true}'
}
trap emit_continue EXIT

# 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"

# 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. Lower-case first so the accepted set (1 / any-case true) matches
# the PowerShell hook's case-insensitive check exactly.
case "$(printf '%s' "${ASPIRE_CLI_TELEMETRY_OPTOUT}" | tr '[:upper:]' '[:lower:]')" in
1|true) exit 0 ;;
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
}

# Read a string field's value from anywhere in the payload, best-effort, without assuming the
# payload's shape or which client produced it. The nested tool input arrives in two shapes:
# - Claude/VS Code send a real nested object: "tool_input":{"file_path":"..."}
# - Copilot sends toolArgs as a JSON-encoded STRING whose quotes are escaped:
# "toolArgs":"{\"path\":\"C:\\Users\\me\\skills\\aspire\\SKILL.md\"}"
# Unescaping \" -> " turns the string form into the same flat "field":"value" pairs as the object
# form, so a single extractor reads both. The value's own backslashes (doubled Windows path
# separators) are left intact; the caller's path normalization (tr '\\' '/') collapses them.
extract_nested_field() {
local unescaped
unescaped=$(printf '%s' "$1" | sed 's/\\"/"/g')
extract_json_field "$unescaped" "$2"
}

# 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
exit 0
fi

rawInput=$(cat)
if [ -z "$rawInput" ]; then
exit 0
fi

# Fast path: the vast majority of PostToolUse events are not Aspire-related. Everything we track
# carries "skill"/"Skill" or "aspire" somewhere in the payload (the skill tool name, an aspire-/
# mcp__aspire__ tool name, or a .../skills/<aspire-skill>/ path), so when none of those appear we
# return immediately and skip all of the sed/grep extraction below.
case "$rawInput" in
*skill*|*Skill*|*aspire*|*Aspire*) ;;
*) exit 0 ;;
esac

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
exit 0
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/<skill>/..." 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-<tool> Claude: mcp__aspire__<tool> VS Code: mcp_aspire_<tool>
case "$toolName" in
aspire-*|mcp__aspire__*|mcp_aspire_*)
mcpToolName="$toolName"
eventType="tool_invocation"
shouldTrack=true
;;
esac

if [ "$shouldTrack" != true ]; then
exit 0
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

# Explicit exit 0: the EXIT trap prints the response, but we must not let the CLI's exit code
# (e.g. timeout's 124) leak through as the hook's exit code.
exit 0
Comment on lines +1 to +14
# Telemetry tracking hook for Aspire Skills
# Placeholder — will be implemented when telemetry requirements are defined
# Reads JSON input from stdin, tracks relevant skill invocation events

$ErrorActionPreference = "SilentlyContinue"
# Read input from stdin
$Input = [Console]::In.ReadToEnd()

# Last-resort net for the hook contract: any unhandled terminating error still prints exactly one
# {"continue":true} and exits 0 instead of breaking the agent's tool loop.
trap {
Write-Output '{"continue":true}'
exit 0
}
# No-op for now — telemetry implementation TBD
# When implemented, this will track:
# - Which skills are invoked (aspire detection, guardrails, bridge)
# - Client type (Copilot CLI, Claude Code, VS Code, Gemini)
# - Session context (project type detected, guardrails triggered)

# 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) {
# PowerShell -eq / -ieq are case-insensitive, so this accepts 1 and any-case true,
# matching the lower-cased check in track-telemetry.sh.
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). A read
# failure is exotic and falls through to the top-level trap, which still returns success.
$rawInput = [Console]::In.ReadToEnd()

if ([string]::IsNullOrWhiteSpace($rawInput)) {
Write-Success
}

# Fast path: most PostToolUse events are not Aspire-related. Everything we track carries
# "skill"/"aspire" in the payload (the skill tool name, an aspire-/mcp__aspire__ tool name, or a
# .../skills/<aspire-skill>/ path), so skip JSON parsing entirely when neither appears.
if ($rawInput -notmatch 'skill|aspire') {
Write-Success
}

# A malformed payload yields $null here (or throws into the trap); either way we never guess.
$data = $rawInput | ConvertFrom-Json
if (-not $data) {
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 }

# Copilot encodes toolArgs as a JSON string with escaped quotes (\"field\":\"value\"); unescaping
# them turns it — and the nested-object form Claude/VS Code send (tool_input:{...}) — into the same
# flat "field":"value" pairs. The classification below reads nested fields straight out of this text
# by name, best-effort, rather than assuming the payload's shape or which client produced it.
$normalizedInput = $rawInput -replace '\\"', '"'

$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
}

# Read a string field's value from anywhere in the normalized payload by name (first match wins).
# Used for the nested tool-input values whose container shape differs across clients.
function Get-PayloadField([string] $name) {
$match = [regex]::Match($normalizedInput, '"' + [regex]::Escape($name) + '"\s*:\s*"([^"]*)"')
if ($match.Success) { return $match.Groups[1].Value }
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](Get-PayloadField '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-PayloadField 'path'
if (-not $pathToCheck) { $pathToCheck = Get-PayloadField 'filePath' }
if (-not $pathToCheck) { $pathToCheck = Get-PayloadField 'file_path' }
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-<tool> Claude: mcp__aspire__<tool> VS Code: mcp_aspire_<tool>
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) }

# Bound the call so a hung or slow CLI can't stall the agent's tool loop (mirrors the bash
# `timeout 10`). Run the CLI as a child process we can wait on and kill: an executable on PATH
# (the production `aspire`) is launched directly, while a .ps1 — used by the hook's tests via
# ASPIRE_CLI_COMMAND — is launched through pwsh. stdout/stderr are redirected and drained so a
# banner/log line can neither contaminate the hook's stdout nor deadlock on a full pipe buffer.
# Any failure here is swallowed by the top-level trap.
$psi = [System.Diagnostics.ProcessStartInfo]::new()
if ($aspireCmd -like '*.ps1') {
$psi.FileName = 'pwsh'
foreach ($a in (@('-NoProfile', '-File', $aspireCmd) + $cmdArgs)) { $psi.ArgumentList.Add([string]$a) }
}
else {
$psi.FileName = $aspireCmd
foreach ($a in $cmdArgs) { $psi.ArgumentList.Add([string]$a) }
}
$psi.UseShellExecute = $false
$psi.CreateNoWindow = $true
$psi.RedirectStandardOutput = $true
$psi.RedirectStandardError = $true
$proc = [System.Diagnostics.Process]::Start($psi)
$null = $proc.StandardOutput.ReadToEndAsync()
$null = $proc.StandardError.ReadToEndAsync()
if (-not $proc.WaitForExit(10000)) {
try { $proc.Kill() } catch { }
}

Write-Success
exit 0

$ErrorActionPreference = "SilentlyContinue"
# Read input from stdin
$Input = [Console]::In.ReadToEnd()
@github-actions

Copy link
Copy Markdown
Contributor

Tests selector (audit mode)

The full test matrix and all jobs still run in audit mode. The tests and jobs below are what selective CI would run under enforcement.

2 / 100 test projects · 5 jobs, from 3 changed files.

Selected test projects (2 / 100)

Aspire.Cli.EndToEnd.Tests, Aspire.Cli.Tests

Selected jobs (5)

cli-starter, deployment-e2e, extension-e2e, polyglot, typescript-api-compat


How these were chosen — grouped by what changed

📦 affected project Aspire.Cli
1 test: Aspire.Cli.EndToEnd.Tests

🔧 src/Aspire.Cli/Agents/AspireSkills/Embedded/aspire-skills.metadata.json (changed source)
1 via the project graph: Aspire.Cli.Tests

Job reasons

Job Triggered by
cli-starter • affected project Aspire.Cli
• selected test Aspire.Cli.Tests
deployment-e2e affected project Aspire.Cli
extension-e2e src/Aspire.Cli/Agents/AspireSkills/Embedded/aspire-skills.metadata.json, src/Aspire.Cli/Agents/Hooks/track-telemetry.ps1, src/Aspire.Cli/Agents/Hooks/track-telemetry.sh
• affected project Aspire.Cli
polyglot affected project Aspire.Cli
typescript-api-compat affected project Aspire.Cli

Selection computed for commit 576a9c3.

@github-actions

Copy link
Copy Markdown
Contributor

Retrying the failed CI jobs for this pull request from the CI run attempt. The rerun is being tracked in the rerun attempt.

@github-actions

Copy link
Copy Markdown
Contributor

Retrying the failed CI jobs for this pull request from the CI run attempt. The rerun is being tracked in the rerun attempt.

@github-actions

Copy link
Copy Markdown
Contributor

Retrying the failed CI jobs for this pull request from the CI run attempt. The rerun is being tracked in the rerun attempt.

Copilot AI review requested due to automatic review settings August 15, 2026 17:09
@github-actions
github-actions Bot force-pushed the update-aspire-skills-bundle branch from 576a9c3 to 2e54923 Compare August 15, 2026 17:09

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.

Suppressed comments (3)

src/Aspire.Cli/Agents/Hooks/track-telemetry.ps1:6

  • PowerShell has an automatic variable named $Input (used for pipeline input enumeration). Assigning to $Input here is error-prone and can cause unexpected behavior. Rename this variable to something like $rawInput or $payload.
$Input = [Console]::In.ReadToEnd()

src/Aspire.Cli/Agents/Hooks/track-telemetry.sh:7

  • The script unconditionally blocks reading stdin. If this gets invoked without stdin fully closed (or interactively), it can hang. The prior implementation avoided this (TTY/no-stdin fast path). Consider restoring a non-hook/TTY guard and still honoring the hook contract by emitting {"continue":true} immediately when there is no payload.
INPUT=$(cat)

src/Aspire.Cli/Agents/AspireSkills/Embedded/aspire-skills.metadata.json:13

  • If this hooks metadata is used for integrity validation or installation, it’s important that the hash values are derived from the exact distributed hook file contents using a clearly defined algorithm (and are automatically regenerated as part of the release process). Consider documenting the hash algorithm/source (e.g., sha512 of file bytes) and ensuring there’s tooling/CI to prevent these values from drifting from the actual hook contents.
  "sha512": "d968e7c9268d92c5964490ee5f96182c06a1d48879fe3f56e496544c2ec172596307a82cb4d8d1ee5f680db33570c3f12aae8768f186a5deac973d2ca06ca8bf",
  "hooks": {
    "commitSha": "f8270164334fff609e6b301de8486e5e04207221",
    "files": {
      "track-telemetry.sh": "235af8cc2751eacee0bc815d70fb69451a31332bbb2f292a05afd41343b6e10bbbbbbbcf06ffcfd5b68249e681b162f12e02c11b0c19a843fca040366c266795",
      "track-telemetry.ps1": "a8678831e9c9cdf576b321d22e1119cb4e47714d01ba89546abee7640cb5758e78f9445fc572d52eaa958299d9694c8adec8254481c58a6e158667c10ea012c6"
    }
  }

Comment on lines +6 to 15
# Read input from stdin
INPUT=$(cat)

# Never abort the agent: failures must be silent and we must still emit {"continue":true}.
set +e
# No-op for now — telemetry implementation TBD
# When implemented, this will track:
# - Which skills are invoked (aspire detection, guardrails, bridge)
# - Client type (Copilot CLI, Claude Code, VS Code, Gemini)
# - Session context (project type detected, guardrails triggered)

# Hook contract enforcement: always print exactly one {"continue":true} and exit 0, however the
# script leaves — normal completion, an early `exit 0`, or an unexpected failure under `set +e`.
# A single EXIT trap is the one guaranteed emit point, so every other path just calls `exit 0`
# and never prints the response itself.
_emitted=0
emit_continue() {
[ "$_emitted" -eq 0 ] || return 0
_emitted=1
printf '%s\n' '{"continue":true}'
}
trap emit_continue EXIT

# 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"

# 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. Lower-case first so the accepted set (1 / any-case true) matches
# the PowerShell hook's case-insensitive check exactly.
case "$(printf '%s' "${ASPIRE_CLI_TELEMETRY_OPTOUT}" | tr '[:upper:]' '[:lower:]')" in
1|true) exit 0 ;;
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
}

# Read a string field's value from anywhere in the payload, best-effort, without assuming the
# payload's shape or which client produced it. The nested tool input arrives in two shapes:
# - Claude/VS Code send a real nested object: "tool_input":{"file_path":"..."}
# - Copilot sends toolArgs as a JSON-encoded STRING whose quotes are escaped:
# "toolArgs":"{\"path\":\"C:\\Users\\me\\skills\\aspire\\SKILL.md\"}"
# Unescaping \" -> " turns the string form into the same flat "field":"value" pairs as the object
# form, so a single extractor reads both. The value's own backslashes (doubled Windows path
# separators) are left intact; the caller's path normalization (tr '\\' '/') collapses them.
extract_nested_field() {
local unescaped
unescaped=$(printf '%s' "$1" | sed 's/\\"/"/g')
extract_json_field "$unescaped" "$2"
}

# 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
exit 0
fi

rawInput=$(cat)
if [ -z "$rawInput" ]; then
exit 0
fi

# Fast path: the vast majority of PostToolUse events are not Aspire-related. Everything we track
# carries "skill"/"Skill" or "aspire" somewhere in the payload (the skill tool name, an aspire-/
# mcp__aspire__ tool name, or a .../skills/<aspire-skill>/ path), so when none of those appear we
# return immediately and skip all of the sed/grep extraction below.
case "$rawInput" in
*skill*|*Skill*|*aspire*|*Aspire*) ;;
*) exit 0 ;;
esac

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
exit 0
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/<skill>/..." 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-<tool> Claude: mcp__aspire__<tool> VS Code: mcp_aspire_<tool>
case "$toolName" in
aspire-*|mcp__aspire__*|mcp_aspire_*)
mcpToolName="$toolName"
eventType="tool_invocation"
shouldTrack=true
;;
esac

if [ "$shouldTrack" != true ]; then
exit 0
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

# Explicit exit 0: the EXIT trap prints the response, but we must not let the CLI's exit code
# (e.g. timeout's 124) leak through as the hook's exit code.
exit 0
Comment on lines +5 to +14
# Read input from stdin
$Input = [Console]::In.ReadToEnd()

# Last-resort net for the hook contract: any unhandled terminating error still prints exactly one
# {"continue":true} and exits 0 instead of breaking the agent's tool loop.
trap {
Write-Output '{"continue":true}'
exit 0
}
# No-op for now — telemetry implementation TBD
# When implemented, this will track:
# - Which skills are invoked (aspire detection, guardrails, bridge)
# - Client type (Copilot CLI, Claude Code, VS Code, Gemini)
# - Session context (project type detected, guardrails triggered)

# 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) {
# PowerShell -eq / -ieq are case-insensitive, so this accepts 1 and any-case true,
# matching the lower-cased check in track-telemetry.sh.
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). A read
# failure is exotic and falls through to the top-level trap, which still returns success.
$rawInput = [Console]::In.ReadToEnd()

if ([string]::IsNullOrWhiteSpace($rawInput)) {
Write-Success
}

# Fast path: most PostToolUse events are not Aspire-related. Everything we track carries
# "skill"/"aspire" in the payload (the skill tool name, an aspire-/mcp__aspire__ tool name, or a
# .../skills/<aspire-skill>/ path), so skip JSON parsing entirely when neither appears.
if ($rawInput -notmatch 'skill|aspire') {
Write-Success
}

# A malformed payload yields $null here (or throws into the trap); either way we never guess.
$data = $rawInput | ConvertFrom-Json
if (-not $data) {
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 }

# Copilot encodes toolArgs as a JSON string with escaped quotes (\"field\":\"value\"); unescaping
# them turns it — and the nested-object form Claude/VS Code send (tool_input:{...}) — into the same
# flat "field":"value" pairs. The classification below reads nested fields straight out of this text
# by name, best-effort, rather than assuming the payload's shape or which client produced it.
$normalizedInput = $rawInput -replace '\\"', '"'

$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
}

# Read a string field's value from anywhere in the normalized payload by name (first match wins).
# Used for the nested tool-input values whose container shape differs across clients.
function Get-PayloadField([string] $name) {
$match = [regex]::Match($normalizedInput, '"' + [regex]::Escape($name) + '"\s*:\s*"([^"]*)"')
if ($match.Success) { return $match.Groups[1].Value }
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](Get-PayloadField '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-PayloadField 'path'
if (-not $pathToCheck) { $pathToCheck = Get-PayloadField 'filePath' }
if (-not $pathToCheck) { $pathToCheck = Get-PayloadField 'file_path' }
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-<tool> Claude: mcp__aspire__<tool> VS Code: mcp_aspire_<tool>
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) }

# Bound the call so a hung or slow CLI can't stall the agent's tool loop (mirrors the bash
# `timeout 10`). Run the CLI as a child process we can wait on and kill: an executable on PATH
# (the production `aspire`) is launched directly, while a .ps1 — used by the hook's tests via
# ASPIRE_CLI_COMMAND — is launched through pwsh. stdout/stderr are redirected and drained so a
# banner/log line can neither contaminate the hook's stdout nor deadlock on a full pipe buffer.
# Any failure here is swallowed by the top-level trap.
$psi = [System.Diagnostics.ProcessStartInfo]::new()
if ($aspireCmd -like '*.ps1') {
$psi.FileName = 'pwsh'
foreach ($a in (@('-NoProfile', '-File', $aspireCmd) + $cmdArgs)) { $psi.ArgumentList.Add([string]$a) }
}
else {
$psi.FileName = $aspireCmd
foreach ($a in $cmdArgs) { $psi.ArgumentList.Add([string]$a) }
}
$psi.UseShellExecute = $false
$psi.CreateNoWindow = $true
$psi.RedirectStandardOutput = $true
$psi.RedirectStandardError = $true
$proc = [System.Diagnostics.Process]::Start($psi)
$null = $proc.StandardOutput.ReadToEndAsync()
$null = $proc.StandardError.ReadToEndAsync()
if (-not $proc.WaitForExit(10000)) {
try { $proc.Kill() } catch { }
}

Write-Success
exit 0
Copilot AI review requested due to automatic review settings August 16, 2026 17:09
@github-actions
github-actions Bot force-pushed the update-aspire-skills-bundle branch from 2e54923 to 896f4c0 Compare August 16, 2026 17:09

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.

Suppressed comments (2)

src/Aspire.Cli/Agents/Hooks/track-telemetry.sh:7

  • Reading stdin unconditionally with cat can block indefinitely when stdin is a TTY (e.g., if executed manually or misconfigured). The previous implementation guarded this; consider restoring a check like if [ -t 0 ]; then ... (while still emitting the required {"continue":true}) to avoid hangs.
# Read input from stdin
INPUT=$(cat)

src/Aspire.Cli/Agents/Hooks/track-telemetry.ps1:6

  • In PowerShell, $Input is an automatic variable (pipeline enumerator). Assigning to $Input can cause confusing behavior and makes future expansion of this script riskier. Rename this to something like $rawInput/$payload.
# Read input from stdin
$Input = [Console]::In.ReadToEnd()

# - Which skills are invoked (aspire detection, guardrails, bridge)
# - Client type (Copilot CLI, Claude Code, VS Code, Gemini)
# - Session context (project type detected, guardrails triggered)

Comment on lines +1 to 7
# Telemetry tracking hook for Aspire Skills
# Placeholder — will be implemented when telemetry requirements are defined
# Reads JSON input from stdin, tracks relevant skill invocation events

$ErrorActionPreference = "SilentlyContinue"
# Read input from stdin
$Input = [Console]::In.ReadToEnd()

}

Write-Success
exit 0
Copilot AI review requested due to automatic review settings August 17, 2026 17:11
@github-actions
github-actions Bot force-pushed the update-aspire-skills-bundle branch from 896f4c0 to fb9f57d Compare August 17, 2026 17:11

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 4 comments.

Suppressed comments (2)

src/Aspire.Cli/Agents/Hooks/track-telemetry.sh:4

  • The header comment states the script 'tracks relevant skill invocation events', but the implementation is currently a no-op. To avoid misleading future readers, update the comment to reflect the current behavior (no-op placeholder) or add a note indicating that no tracking/output occurs yet (while still honoring the hook response contract).
# Telemetry tracking hook for Aspire Skills
# Placeholder — will be implemented when telemetry requirements are defined
# Reads JSON input from stdin, tracks relevant skill invocation events

src/Aspire.Cli/Agents/Hooks/track-telemetry.ps1:3

  • The comment says the hook 'tracks relevant skill invocation events', but the script currently does not. Please update the comment to accurately describe the current placeholder behavior (and ensure the hook response contract is still met).
# Telemetry tracking hook for Aspire Skills
# Placeholder — will be implemented when telemetry requirements are defined
# Reads JSON input from stdin, tracks relevant skill invocation events


# Explicit exit 0: the EXIT trap prints the response, but we must not let the CLI's exit code
# (e.g. timeout's 124) leak through as the hook's exit code.
exit 0
}

Write-Success
exit 0
Comment on lines +6 to +7
# Read input from stdin
INPUT=$(cat)
Comment on lines +5 to +6
# Read input from stdin
$Input = [Console]::In.ReadToEnd()
Copilot AI review requested due to automatic review settings August 17, 2026 17:12

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.

Suppressed comments (2)

src/Aspire.Cli/Agents/Hooks/track-telemetry.sh:7

  • In the current placeholder version, INPUT is read but never used. If the hook is intentionally a no-op, consider removing the stdin read until it’s required; otherwise, use the captured input as part of the implementation. (Also note the previous implementation avoided breaking the session by guaranteeing a JSON response—worth keeping even while placeholder.)
INPUT=$(cat)

src/Aspire.Cli/Agents/Hooks/track-telemetry.ps1:6

  • PowerShell has an automatic variable named $Input (commonly used for pipeline enumerators). Assigning to $Input is confusing and can lead to subtle issues if pipeline input is ever introduced. Rename this to something explicit like $RawInput or $PayloadText (and if it remains unused in the placeholder, consider removing it).
$Input = [Console]::In.ReadToEnd()

Comment on lines +6 to 15
# Read input from stdin
INPUT=$(cat)

# Never abort the agent: failures must be silent and we must still emit {"continue":true}.
set +e
# No-op for now — telemetry implementation TBD
# When implemented, this will track:
# - Which skills are invoked (aspire detection, guardrails, bridge)
# - Client type (Copilot CLI, Claude Code, VS Code, Gemini)
# - Session context (project type detected, guardrails triggered)

# Hook contract enforcement: always print exactly one {"continue":true} and exit 0, however the
# script leaves — normal completion, an early `exit 0`, or an unexpected failure under `set +e`.
# A single EXIT trap is the one guaranteed emit point, so every other path just calls `exit 0`
# and never prints the response itself.
_emitted=0
emit_continue() {
[ "$_emitted" -eq 0 ] || return 0
_emitted=1
printf '%s\n' '{"continue":true}'
}
trap emit_continue EXIT

# 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"

# 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. Lower-case first so the accepted set (1 / any-case true) matches
# the PowerShell hook's case-insensitive check exactly.
case "$(printf '%s' "${ASPIRE_CLI_TELEMETRY_OPTOUT}" | tr '[:upper:]' '[:lower:]')" in
1|true) exit 0 ;;
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
}

# Read a string field's value from anywhere in the payload, best-effort, without assuming the
# payload's shape or which client produced it. The nested tool input arrives in two shapes:
# - Claude/VS Code send a real nested object: "tool_input":{"file_path":"..."}
# - Copilot sends toolArgs as a JSON-encoded STRING whose quotes are escaped:
# "toolArgs":"{\"path\":\"C:\\Users\\me\\skills\\aspire\\SKILL.md\"}"
# Unescaping \" -> " turns the string form into the same flat "field":"value" pairs as the object
# form, so a single extractor reads both. The value's own backslashes (doubled Windows path
# separators) are left intact; the caller's path normalization (tr '\\' '/') collapses them.
extract_nested_field() {
local unescaped
unescaped=$(printf '%s' "$1" | sed 's/\\"/"/g')
extract_json_field "$unescaped" "$2"
}

# 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
exit 0
fi

rawInput=$(cat)
if [ -z "$rawInput" ]; then
exit 0
fi

# Fast path: the vast majority of PostToolUse events are not Aspire-related. Everything we track
# carries "skill"/"Skill" or "aspire" somewhere in the payload (the skill tool name, an aspire-/
# mcp__aspire__ tool name, or a .../skills/<aspire-skill>/ path), so when none of those appear we
# return immediately and skip all of the sed/grep extraction below.
case "$rawInput" in
*skill*|*Skill*|*aspire*|*Aspire*) ;;
*) exit 0 ;;
esac

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
exit 0
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/<skill>/..." 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-<tool> Claude: mcp__aspire__<tool> VS Code: mcp_aspire_<tool>
case "$toolName" in
aspire-*|mcp__aspire__*|mcp_aspire_*)
mcpToolName="$toolName"
eventType="tool_invocation"
shouldTrack=true
;;
esac

if [ "$shouldTrack" != true ]; then
exit 0
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

# Explicit exit 0: the EXIT trap prints the response, but we must not let the CLI's exit code
# (e.g. timeout's 124) leak through as the hook's exit code.
exit 0
Comment on lines +5 to +14
# Read input from stdin
$Input = [Console]::In.ReadToEnd()

# Last-resort net for the hook contract: any unhandled terminating error still prints exactly one
# {"continue":true} and exits 0 instead of breaking the agent's tool loop.
trap {
Write-Output '{"continue":true}'
exit 0
}
# No-op for now — telemetry implementation TBD
# When implemented, this will track:
# - Which skills are invoked (aspire detection, guardrails, bridge)
# - Client type (Copilot CLI, Claude Code, VS Code, Gemini)
# - Session context (project type detected, guardrails triggered)

# 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) {
# PowerShell -eq / -ieq are case-insensitive, so this accepts 1 and any-case true,
# matching the lower-cased check in track-telemetry.sh.
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). A read
# failure is exotic and falls through to the top-level trap, which still returns success.
$rawInput = [Console]::In.ReadToEnd()

if ([string]::IsNullOrWhiteSpace($rawInput)) {
Write-Success
}

# Fast path: most PostToolUse events are not Aspire-related. Everything we track carries
# "skill"/"aspire" in the payload (the skill tool name, an aspire-/mcp__aspire__ tool name, or a
# .../skills/<aspire-skill>/ path), so skip JSON parsing entirely when neither appears.
if ($rawInput -notmatch 'skill|aspire') {
Write-Success
}

# A malformed payload yields $null here (or throws into the trap); either way we never guess.
$data = $rawInput | ConvertFrom-Json
if (-not $data) {
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 }

# Copilot encodes toolArgs as a JSON string with escaped quotes (\"field\":\"value\"); unescaping
# them turns it — and the nested-object form Claude/VS Code send (tool_input:{...}) — into the same
# flat "field":"value" pairs. The classification below reads nested fields straight out of this text
# by name, best-effort, rather than assuming the payload's shape or which client produced it.
$normalizedInput = $rawInput -replace '\\"', '"'

$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
}

# Read a string field's value from anywhere in the normalized payload by name (first match wins).
# Used for the nested tool-input values whose container shape differs across clients.
function Get-PayloadField([string] $name) {
$match = [regex]::Match($normalizedInput, '"' + [regex]::Escape($name) + '"\s*:\s*"([^"]*)"')
if ($match.Success) { return $match.Groups[1].Value }
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](Get-PayloadField '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-PayloadField 'path'
if (-not $pathToCheck) { $pathToCheck = Get-PayloadField 'filePath' }
if (-not $pathToCheck) { $pathToCheck = Get-PayloadField 'file_path' }
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-<tool> Claude: mcp__aspire__<tool> VS Code: mcp_aspire_<tool>
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) }

# Bound the call so a hung or slow CLI can't stall the agent's tool loop (mirrors the bash
# `timeout 10`). Run the CLI as a child process we can wait on and kill: an executable on PATH
# (the production `aspire`) is launched directly, while a .ps1 — used by the hook's tests via
# ASPIRE_CLI_COMMAND — is launched through pwsh. stdout/stderr are redirected and drained so a
# banner/log line can neither contaminate the hook's stdout nor deadlock on a full pipe buffer.
# Any failure here is swallowed by the top-level trap.
$psi = [System.Diagnostics.ProcessStartInfo]::new()
if ($aspireCmd -like '*.ps1') {
$psi.FileName = 'pwsh'
foreach ($a in (@('-NoProfile', '-File', $aspireCmd) + $cmdArgs)) { $psi.ArgumentList.Add([string]$a) }
}
else {
$psi.FileName = $aspireCmd
foreach ($a in $cmdArgs) { $psi.ArgumentList.Add([string]$a) }
}
$psi.UseShellExecute = $false
$psi.CreateNoWindow = $true
$psi.RedirectStandardOutput = $true
$psi.RedirectStandardError = $true
$proc = [System.Diagnostics.Process]::Start($psi)
$null = $proc.StandardOutput.ReadToEndAsync()
$null = $proc.StandardError.ReadToEndAsync()
if (-not $proc.WaitForExit(10000)) {
try { $proc.Kill() } catch { }
}

Write-Success
exit 0
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area-cli area-engineering-systems infrastructure helix infra engineering repo stuff

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant