[Automated] Update Aspire skills bundle - #19393
Conversation
|
🚀 Dogfood this PR with:
curl -fsSL https://raw.githubusercontent.com/microsoft/aspire/main/eng/scripts/get-aspire-cli-pr.sh | bash -s -- 19393Or
iex "& { $(irm https://raw.githubusercontent.com/microsoft/aspire/main/eng/scripts/get-aspire-cli-pr.ps1) } 19393" |
There was a problem hiding this comment.
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.shandtrack-telemetry.ps1from 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.
| #!/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 |
| # 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() |
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)
Selected jobs (5)
How these were chosen — grouped by what changed📦 affected project 🔧 Job reasons
Selection computed for commit |
|
Retrying the failed CI jobs for this pull request from the CI run attempt. The rerun is being tracked in the rerun attempt. |
|
Retrying the failed CI jobs for this pull request from the CI run attempt. The rerun is being tracked in the rerun attempt. |
|
Retrying the failed CI jobs for this pull request from the CI run attempt. The rerun is being tracked in the rerun attempt. |
576a9c3 to
2e54923
Compare
There was a problem hiding this comment.
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"
}
}
| # 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 |
| # 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 |
2e54923 to
896f4c0
Compare
There was a problem hiding this comment.
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
catcan block indefinitely when stdin is a TTY (e.g., if executed manually or misconfigured). The previous implementation guarded this; consider restoring a check likeif [ -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,
$Inputis an automatic variable (pipeline enumerator). Assigning to$Inputcan 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) | ||
|
|
| # 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 |
896f4c0 to
fb9f57d
Compare
There was a problem hiding this comment.
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 |
| # Read input from stdin | ||
| INPUT=$(cat) |
| # Read input from stdin | ||
| $Input = [Console]::In.ReadToEnd() |
There was a problem hiding this comment.
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()
| # 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 |
| # 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 |
Auto-generated update to refresh the embedded Aspire skills bundle fallback used by the Aspire CLI.