Skip to content
Open
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: 3 additions & 1 deletion docs/specs/aspire-product-telemetry.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,9 @@ Dashboard shutdown retains its service-style policy, even when the host's cancel

### Registration and classification

`aspire agent init` registers all-tool post-invocation hooks for supported Copilot and Claude clients through [TelemetryHookConfigurator](../../src/Aspire.Cli/Agents/Hooks/TelemetryHookConfigurator.cs). Hooks invoke `aspire agent telemetry --hook` directly through the CLI executable or its managed entry point, without launching PowerShell or Bash. Re-running initialization refreshes Aspire's registration while preserving unrelated user hooks.
`aspire agent init` registers user-level post-invocation hooks for a supported Copilot or Claude client only after it registers the `aspire@aspire-skills` plugin or configures the Aspire MCP server for that client at the requested scope, or finds either already set up there. Hooks are always registered at user level, including for `--scope project`. Copilot runs hooks from every configuration source, including a repository's `.claude/settings.json`, and does not evaluate Claude `if` rules, so a project-level hook would start the CLI for unrelated tool calls and a second time alongside the user-level hook. Hooks are registered through [TelemetryHookConfigurator](../../src/Aspire.Cli/Agents/Hooks/TelemetryHookConfigurator.cs). Hooks invoke `aspire agent telemetry --hook` directly through the CLI executable or its managed entry point, without launching PowerShell or Bash. Re-running initialization refreshes Aspire's registration while preserving unrelated user hooks.

