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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,9 +134,9 @@ For more details, see [Connect to sovereign clouds](https://learn.microsoft.com/

| Client | Skills | MCP Servers | Hooks | Marketplace | Manifest | Status |
|--------|:------:|:-----------:|:-----:|:-----------:|----------|--------|
| **Copilot CLI** | ✅ | ✅ | ✅ (`hooks/hooks.json`) | ✅ `.plugin/` | `.plugin/plugin.json` | ✅ Onboarded |
| **Copilot CLI** | ✅ | ✅ | ✅ (`hooks/copilot-hooks.json`) | ✅ `.plugin/` | `.plugin/plugin.json` | ✅ Onboarded |
| **Claude Code** | ✅ | ✅ | ✅ (`hooks/claude-hooks.json`) | `.claude-plugin/marketplace.json` (exists only in azure-skills repo)| ✅ `plugin/.claude-plugin/plugin.json` | ✅ Onboarded |
| **VS Code Extension** | ✅ (`.agents` folder) | ✅ | ✅ `hooks/hooks.json` (`.agents` folder) | Extension-based | Extension-based | ✅ Onboarded |
| **VS Code Extension** | ✅ (`.agents` folder) | ✅ | ✅ `hooks/copilot-hooks.json` (`.agents` folder) | Extension-based | Extension-based | ✅ Onboarded |
| **IntelliJ** | ✅ (`.agents` folder) | ✅ | ❌ Not supported by client | Extension-based | Extension-based | ✅ Skills Onboarded 🔜 Hooks Support ETA - End of April 2026 |
| **Gemini CLI** | ✅ | ✅ | ❌ Not supported by us | No marketplace | `gemini-extension.json` | ✅ Onboarded|
| **Cursor** | ✅ | ✅ | `plugin/hooks/cursor-hooks.json` | `.cursor-plugin/marketplace.json` (exists only in azure-skills repo) | ✅ `plugin/.cursor-plugin/plugin.json` | ✅ Onboarded. Hooks testing - WIP |
Expand Down
8 changes: 5 additions & 3 deletions docs/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@ These files are used by clients when running agent sessions. We have to maintain

## Copilot CLI

Copilot CLI uses the `hooks.json` hooks manifest. Although it shares the manifest with VS Code, it only uses the `bash` and `powershell` properties defined in it. At runtime, Copilot CLI replaces the `PLUGIN_ROOT` variable to construct the path that can resolve the scripts. On macOS and Linux, it executes the `bash` script. On Windows, it executes the `powershell` script.
Copilot CLI uses the `copilot-hooks.json` hooks manifest, referenced explicitly via the `hooks` property in the Copilot plugin manifest (`.plugin/plugin.json`). Although it shares the manifest with VS Code, it only uses the `bash` and `powershell` properties defined in it. At runtime, Copilot CLI replaces the `PLUGIN_ROOT` variable to construct the path that can resolve the scripts. On macOS and Linux, it executes the `bash` script. On Windows, it executes the `powershell` script.

## VS Code

VS Code uses the `hooks.json` hooks manifest. Although it shares the manifest with Copilot CLI, it only uses the `windows`, `osx` and `linux` properties defined in it. At runtime, VS Code replaces the `PLUGIN_ROOT` variable to construct the path that can resolve the scripts. It then executes the script matching the host OS.
VS Code uses the `copilot-hooks.json` hooks manifest. Although it shares the manifest with Copilot CLI, it only uses the `windows`, `osx` and `linux` properties defined in it. At runtime, VS Code replaces the `PLUGIN_ROOT` variable to construct the path that can resolve the scripts. It then executes the script matching the host OS.

## Claude Code

Expand All @@ -22,4 +22,6 @@ Cursor uses the `cursor-hooks.json` hooks manifest. At runtime, Cursor replaces

## Misc

Most clients look for `hooks/hooks.json` as the default hook configuration and try to use it if no explicit `hooks` property is defined in the plugin manifest. We decided to explicitly define hooks manifest for every client because it's impossible to create one hooks manifest for all clients. Copilot/VS Code, Claude and Cursor use mutually exclusive schema for hooks manifest, which means the manifest is guaranteed to cause syntax errors in one or more clients. Besides, clients use different variables to represent the plugin root. Having the incorrect variable will cause the client to fail to resolve the script path, resulting in runtime failures.
Most clients look for `hooks/hooks.json` as the default hook configuration and try to use it if no explicit `hooks` property is defined in the plugin manifest. We decided to explicitly define a hooks manifest for every client because it's impossible to create one hooks manifest for all clients. Copilot/VS Code, Claude and Cursor use mutually exclusive schema for hooks manifest, which means the manifest is guaranteed to cause syntax errors in one or more clients. Besides, clients use different variables to represent the plugin root. Having the incorrect variable will cause the client to fail to resolve the script path, resulting in runtime failures.

For this reason there is intentionally no file at the default `hooks/hooks.json` path. The Copilot/VS Code manifest is named `copilot-hooks.json` and is referenced explicitly from the Copilot plugin manifest. If a Copilot-format `hooks.json` were left at the default path, clients such as Claude Code — whose `hooks` property is *additive* to the default discovery rather than a replacement — would also load it and fail schema validation against their own hooks manifest (see [issue #2957](https://github.com/microsoft/GitHub-Copilot-for-Azure/issues/2957)).
File renamed without changes.
89 changes: 74 additions & 15 deletions hooks/scripts/track-telemetry.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -84,9 +84,53 @@
# If the path matches AND is not a SKILL.md file, the relative path after
# "skills/" is extracted and emitted as a reference_file_read event.
# SKILL.md reads are tracked as skill_invocation instead (not double-counted).
#
# === Debugging ===
#
# If the AZURE_SKILLS_TELEMETRY_LOG_DIR env var is set, the script will create
# a "raw-input" subdirectory and write each raw JSON input to a timestamped file
# for debugging. It will also append a "telemetry.log" file with MCP args for
# each tracked event.
#
# When using `--plugin-dir` to load a local plugin the AZURE_SKILLS_PLUGIN_ROOT
# env var should be set so that the script can detect local skill paths for
# reference_file_read events.

$ErrorActionPreference = "SilentlyContinue"

# Dumps raw input to a file in the AZURE_SKILLS_TELEMETRY_LOG_DIR/raw-input/
# directory for debugging if the env var is set.
function Write-RawInputToFile {
param([string]$RawInput)
if ($env:AZURE_SKILLS_TELEMETRY_LOG_DIR) {
$logDir = $env:AZURE_SKILLS_TELEMETRY_LOG_DIR
$rawInputDir = Join-Path $logDir 'raw-input'
if (-not (Test-Path -LiteralPath $rawInputDir)) {
New-Item -ItemType Directory -Path $rawInputDir -Force | Out-Null
}
$timestamp = (Get-Date).ToUniversalTime().ToString("yyyyMMddTHHmmssZ")
$rawInputFile = Join-Path $rawInputDir "$timestamp.json"
try {
$RawInput | Out-File -FilePath $rawInputFile -Encoding utf8 -Force
} catch { }
}
}

# Writes a debug log entry to the AZURE_SKILLS_TELEMETRY_LOG_DIR/telemetry.log file
# if the env var is set.
function Write-TelemetryDebugLog {
param([string]$Content)

if ($env:AZURE_SKILLS_TELEMETRY_LOG_DIR) {
$logDir = $env:AZURE_SKILLS_TELEMETRY_LOG_DIR
$logFile = Join-Path $logDir 'telemetry.log'
$logEntry = "$(Get-Date -Format 'yyyy-MM-ddTHH:mm:ss') | $Content"
try {
Add-Content -Path $logFile -Value $logEntry -ErrorAction SilentlyContinue
} catch { }
}
}

# Skip telemetry if opted out
if ($env:AZURE_MCP_COLLECT_TELEMETRY -eq "false") {
Write-Output '{"continue":true}'
Expand Down Expand Up @@ -143,6 +187,8 @@ if ([string]::IsNullOrWhiteSpace($rawInput)) {
Write-Success
}

Write-RawInputToFile -RawInput $rawInput

# === STEP 1: Read and parse input ===

# Parse JSON input
Expand Down Expand Up @@ -228,6 +274,16 @@ $pathPatternClaude = '\.claude/plugins/cache/(azure-skills|claude-plugins-offici
$pathPatternVscodeAgentPlugins = 'agent-plugins/github\.com/microsoft/azure-skills/\.github/plugins/azure-skills/skills/'
$pathPatternAgentsSkills = '\.agents/skills/'

# Put the path patterns into an array for easier iteration
$pathPatterns = @($pathPatternCopilot, $pathPatternClaude, $pathPatternVscodeAgentPlugins, $pathPatternAgentsSkills)

# If $env:AZURE_SKILLS_PLUGIN_ROOT is set, add it to the path patterns for local skill development
if ($env:AZURE_SKILLS_PLUGIN_ROOT) {
$localSkillsPath = [regex]::Escape($env:AZURE_SKILLS_PLUGIN_ROOT) + '/skills/'
$localSkillsPath = $localSkillsPath -replace '\\', '/' -replace '/+', '/'
$pathPatterns += $localSkillsPath
}

$shouldTrack = $false
$eventType = $null
$skillName = $null
Expand Down Expand Up @@ -258,16 +314,13 @@ if ($toolName -eq "view" -or $toolName -eq "Read" -or $toolName -eq "read_file")
# Normalize path: convert to lowercase, replace backslashes, and squeeze consecutive slashes
$pathLower = $pathToCheck.ToLower() -replace '\\', '/' -replace '/+', '/'

# Check for SKILL.md pattern — only match azure-skills paths (see path patterns above)
# Check for SKILL.md pattern — only match azure-skills paths (see pathPatterns above)
$isAzureSkillMd = $false
if ($pathLower -match "${pathPatternCopilot}[^/]+/skill\.md") {
$isAzureSkillMd = $true
} elseif ($pathLower -match "${pathPatternClaude}[^/]+/skill\.md") {
$isAzureSkillMd = $true
} elseif ($pathLower -match "${pathPatternVscodeAgentPlugins}[^/]+/skill\.md") {
$isAzureSkillMd = $true
} elseif ($pathLower -match "${pathPatternAgentsSkills}[^/]+/skill\.md") {
$isAzureSkillMd = $true
foreach ($pattern in $pathPatterns) {
if ($pathLower -match "${pattern}[^/]+/skill\.md") {
$isAzureSkillMd = $true
break
}
}

if ($isAzureSkillMd) {
Expand Down Expand Up @@ -302,15 +355,18 @@ if (-not $filePath -and -not $skillName) {
# Normalize path for matching: replace backslashes and squeeze consecutive slashes
$pathLower = $pathToCheck.ToLower() -replace '\\', '/' -replace '/+', '/'

$matchCopilotSkills = $pathLower -match $pathPatternCopilot
$matchClaudeSkills = $pathLower -match $pathPatternClaude
$matchVscodeAgentPlugins = $pathLower -match $pathPatternVscodeAgentPlugins
$matchAgentsSkills = $pathLower -match $pathPatternAgentsSkills
if ($matchCopilotSkills -or $matchClaudeSkills -or $matchVscodeAgentPlugins -or $matchAgentsSkills) {
$matchesPattern = $false
foreach ($pattern in $pathPatterns) {
if ($pathLower -match $pattern) {
$matchesPattern = $true
break
}
}
if ($matchesPattern) {
# Extract relative path after 'skills/'
$pathNormalized = $pathToCheck -replace '\\', '/' -replace '/+', '/'

if ($pathNormalized -match '(?:azure/(?:[0-9]+\.[0-9]+\.[0-9]+/)?skills|azure-skills/skills|\.agents/skills)/(.+)$') {
if ($pathNormalized -match '.*/skills/(.+)$') {
$filePath = $Matches[1]

if (-not $shouldTrack) {
Expand Down Expand Up @@ -349,6 +405,9 @@ if ($shouldTrack) {
try {
& npx -y @azure/mcp@latest @mcpArgs 2>&1 | Out-Null
} catch { }

# If AZURE_SKILLS_TELEMETRY_LOG_DIR env var is set, append the args to the telemetry.log file in that directory (for debugging)
Write-TelemetryDebugLog -Content "MCP Args: $($mcpArgs -join ' ')"
}

# Output success to stdout (required by hooks)
Expand Down
49 changes: 47 additions & 2 deletions hooks/scripts/track-telemetry.sh
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,17 @@
# If the path matches AND is not a SKILL.md file, the relative path after
# "skills/" is extracted and emitted as a reference_file_read event.
# SKILL.md reads are tracked as skill_invocation instead (not double-counted).
#
# === Debugging ===
#
# If the AZURE_SKILLS_TELEMETRY_LOG_DIR env var is set, the script will create
# a "raw-input" subdirectory and write each raw JSON input to a timestamped file
# for debugging. It will also append a "telemetry.log" file with MCP args for
# each tracked event.
#
# When using `--plugin-dir` to load a local plugin the AZURE_SKILLS_PLUGIN_ROOT
# env var should be set so that the script can detect local skill paths for
# reference_file_read events.

set +e # Don't exit on errors - fail silently for privacy

Expand All @@ -101,6 +112,27 @@ return_success() {
exit 0
}

# Dumps raw input to a file in the AZURE_SKILLS_TELEMETRY_LOG_DIR/raw-input/
# directory for debugging if the env var is set.
write_raw_input_to_file() {
local rawInputValue="$1"
[ -n "$AZURE_SKILLS_TELEMETRY_LOG_DIR" ] || return 0
local rawInputDir="$AZURE_SKILLS_TELEMETRY_LOG_DIR/raw-input"
mkdir -p "$rawInputDir" 2>/dev/null || return 0
local ts
ts=$(date -u +"%Y%m%dT%H%M%SZ")
printf '%s\n' "$rawInputValue" > "$rawInputDir/$ts.json" 2>/dev/null || true
}

# Appends a debug log entry to the AZURE_SKILLS_TELEMETRY_LOG_DIR/telemetry.log
# file if the env var is set.
write_telemetry_debug_log() {
local content="$1"
[ -n "$AZURE_SKILLS_TELEMETRY_LOG_DIR" ] || return 0
local logFile="$AZURE_SKILLS_TELEMETRY_LOG_DIR/telemetry.log"
echo "$(date +"%Y-%m-%dT%H:%M:%S") | $content" >> "$logFile" 2>/dev/null || true
}

# Resolve this script's directory so we can locate bundled skills. In the
# installed plugin, hooks/ and skills/ are siblings under the plugin root, so
# <script-dir>/../../skills/<name>/SKILL.md is the skill definition.
Expand Down Expand Up @@ -184,6 +216,8 @@ if [ -z "$rawInput" ]; then
return_success
fi

write_raw_input_to_file "$rawInput"

# === STEP 1: Read and parse input ===

# Extract fields from hook data
Expand Down Expand Up @@ -250,6 +284,13 @@ is_azure_skills_path() {
[[ "$p" == *".claude/plugins/cache/claude-plugins-official/azure/"*"/skills/"* ]] && return 0
[[ "$p" == *"agent-plugins/github.com/microsoft/azure-skills/.github/plugins/azure-skills/skills/"* ]] && return 0
[[ "$p" == *".agents/skills/"* ]] && return 0
# Local plugin development: match paths under AZURE_SKILLS_PLUGIN_ROOT/skills/
# (e.g. when loading a local plugin via `--plugin-dir`)
if [ -n "$AZURE_SKILLS_PLUGIN_ROOT" ]; then
local localRoot
localRoot=$(echo "$AZURE_SKILLS_PLUGIN_ROOT" | tr '[:upper:]' '[:lower:]' | tr '\\' '/' | sed 's|//*|/|g')
[[ "$p" == *"${localRoot}/skills/"* ]] && return 0
fi
return 1
}

Expand Down Expand Up @@ -319,8 +360,8 @@ if [ -z "$filePath" ] && [ -z "$skillName" ]; then
# Extract relative path after 'skills/'
pathNormalized=$(echo "$pathToCheck" | tr '\\' '/' | sed 's|//*|/|g')

if [[ "$pathNormalized" =~ (azure/([0-9]+\.[0-9]+\.[0-9]+/)?skills|azure-skills/skills|\.agents/skills)/(.+)$ ]]; then
filePath="${BASH_REMATCH[3]}"
if [[ "$pathNormalized" =~ .*/skills/(.+)$ ]]; then
filePath="${BASH_REMATCH[1]}"

if [ "$shouldTrack" = false ]; then
shouldTrack=true
Expand Down Expand Up @@ -358,6 +399,10 @@ if [ "$shouldTrack" = true ]; then

# Publish telemetry via npx
npx -y @azure/mcp@latest "${mcpArgs[@]}" >/dev/null 2>&1 || true

# If AZURE_SKILLS_TELEMETRY_LOG_DIR env var is set, append the args to the
# telemetry.log file in that directory (for debugging)
write_telemetry_debug_log "MCP Args: ${mcpArgs[*]}"
fi

# Output success to stdout (required by hooks)
Expand Down
3 changes: 2 additions & 1 deletion plugins/azure-skills/.plugin/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,6 @@
"diagnostics"
],
"skills": "./skills/",
"mcpServers": "./.mcp.json"
"mcpServers": "./.mcp.json",
"hooks": "./hooks/copilot-hooks.json"
}
3 changes: 2 additions & 1 deletion scripts/src/plugin/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ function main() {
mcpServers: "./.mcp.json"
};
const copilotPluginManifest = {
...pluginManifestBase
...pluginManifestBase,
hooks: "./hooks/copilot-hooks.json"
};
const claudeCodePluginManifest = {
...pluginManifestBase,
Expand Down
Loading