File: Agents\ClaudeCode\ClaudeCodeAgentClient.cs
Web Access
Project: src\src\Aspire.Cli\Aspire.Cli.csproj (aspire)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Text.Json;
using System.Text.Json.Nodes;
using Aspire.Cli.Agents.AspireSkills;
using Aspire.Cli.Agents.Hooks;
using Aspire.Cli.Resources;
using Microsoft.Extensions.Logging;
 
namespace Aspire.Cli.Agents.ClaudeCode;
 
internal sealed class ClaudeCodeAgentClient(
    CliExecutionContext context, IEnvironment environment, ILogger<ClaudeCodeAgentClient> logger)
    : IAgentClient
{
    public const string ClientId = "claude";
 
    private static readonly McpConfigFileHelper.Configuration s_mcp = new() { ServersPropertyName = "mcpServers" };
 
    public string Id => ClientId;
    public string DisplayName { get; } = AgentCommandStrings.Environment_ClaudeCode;
    public AgentEnvironment Environments => AgentEnvironment.ClaudeCode;
 
    public string GetSkillBaseDirectory(AgentConfigurationScope scope, DirectoryInfo workspaceRoot)
        => Path.Combine(scope is AgentConfigurationScope.Project
            ? Path.Combine(workspaceRoot.FullName, ClaudeCodePaths.ConfigDirectoryName)
            : ClaudeCodePaths.GetConfigDirectory(context, environment), "skills");
 
    public IEnumerable<string> GetLocalSkillDirectories(AgentConfigurationScope scope, DirectoryInfo workspaceRoot)
    {
        yield return GetSkillBaseDirectory(scope, workspaceRoot);
    }
 
    public bool HasIntegrationRegistration(
        DirectoryInfo workspaceRoot, AgentConfigurationScope scope, CancellationToken cancellationToken)
        => AspireSkillsRemoteConfiguration.HasPluginRegistration(
            GetSettingsFiles(workspaceRoot, scope,
                AgentConfiguration.EnumerateJsonFiles(Path.Combine(ManagedSettingsDirectory, "managed-settings.d"))), cancellationToken);
 
    public string GetIntegrationConfigPath(AgentConfigurationScope scope, DirectoryInfo workspaceRoot)
    {
        var directory = scope is AgentConfigurationScope.Project
            ? Path.Combine(workspaceRoot.FullName, ClaudeCodePaths.ConfigDirectoryName)
            : ClaudeCodePaths.GetConfigDirectory(context, environment);
 
        return Path.Combine(directory, "settings.json");
    }
 
    public Task UpdateIntegrationConfigAsync(
        JsonObject config, DirectoryInfo workspaceRoot, AgentConfigurationScope scope,
        AgentConfiguration edit, CancellationToken cancellationToken)
        => AspireSkillsRemoteConfiguration.ApplyPluginAsync(config,
            GetSettingsFiles(workspaceRoot, scope,
                edit.GetJsonFilesForEdit(Path.Combine(ManagedSettingsDirectory, "managed-settings.d"))),
            GetIntegrationConfigPath(scope, workspaceRoot), edit, cancellationToken);
 
    public string GetMcpConfigPath(AgentConfigurationScope scope, DirectoryInfo workspaceRoot)
        => scope is AgentConfigurationScope.Project ? Path.Combine(workspaceRoot.FullName, ".mcp.json")
            : ClaudeCodePaths.GetGlobalMcpConfigPath(context, environment);
 
    public IEnumerable<(string Path, bool ConfigureServer)> GetMcpConfigPaths(
        AgentConfigurationScope scope, DirectoryInfo workspaceRoot)
        => [(GetMcpConfigPath(scope, workspaceRoot), true)];
 
    public async Task<(JsonObject Config, string Path)> ReadMcpConfigAsync(
        string configFilePath, DirectoryInfo workspaceRoot, AgentConfigurationScope scope,
        AgentConfiguration edit, CancellationToken cancellationToken)
        => (await McpConfigFileHelper.ReadConfigAsync(configFilePath, s_mcp.JsonDocumentOptions, edit, cancellationToken), configFilePath);
 
    public (bool Repaired, bool Added) UpdateMcpConfig(
        JsonObject config, string configFilePath, bool configureServer, AgentConfigurationScope scope)
    {
        var repaired = McpConfigFileHelper.UpdateDeprecatedCommand(config, s_mcp.ServersPropertyName);
        var added = configureServer && McpConfigFileHelper.ConfigureMcpServer(config, s_mcp, scope, configFilePath);
 
        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 override string ToString() => Id;
 
    private async Task<TelemetryHookSkipReason?> ConfigureTelemetryHookAsync(CancellationToken cancellationToken)
    {
        var claudeDirectory = ClaudeCodePaths.GetConfigDirectory(context, environment);
        var settingsPath = Path.Combine(claudeDirectory, "settings.json");
        var edit = new AgentConfiguration();
        byte[]? bytes;
        try
        {
            bytes = await edit.ReadBytesForEditAsync(settingsPath, cancellationToken);
        }
        catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
        {
            logger.LogDebug(ex, "Failed to read Claude settings at {Path}.", settingsPath);
            return TelemetryHookSkipReason.WriteFailed;
        }
 
        JsonObject settings;
        if (bytes is not null)
        {
            using var reader = new StreamReader(new MemoryStream(bytes));
            var content = await reader.ReadToEndAsync(cancellationToken);
            JsonNode? parsed;
            try
            {
                parsed = JsonNode.Parse(content);
            }
            catch (JsonException ex)
            {
                // 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;
            }
 
            switch (parsed)
            {
                case null:
                    settings = new JsonObject();
                    break;
                case JsonObject existing:
                    settings = existing;
                    break;
                default:
                    return TelemetryHookSkipReason.UnexpectedConfigShape;
            }
        }
        else
        {
            settings = new JsonObject();
        }
 
        JsonObject hooks;
        if (settings.TryGetPropertyValue("hooks", out var hooksNode))
        {
            if (hooksNode is not JsonObject hooksObject)
            {
                return TelemetryHookSkipReason.UnexpectedConfigShape;
            }
            hooks = hooksObject;
        }
        else
        {
            hooks = new JsonObject();
            settings["hooks"] = hooks;
        }
 
        JsonArray postToolUse;
        if (hooks.TryGetPropertyValue("PostToolUse", out var postToolUseNode))
        {
            if (postToolUseNode is not JsonArray postToolUseArray)
            {
                return TelemetryHookSkipReason.UnexpectedConfigShape;
            }
            postToolUse = postToolUseArray;
        }
        else
        {
            postToolUse = new JsonArray();
            hooks["PostToolUse"] = postToolUse;
        }
 
        RemoveExistingAspireHooks(postToolUse);
        // Register all-tool coverage with direct execution, without a shell on each invocation.
        // 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);
 
        try
        {
            Directory.CreateDirectory(claudeDirectory);
            await edit.PublishAsync(settingsPath, settings, cancellationToken);
            return null;
        }
        catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
        {
            logger.LogDebug(ex, "Failed to write Claude settings at {Path}.", settingsPath);
            return TelemetryHookSkipReason.WriteFailed;
        }
    }
 
    private static void RemoveExistingAspireHooks(JsonArray postToolUse)
    {
        // Remove individual Aspire hooks, preserving user hooks that share the same matcher group.
        for (var groupIndex = postToolUse.Count - 1; groupIndex >= 0; groupIndex--)
        {
            if (postToolUse[groupIndex] is not JsonObject group
                || group["hooks"] is not JsonArray hooks)
            {
                continue;
            }
            for (var hookIndex = hooks.Count - 1; hookIndex >= 0; hookIndex--)
            {
                if (IsAspireHook(hooks[hookIndex]))
                {
                    hooks.RemoveAt(hookIndex);
                }
            }
            if (hooks.Count == 0)
            {
                postToolUse.RemoveAt(groupIndex);
            }
        }
    }
 
    private static bool IsAspireHook(JsonNode? node)
    {
        // Match legacy scripts or our exact CLI invocation, not arbitrary Aspire commands.
        if (node is not JsonObject hook)
        {
            return false;
        }
        if (hook["command"] is JsonValue commandValue
            && commandValue.TryGetValue<string>(out var command)
            && ReferencesTelemetryScript(command))
        {
            return true;
        }
        if (hook["args"] is JsonArray args)
        {
            var values = args.Select(arg => arg is JsonValue value && value.TryGetValue<string>(out var text) ? text : null).ToArray();
            var executable = hook["command"] is JsonValue executableValue && executableValue.TryGetValue<string>(out var executableText)
                ? executableText : null;
            var (currentCommand, _) = AgentTelemetryHook.GetCommand(AgentTelemetryProtocol.HookOptionName);
            var executableName = Path.GetFileNameWithoutExtension(executable);
            var isDotnet = string.Equals(executableName, "dotnet", StringComparison.OrdinalIgnoreCase);
            var isDirectAspireInvocation = !isDotnet
                && values is [AgentTelemetryProtocol.AgentCommandName, AgentTelemetryProtocol.TelemetryCommandName, AgentTelemetryProtocol.HookOptionName]
                && (executable == currentCommand || string.Equals(executableName, "aspire", StringComparison.OrdinalIgnoreCase));
            var isManagedAspireInvocation = isDotnet
                && values is [var assemblyPath, AgentTelemetryProtocol.AgentCommandName, AgentTelemetryProtocol.TelemetryCommandName, AgentTelemetryProtocol.HookOptionName]
                && string.Equals(Path.GetFileName(assemblyPath), "aspire.dll", StringComparison.OrdinalIgnoreCase);
 
            return isDirectAspireInvocation || isManagedAspireInvocation || values.Any(ReferencesTelemetryScript);
        }
 
        return false;
    }
 
    private static bool ReferencesTelemetryScript(string? value)
        => value is not null
            && (value.Contains(TelemetryHookInstaller.ShellResourceName, StringComparison.OrdinalIgnoreCase)
                || value.Contains(TelemetryHookInstaller.PowerShellResourceName, StringComparison.OrdinalIgnoreCase));
 
    private string ManagedSettingsDirectory
        => AgentPath.GetManagedSettingsDirectory(context, environment, "ClaudeCode", "claude-code");
 
    private IEnumerable<string> GetSettingsFiles(
        DirectoryInfo workspaceRoot, AgentConfigurationScope scope, IEnumerable<string> fragments)
    {
        yield return Path.Combine(ClaudeCodePaths.GetConfigDirectory(context, environment), "settings.json");
        if (scope is AgentConfigurationScope.Project)
        {
            yield return Path.Combine(workspaceRoot.FullName, ".claude", "settings.json");
            yield return Path.Combine(workspaceRoot.FullName, ".claude", "settings.local.json");
        }
        yield return Path.Combine(ManagedSettingsDirectory, "managed-settings.json");
        foreach (var path in fragments)
        {
            yield return path;
        }
    }
}