Each registration includes a tool-name `matcher` ([CopilotAgentClient](../../src/Aspire.Cli/Agents/Copilot/CopilotAgentClient.cs), [ClaudeCodeAgentClient](../../src/Aspire.Cli/Agents/ClaudeCode/ClaudeCodeAgentClient.cs)) so the client starts the CLI only for tools the classifier can track. The Copilot user hook matches skill and Aspire MCP tools. Copilot matchers see only the tool name, so the user hook does not match file-read tools, which would start the CLI for every read in every repository. Claude matches the Skill and Aspire MCP tools, and matches `Read` only for Aspire skill paths through an `if` rule. Claude Code builds earlier than 2.1.85 ignore `if` and run the Read hook for every read, and builds earlier than 2.1.176 can fail to match Read path rules ([Claude Code changelog](https://github.com/anthropics/claude-code/blob/main/CHANGELOG.md)). Claude Code does not evaluate `if` rules against Skill input ([anthropics/claude-code#95819](https://github.com/anthropics/claude-code/issues/95819)), so the Skill hook runs for every skill and the classifier drops non-Aspire skills. Clients that ignore `matcher`, such as Copilot CLI versions earlier than 1.0.63, invoke the hook for every tool, and the classifier remains the authoritative filter.

[AgentTelemetryHook](../../src/Aspire.Cli/Agents/Hooks/AgentTelemetryHook.cs) classifies incoming tool invocations before initializing telemetry. It reports only the Aspire-owned catalog:

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,16 @@ public static bool HasPluginRegistration(IEnumerable<string> settings, IReadOnly
var enabled = new HashSet<string>(StringComparer.Ordinal);
foreach (var path in settings)
{
if (AgentConfiguration.ReadObject(path) is { } config &&
OptionalObject(config, "enabledPlugins") is { } plugins)
// A JSON null registers nothing, so it reads like a missing key. Other wrong types still throw
// InvalidOperationException from AsObject or GetValue.
if (AgentConfiguration.ReadObject(path)?["enabledPlugins"] is { } pluginsNode)
{
var plugins = pluginsNode.AsObject();
foreach (var pluginName in pluginNames)
{
if (Boolean(plugins, pluginName) is { } value)
if (plugins[pluginName] is { } valueNode)
{
if (value)
if (valueNode.GetValue<bool>())
{
enabled.Add(pluginName);
}
Expand Down
105 changes: 69 additions & 36 deletions src/Aspire.Cli/Agents/ClaudeCode/ClaudeCodeAgentClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,39 @@ internal sealed class ClaudeCodeAgentClient(

private static readonly McpConfigFileHelper.Configuration s_mcp = new() { ServersPropertyName = "mcpServers" };

/// <summary>
/// <c>PostToolUse</c> <c>matcher</c> for the Aspire MCP tools. Reads use <see cref="TelemetryHookReadIf"/>.
/// </summary>
/// <remarks>
/// Claude Code matches unanchored, so the explicit anchors keep each matcher from matching other tool names
/// (https://code.claude.com/docs/en/hooks#matcher-patterns).
/// </remarks>
internal static string TelemetryHookMcpMatcher { get; } = $"^(?:{AgentTelemetryHook.McpMatcher})$";

/// <summary>
/// <c>PostToolUse</c> <c>matcher</c> for the Skill tool.
/// </summary>
/// <remarks>
/// The hook runs for every skill, and the classifier drops non-Aspire skills. A handler <c>if</c> rule such as
/// <c>Skill(aspire*)</c> would avoid starting the CLI for other skills, but Claude Code does not evaluate <c>if</c>
/// rules against Skill input (https://github.com/anthropics/claude-code/issues/95819).
/// </remarks>
internal const string TelemetryHookSkillMatcher = "^Skill$";

internal const string TelemetryHookReadMatcher = "^Read$";

/// <summary>
/// Handler <c>if</c> rule that limits Read hooks to files under an Aspire skill directory.
/// </summary>
/// <remarks>
/// Every canonical skill name starts with <c>aspire</c>. <c>//**/</c> matches at any absolute depth, so this covers
/// plugin, user, and project skill installs (https://code.claude.com/docs/en/hooks#filter-by-tool-name-and-arguments-with-the-if-field).
/// Claude Code builds earlier than 2.1.85 ignore <c>if</c> and run the hook for every read, and the classifier drops
/// non-Aspire reads. Builds earlier than 2.1.176 can fail to match Read path rules
/// (https://github.com/anthropics/claude-code/blob/main/CHANGELOG.md).
/// </remarks>
internal const string TelemetryHookReadIf = "Read(//**/skills/aspire*/**)";

public string Id => ClientId;
public string DisplayName { get; } = AgentCommandStrings.Environment_ClaudeCode;
public AgentEnvironment Environments => AgentEnvironment.ClaudeCode;
Expand Down Expand Up @@ -77,24 +110,11 @@ public string GetMcpConfigPath(AgentConfigurationScope scope, DirectoryInfo work
return (repaired, added);
}

public async Task<TelemetryHookConfigurationResult> ConfigureTelemetryHooksAsync(
AgentEnvironment detectedEnvironments, CancellationToken cancellationToken)
{
if (!detectedEnvironments.HasFlag(AgentEnvironment.ClaudeCode))
{
return new TelemetryHookConfigurationResult(AgentEnvironment.None, []);
}
var reason = await ConfigureTelemetryHookAsync(cancellationToken);

return reason is { } skipReason
? new TelemetryHookConfigurationResult(AgentEnvironment.None,
[new TelemetryHookSkip(AgentEnvironment.ClaudeCode, skipReason)])
: new TelemetryHookConfigurationResult(AgentEnvironment.ClaudeCode, []);
}
public bool SupportsTelemetryHooks => true;

public override string ToString() => Id;

private async Task<TelemetryHookSkipReason?> ConfigureTelemetryHookAsync(CancellationToken cancellationToken)
public async Task<TelemetryHookStatus> ConfigureTelemetryHookAsync(CancellationToken cancellationToken)
{
var claudeDirectory = ClaudeCodePaths.GetConfigDirectory(context, environment);
var settingsPath = Path.Combine(claudeDirectory, "settings.json");
Expand All @@ -107,7 +127,7 @@ [new TelemetryHookSkip(AgentEnvironment.ClaudeCode, skipReason)])
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
logger.LogDebug(ex, "Failed to read Claude settings at {Path}.", settingsPath);
return TelemetryHookSkipReason.WriteFailed;
return TelemetryHookStatus.WriteFailed;
}

JsonObject settings;
Expand All @@ -124,7 +144,7 @@ [new TelemetryHookSkip(AgentEnvironment.ClaudeCode, skipReason)])
{
// Preserve malformed settings rather than replacing content we cannot merge.
logger.LogDebug(ex, "Claude settings at {Path} contained malformed JSON; skipping hook registration.", settingsPath);
return TelemetryHookSkipReason.MalformedConfig;
return TelemetryHookStatus.MalformedConfig;
}

switch (parsed)
Expand All @@ -136,7 +156,7 @@ [new TelemetryHookSkip(AgentEnvironment.ClaudeCode, skipReason)])
settings = existing;
break;
default:
return TelemetryHookSkipReason.UnexpectedConfigShape;
return TelemetryHookStatus.UnexpectedConfigShape;
}
}
else
Expand All @@ -149,7 +169,7 @@ [new TelemetryHookSkip(AgentEnvironment.ClaudeCode, skipReason)])
{
if (hooksNode is not JsonObject hooksObject)
{
return TelemetryHookSkipReason.UnexpectedConfigShape;
return TelemetryHookStatus.UnexpectedConfigShape;
}
hooks = hooksObject;
}
Expand All @@ -164,7 +184,7 @@ [new TelemetryHookSkip(AgentEnvironment.ClaudeCode, skipReason)])
{
if (postToolUseNode is not JsonArray postToolUseArray)
{
return TelemetryHookSkipReason.UnexpectedConfigShape;
return TelemetryHookStatus.UnexpectedConfigShape;
}
postToolUse = postToolUseArray;
}
Expand All @@ -175,35 +195,48 @@ [new TelemetryHookSkip(AgentEnvironment.ClaudeCode, skipReason)])
}

RemoveExistingAspireHooks(postToolUse);
// Register all-tool coverage with direct execution, without a shell on each invocation.
// Run directly, without a shell, and only for tools or skill file reads the classifier can
// track so Claude does not launch the CLI on every tool call or read.
// https://docs.claude.com/en/docs/claude-code/hooks
var (command, args) = AgentTelemetryHook.GetCommand(AgentTelemetryProtocol.HookOptionName);
JsonNode hookGroup = new JsonObject
{
["matcher"] = "*",
["hooks"] = new JsonArray(new JsonObject
{
["type"] = "command",
["command"] = command,
["args"] = new JsonArray(args.Select(arg => (JsonNode?)JsonValue.Create(arg)).ToArray()),
["timeout"] = 30,
}),
};
postToolUse.Add(hookGroup);
postToolUse.Add(CreateHookGroup(TelemetryHookSkillMatcher, condition: null));
postToolUse.Add(CreateHookGroup(TelemetryHookMcpMatcher, condition: null));
postToolUse.Add(CreateHookGroup(TelemetryHookReadMatcher, TelemetryHookReadIf));

try
{
Directory.CreateDirectory(claudeDirectory);
await edit.PublishAsync(settingsPath, settings, cancellationToken);
return null;
return TelemetryHookStatus.Configured;
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
logger.LogDebug(ex, "Failed to write Claude settings at {Path}.", settingsPath);
return TelemetryHookSkipReason.WriteFailed;
return TelemetryHookStatus.WriteFailed;
}
}

private static JsonNode CreateHookGroup(string matcher, string? condition)
{
var (command, args) = AgentTelemetryHook.GetCommand(AgentTelemetryProtocol.HookOptionName);
var hook = new JsonObject
{
["type"] = "command",
["command"] = command,
["args"] = new JsonArray(args.Select(arg => (JsonNode?)JsonValue.Create(arg)).ToArray()),
["timeout"] = 30,
};
if (condition is not null)
{
hook["if"] = condition;
}

return new JsonObject
{
["matcher"] = matcher,
["hooks"] = new JsonArray(hook),
};
}

private static void RemoveExistingAspireHooks(JsonArray postToolUse)
{
// Remove individual Aspire hooks, preserving user hooks that share the same matcher group.
Expand Down
74 changes: 54 additions & 20 deletions src/Aspire.Cli/Agents/Copilot/CopilotAgentClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,16 @@ internal sealed class CopilotAgentClient(
}
};

/// <summary>
/// User-level <c>postToolUse</c> <c>matcher</c> for the skill and Aspire MCP tools.
/// </summary>
/// <remarks>
/// Copilot matchers see only the tool name, so matching file-read tools would launch the CLI for every
/// read in every repository. Copilot anchors the pattern itself, and the explicit anchors keep it correct
/// when evaluated as a plain regular expression (https://docs.github.com/en/copilot/reference/hooks-reference).
/// </remarks>
internal static string TelemetryHookMatcher { get; } = $"^(?:[Ss]kill|{AgentTelemetryHook.McpMatcher})$";

public string Id => ClientId;
public string DisplayName { get; } = AgentCommandStrings.Environment_Copilot;
public AgentEnvironment Environments => AgentEnvironment.CopilotCli | AgentEnvironment.CopilotApp | AgentEnvironment.VsCode;
Expand Down Expand Up @@ -85,6 +95,38 @@ public string GetMcpConfigPath(AgentConfigurationScope scope, DirectoryInfo work
}
}

public IEnumerable<string> GetForwardedMcpConfigPaths(AgentConfigurationScope scope)
{
if (scope is not AgentConfigurationScope.Global)
{
yield break;
}

// VS Code forwards user-profile MCP servers to the Copilot harness. The default profile
// stores them in User/mcp.json and named profiles in User/profiles/<id>/mcp.json:
// https://code.visualstudio.com/docs/agents/reference/mcp-configuration
var home = context.HomeDirectory.FullName;
var userDataRoot = environment.IsWindows()
? AgentPath.GetOverride("APPDATA", context, environment) ?? Path.Combine(home, "AppData", "Roaming")
: environment.IsMacOS()
? Path.Combine(home, "Library", "Application Support")
: AgentPath.GetOverride("XDG_CONFIG_HOME", context, environment) ?? Path.Combine(home, ".config");
foreach (var product in new[] { "Code", "Code - Insiders" })
{
var userDirectory = Path.Combine(userDataRoot, product, "User");
yield return Path.Combine(userDirectory, "mcp.json");

var profilesDirectory = new DirectoryInfo(Path.Combine(userDirectory, "profiles"));
if (profilesDirectory.Exists)
{
foreach (var profile in profilesDirectory.EnumerateDirectories("*", new EnumerationOptions { IgnoreInaccessible = true }))
{
yield return Path.Combine(profile.FullName, "mcp.json");
}
}
}
}

public async Task<(JsonObject Config, string Path)> ReadMcpConfigAsync(
string configFilePath, DirectoryInfo workspaceRoot, AgentConfigurationScope scope,
AgentConfiguration edit, CancellationToken cancellationToken)
Expand All @@ -100,26 +142,18 @@ public string GetMcpConfigPath(AgentConfigurationScope scope, DirectoryInfo work
return (repaired, added);
}

public async Task<TelemetryHookConfigurationResult> ConfigureTelemetryHooksAsync(
AgentEnvironment detectedEnvironments, CancellationToken cancellationToken)
{
var targetEnvironment = detectedEnvironments & (AgentEnvironment.CopilotApp | AgentEnvironment.CopilotCli);
// App and CLI share one user hook. Prefer the App display name when both are detected.
// Canonical scripts still attribute App events to copilot-cli:
// https://github.com/microsoft/aspire-skills/issues/71.
if (targetEnvironment.HasFlag(AgentEnvironment.CopilotApp))
{
targetEnvironment = AgentEnvironment.CopilotApp;
}
if (targetEnvironment == AgentEnvironment.None)
{
return new TelemetryHookConfigurationResult(AgentEnvironment.None, []);
}
// VS Code, the Copilot App, and the Copilot CLI share the Copilot harness, which reads user hooks from
// ~/.copilot/hooks, so one registration covers all of them:
// https://code.visualstudio.com/docs/agent-customization/hooks#_choose-a-hook-source
public bool SupportsTelemetryHooks => true;

public async Task<TelemetryHookStatus> ConfigureTelemetryHookAsync(CancellationToken cancellationToken)
{
try
{
// Copilot reads Claude settings only at project scope, so these user hooks cannot
// duplicate Claude's user hook. Direct exec also avoids a shell launch per tool call.
// duplicate Claude's user hook. Direct exec avoids a shell launch, and the matcher keeps
// Copilot from launching the CLI for tool calls the classifier would discard.
// https://docs.github.com/en/copilot/reference/hooks-reference#command-hooks
var hooksDirectory = Path.Combine(GetSettingsDirectory(), "hooks");
Directory.CreateDirectory(hooksDirectory);
Expand All @@ -134,6 +168,7 @@ public async Task<TelemetryHookConfigurationResult> ConfigureTelemetryHooksAsync
new JsonObject
{
["type"] = "command",
["matcher"] = TelemetryHookMatcher,
["exec"] = command,
["args"] = new JsonArray(args.Select(arg => (JsonNode?)JsonValue.Create(arg)).ToArray()),
["timeoutSec"] = 30,
Expand All @@ -144,13 +179,12 @@ public async Task<TelemetryHookConfigurationResult> ConfigureTelemetryHooksAsync
await edit.ReadBytesForEditAsync(filePath, cancellationToken);
await edit.PublishAsync(filePath, config, cancellationToken);

return new TelemetryHookConfigurationResult(targetEnvironment, []);
return TelemetryHookStatus.Configured;
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
logger.LogDebug(ex, "Failed to write Copilot CLI telemetry hook configuration.");
return new TelemetryHookConfigurationResult(AgentEnvironment.None,
[new TelemetryHookSkip(targetEnvironment, TelemetryHookSkipReason.WriteFailed)]);
logger.LogDebug(ex, "Failed to write Copilot telemetry hook configuration.");
return TelemetryHookStatus.WriteFailed;
}
}

Expand Down
Loading
Loading