File: Commands\AgentInitCommand.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.CommandLine;
using System.CommandLine.Completions;
using System.Diagnostics;
using System.Globalization;
using System.Text.Json;
using Aspire.Cli.Agents;
using Aspire.Cli.Agents.AspireSkills;
using Aspire.Cli.Agents.Hooks;
using Aspire.Cli.Agents.Playwright;
using Aspire.Cli.Git;
using Aspire.Cli.Interaction;
using Aspire.Cli.Projects;
using Aspire.Cli.Resources;
using Aspire.Cli.Utils;
using Spectre.Console;
 
namespace Aspire.Cli.Commands;
 
/// <summary>
/// Command that initializes configuration and copied skills for selected agents.
/// This is the new command under 'aspire agent init'.
/// </summary>
internal sealed class AgentInitCommand : BaseCommand
{
    private readonly IAgentEnvironmentDetector _agentEnvironmentDetector;
    private readonly IAspireSkillsInstaller _aspireSkillsInstaller;
    private readonly PlaywrightCliInstaller _playwrightCliInstaller;
    private readonly IGitRepository _gitRepository;
    private readonly ILanguageDiscovery _languageDiscovery;
    private readonly ITelemetryHookConfigurator _telemetryHookConfigurator;
    private readonly IEnvironment _environment;
    private readonly ICliHostEnvironment _hostEnvironment;
    public AgentInitCommand(
        IAgentEnvironmentDetector agentEnvironmentDetector,
        IAspireSkillsInstaller aspireSkillsInstaller,
        PlaywrightCliInstaller playwrightCliInstaller,
        IGitRepository gitRepository,
        ILanguageDiscovery languageDiscovery,
        ITelemetryHookConfigurator telemetryHookConfigurator,
        IEnvironment environment,
        CommonCommandServices services)
        : base("init", AgentCommandStrings.InitCommand_Description, services)
    {
        _agentEnvironmentDetector = agentEnvironmentDetector;
        _aspireSkillsInstaller = aspireSkillsInstaller;
        _playwrightCliInstaller = playwrightCliInstaller;
        _gitRepository = gitRepository;
        _languageDiscovery = languageDiscovery;
        _telemetryHookConfigurator = telemetryHookConfigurator;
        _environment = environment;
        _hostEnvironment = services.HostEnvironment;
        Options.Add(s_workspaceRootOption);
        Options.Add(s_mcpOption);
        Options.Add(s_agentsOption);
        Options.Add(s_scopeOption);
        Options.Add(s_skillsOption);
    }
 
    internal static readonly Option<string?> s_agentsOption = CreateAgentsOption();
    internal static readonly Option<AgentConfigurationScope> s_scopeOption = CreateScopeOption();
 
    internal static readonly Option<string?> s_workspaceRootOption = new("--workspace-root")
    {
        Description = AgentCommandStrings.InitCommand_WorkspaceRootOptionDescription
    };
 
    internal static readonly Option<string?> s_skillsOption = new("--skills")
    {
        Description = string.Format(CultureInfo.InvariantCulture, AgentCommandStrings.InitCommand_SkillsOptionDescription,
            string.Join(",", SkillDefinition.CliDefined.Select(s => s.Name)),
            ConsoleInteractionService.AllChoice,
            ConsoleInteractionService.NoneChoice),
        Recursive = true
    };
 
    /// <summary>
    /// Confirms whether standalone <c>aspire agent init</c> configures the Aspire MCP server for
    /// selected agent environments. This option is only registered on this command — <c>aspire
    /// new</c> and <c>aspire init</c> do not add it, so MCP configuration is unavailable from
    /// those chained flows by construction. Omitting the option defaults to "no"; passing
    /// <c>--mcp</c> opts in, and <c>--mcp=false</c> is an explicit opt-out.
    /// </summary>
    internal static readonly Option<bool?> s_mcpOption = new("--mcp")
    {
        Description = AgentCommandStrings.InitCommand_McpOptionDescription,
        Recursive = true
    };
 
    private static Option<string?> CreateAgentsOption()
    {
        var supportedClients = string.Join(",", AgentClient.All.Select(static client => client.Id));
        var option = new Option<string?>("--agents")
        {
            HelpName = "agents",
            Description = string.Format(CultureInfo.InvariantCulture, AgentCommandStrings.InitCommand_EnvironmentsOptionDescription,
                supportedClients, ConsoleInteractionService.AllChoice, ConsoleInteractionService.NoneChoice),
            Recursive = true,
            Arity = ArgumentArity.ExactlyOne,
            CustomParser = result =>
            {
                if (result.Tokens.Count == 0)
                {
                    return null;
                }
 
                var value = result.Tokens[0].Value;
                var selected = ConsoleInteractionService.MatchChoices(value, AgentClient.All, static client => client.Id);
                if (selected is null || selected.Count == 0 &&
                    !string.Equals(value, ConsoleInteractionService.NoneChoice, StringComparison.OrdinalIgnoreCase))
                {
                    result.AddError(string.Format(CultureInfo.CurrentCulture, AgentCommandStrings.InitCommand_InvalidEnvironments,
                        value, supportedClients, ConsoleInteractionService.AllChoice, ConsoleInteractionService.NoneChoice));
                }
 
                return value;
            }
        };
        option.CompletionSources.Clear();
        option.CompletionSources.Add(static _ =>
            [.. AgentClient.All.Select(static client => new CompletionItem(client.Id)),
                new CompletionItem(ConsoleInteractionService.AllChoice), new CompletionItem(ConsoleInteractionService.NoneChoice)]);
        return option;
    }
 
    private static Option<AgentConfigurationScope> CreateScopeOption()
    {
        var option = new Option<AgentConfigurationScope>("--scope")
        {
            HelpName = "project|global",
            Description = AgentCommandStrings.InitCommand_ScopeOptionDescription,
            Recursive = true,
            Arity = ArgumentArity.ExactlyOne
        };
        // Native enum parsing also accepts numeric and composite values; scope is one named choice.
        option.Validators.Add(result =>
        {
            if (result.Tokens.Count == 1 &&
                Enum.TryParse<AgentConfigurationScope>(result.Tokens[0].Value, ignoreCase: true, out _) &&
                !Enum.GetNames<AgentConfigurationScope>().Contains(result.Tokens[0].Value, StringComparer.OrdinalIgnoreCase))
            {
                result.AddError(string.Format(CultureInfo.CurrentCulture, AgentCommandStrings.InitCommand_InvalidScope, result.Tokens[0].Value));
            }
        });
        option.CompletionSources.Clear();
        option.CompletionSources.Add(static _ => [new CompletionItem("project"), new CompletionItem("global")]);
 
        return option;
    }
 
    /// <summary>
    /// Public entry point for executing the init command.
    /// This allows McpInitCommand to delegate to this implementation.
    /// </summary>
    internal Task<CommandResult> ExecuteCommandAsync(ParseResult parseResult, CancellationToken cancellationToken)
    {
        return ExecuteAsync(parseResult, cancellationToken);
    }
 
    /// <summary>
    /// Prompts the user to run agent init after a successful command, then chains into agent init if accepted.
    /// Used by commands (e.g. <c>aspire init</c>, <c>aspire new</c>) to offer agent init as a follow-up step.
    /// Every bundle-sourced skill is pre-selected, including aspireify.
    /// Chained flows never register <c>--mcp</c> and never offer MCP server configuration: this
    /// method always executes agent init with MCP configuration unavailable, so MCP remains an
    /// explicit opt-in reachable only through standalone <c>aspire agent init</c>.
    /// The agent, skill, and scope bindings reuse the standalone command's non-interactive selection semantics.
    /// </summary>
    internal async Task<AgentInitExecutionResult> PromptAndChainAsync(
        IInteractionService interactionService,
        int previousResultExitCode,
        DirectoryInfo workspaceRoot,
        PromptBinding<bool> agentInitBinding,
        PromptBinding<string?> agentsBinding,
        PromptBinding<string?> skillsBinding,
        PromptBinding<AgentConfigurationScope> scopeBinding,
        CancellationToken cancellationToken)
    {
        if (previousResultExitCode != CliExitCodes.Success)
        {
            return new(previousResultExitCode, [], []);
        }
 
        // Add a separating line between prompt and previous work in aspire new and aspire init.
        interactionService.DisplayEmptyLine();
 
        var runAgentInit = await interactionService.PromptConfirmAsync(
            SharedCommandStrings.PromptRunAgentInit,
            binding: agentInitBinding,
            cancellationToken: cancellationToken);
 
        if (runAgentInit)
        {
            return await ExecuteAgentInitAsync(
                workspaceRoot, agentsBinding, skillsBinding, scopeBinding, mcpBinding: null, cancellationToken);
        }
 
        return new(CliExitCodes.Success, [], []);
    }
 
    protected override async Task<CommandResult> ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken)
    {
        var workspaceRoot = await PromptForWorkspaceRootAsync(parseResult, cancellationToken);
        var result = await ExecuteAgentInitAsync(
            workspaceRoot,
            PromptBinding.Create(parseResult, s_agentsOption),
            PromptBinding.Create(parseResult, s_skillsOption),
            PromptBinding.Create(parseResult, s_scopeOption, AgentConfigurationScope.Project),
            PromptBinding.CreateBoolConfirm(parseResult, s_mcpOption, defaultValue: false),
            cancellationToken);
        return CommandResult.FromExitCode(result.ExitCode);
    }
 
    private async Task<DirectoryInfo> PromptForWorkspaceRootAsync(ParseResult parseResult, CancellationToken cancellationToken)
    {
        // Try to discover the git repository root to use as the default workspace root
        var gitRoot = await _gitRepository.GetRootAsync(cancellationToken);
        var defaultWorkspaceRoot = gitRoot ?? ExecutionContext.WorkingDirectory;
 
        // Prompt the user for the workspace root
        var workspaceRootPath = await InteractionService.PromptForFilePathAsync(
            McpCommandStrings.InitCommand_WorkspaceRootPrompt,
            binding: PromptBinding.Create(parseResult, s_workspaceRootOption, defaultWorkspaceRoot.FullName),
            validator: path =>
            {
                if (string.IsNullOrWhiteSpace(path))
                {
                    return ValidationResult.Error(McpCommandStrings.InitCommand_WorkspaceRootRequired);
                }
 
                if (!Directory.Exists(path))
                {
                    return ValidationResult.Error(string.Format(CultureInfo.InvariantCulture, McpCommandStrings.InitCommand_WorkspaceRootNotFound, path));
                }
 
                return ValidationResult.Success();
            },
            directory: true,
            cancellationToken: cancellationToken);
 
        return new DirectoryInfo(workspaceRootPath);
    }
 
    private async Task<AgentInitExecutionResult> ExecuteAgentInitAsync(
        DirectoryInfo workspaceRoot,
        PromptBinding<string?> agentsBinding,
        PromptBinding<string?> skillsBinding,
        PromptBinding<AgentConfigurationScope> scopeBinding,
        PromptBinding<bool>? mcpBinding,
        CancellationToken cancellationToken)
    {
        var context = new AgentEnvironmentScanContext
        {
            WorkingDirectory = ExecutionContext.WorkingDirectory,
            RepositoryRoot = workspaceRoot
        };
 
        AgentEnvironment? detectedEnvironments = null;
 
        var (agentsProvided, requestedAgents) = agentsBinding.Resolve();
        IReadOnlyList<AgentClient> selectedClients;
        if (agentsProvided && requestedAgents is not null)
        {
            var matchedClients = ConsoleInteractionService.MatchChoices(requestedAgents, AgentClient.All, static client => client.Id);
            if (matchedClients is null)
            {
                InteractionService.DisplayError(string.Format(CultureInfo.CurrentCulture, AgentCommandStrings.InitCommand_InvalidEnvironments,
                    requestedAgents, string.Join(",", AgentClient.All.Select(static client => client.Id)),
                    ConsoleInteractionService.AllChoice, ConsoleInteractionService.NoneChoice));
                return new(CliExitCodes.InvalidCommand, [], []);
            }
 
            selectedClients = matchedClients;
        }
        else
        {
            detectedEnvironments = await InteractionService.ShowStatusAsync(
                McpCommandStrings.InitCommand_DetectingAgentEnvironments,
                async () => await _agentEnvironmentDetector.DetectAsync(context, cancellationToken),
                emoji: KnownEmojis.Robot);
 
            // Shared discovery does not authorize configuring another agent. Defaults
            // express detected agent use, independently of assets and their destinations.
            var defaultClients = AgentClient.All
                .Where(client => client.Includes(detectedEnvironments.Value)).ToArray();
            if (defaultClients.Length == 0)
            {
                defaultClients = [AgentClient.Copilot];
            }
 
            selectedClients = await InteractionService.PromptForSelectionsAsync(
                AgentCommandStrings.InitCommand_SelectAgentsPrompt,
                AgentClient.All.OrderByDescending(client => defaultClients.Contains(client)),
                static client => client.DisplayName.EscapeMarkup(),
                preSelected: defaultClients,
                optional: true,
                binding: agentsBinding.WithDefault(string.Join(",", defaultClients.Select(static client => client.Id))),
                echoSelected: false,
                cancellationToken: cancellationToken);
        }
 
        // Both explicit and interactive opt-outs stop before acquiring the skill catalog.
        if (selectedClients.Count == 0)
        {
            InteractionService.DisplaySubtleMessage(AgentCommandStrings.InitCommand_NoConfigurationSelected);
            return new(CliExitCodes.Success, [], []);
        }
 
        // Detect the AppHost language to determine which skills to offer.
        // When no language is detected (e.g., standalone `aspire agent init`), language-restricted skills are excluded.
        var detectedLanguage = await _languageDiscovery.DetectLanguageRecursiveAsync(workspaceRoot, cancellationToken);
 
        AspireSkillsBundle? aspireSkillsBundle = null;
        string? bundleInstallFailureMessage = null;
 
        IReadOnlyList<SkillDefinition> availableSkills;
        if (ShouldSkipBundleCatalogResolution(skillsBinding))
        {
            availableSkills = SkillDefinition.CliDefined
                .Where(s => s.IsApplicableToLanguage(detectedLanguage))
                .ToList();
        }
        else
        {
            (availableSkills, aspireSkillsBundle, bundleInstallFailureMessage) = await ResolveAvailableSkillsAsync(detectedLanguage, cancellationToken);
        }
 
        // Order the merged catalog deterministically by name so the prompt is stable
        // regardless of manifest order. OrdinalIgnoreCase matches the case-insensitive
        // --skills parsing used elsewhere.
        availableSkills = [.. availableSkills.OrderBy(static s => s.Name, StringComparer.OrdinalIgnoreCase)];
 
        var defaultSkills = availableSkills.Where(static s => s.IsDefault).ToList();
        var defaultSkillNames = string.Join(",", defaultSkills.Select(s => s.Name));
        var skillsBindingWithDefault = skillsBinding.WithDefault(defaultSkillNames);
 
        // When the bundle failed to install and the caller passed an explicit --skills value
        // that names a bundle-only skill, the upcoming MatchChoicesOrThrow will reject the
        // value as "not a valid choice" with no hint that the underlying cause was the
        // bundle. Surface the install failure first so users can see why the catalog is short.
        // We only do this when the value contains a name that is not in the available catalog
        // and not a CLI-defined skill, so happy-path runs stay silent.
        if (bundleInstallFailureMessage is not null)
        {
            var (wasProvided, requestedSkills, _) = PromptBinding.Resolve(skillsBindingWithDefault);
            if (wasProvided && requestedSkills is not null && HasUnknownBundleSkillCandidate(requestedSkills, availableSkills))
            {
                InteractionService.DisplayError(bundleInstallFailureMessage);
            }
        }
 
        var selectedSkills = await InteractionService.PromptForSelectionsAsync(
            AgentCommandStrings.InitCommand_SelectSkills,
            availableSkills,
            skill => $"{skill.Name.EscapeMarkup()} — {SimplifyDescription(skill.Description).EscapeMarkup()}",
            preSelected: defaultSkills,
            optional: true,
            binding: skillsBindingWithDefault,
            echoSelected: false,
            cancellationToken: cancellationToken);
 
        // `mcpBinding` is only supplied by standalone `aspire agent init` (see s_mcpOption).
        // Chained flows (`aspire new`, `aspire init`) never construct a binding for it, so MCP
        // configuration is unreachable from those flows by construction rather than by a runtime
        // visibility flag. The Aspire MCP server is the only server the CLI configures today, so
        // this is a plain confirmation rather than a selection; the confirmed choice is applied to
        // every selected configuration target.
        var configureMcp = false;
        if (mcpBinding is not null)
        {
            configureMcp = await InteractionService.PromptConfirmAsync(
                AgentCommandStrings.InitCommand_ConfigureMcpServerPrompt,
                binding: mcpBinding,
                cancellationToken: cancellationToken);
        }
 
        if (selectedSkills.Count == 0 && !configureMcp)
        {
            InteractionService.DisplaySubtleMessage(AgentCommandStrings.InitCommand_NoConfigurationSelected);
            return new(CliExitCodes.Success, selectedClients, []);
        }
 
        var (scopeProvided, requestedScope) = scopeBinding.Resolve();
        var scope = scopeProvided ? requestedScope : scopeBinding.DefaultValue;
        var installSkills = selectedSkills.Count > 0;
 
        if (!scopeProvided && _hostEnvironment.SupportsInteractiveInput)
        {
            scope = await InteractionService.PromptForSelectionAsync(
                AgentCommandStrings.InitCommand_SelectScopePrompt,
                new[] { AgentConfigurationScope.Project, AgentConfigurationScope.Global },
                value => value is AgentConfigurationScope.Project
                    ? AgentCommandStrings.InitCommand_ProjectScope
                    : AgentCommandStrings.InitCommand_GlobalScope,
                cancellationToken: cancellationToken);
        }
 
        if (scope is AgentConfigurationScope.Global && detectedEnvironments is null)
        {
            // Capture hook evidence before configuration can create new detection markers.
            detectedEnvironments = await InteractionService.ShowStatusAsync(
                McpCommandStrings.InitCommand_DetectingAgentEnvironments,
                async () => await _agentEnvironmentDetector.DetectAsync(context, cancellationToken),
                emoji: KnownEmojis.Robot);
        }
 
        DisplayInstallationLocations(selectedClients, scope, workspaceRoot, installSkills, configureMcp);
 
        // The selected clients determine destinations; defaults never fabricate detection.
        var selectedSkillDirs = new HashSet<string>(OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal);
        if (selectedSkills.Count > 0)
        {
            selectedSkillDirs.UnionWith(selectedClients
                .Select(client => client.GetSkillBaseDirectory(scope, workspaceRoot, ExecutionContext, _environment)));
        }
 
        // --- Phase 3: Apply copied skill files at each unique destination ---
        // Each skill file write is fast (small markdown files), so sequential execution
        // is fine — parallelizing would complicate error handling for no meaningful gain.
        var hasErrors = false;
 
        var installedSkills = new List<InstalledSkillSummaryItem>();
 
        foreach (var skillDirectory in selectedSkillDirs)
        {
            foreach (var skill in selectedSkills)
            {
                // Playwright CLI is installed via PlaywrightCliInstaller, not as a static skill file
                if (!skill.HasInstallableFiles)
                {
                    continue;
                }
 
                if (skill.SourceKind is SkillSourceKind.AspireSkillsBundle && aspireSkillsBundle is null)
                {
                    continue;
                }
 
                var installResult = await InstallSkillAsync(
                    skillDirectory,
                    GetDisplayConfigurationPath(skillDirectory, scope, workspaceRoot),
                    skill,
                    aspireSkillsBundle,
                    cancellationToken);
                hasErrors |= !installResult.Succeeded;
                if (installResult.UpdatedSkill is not null)
                {
                    installedSkills.Add(installResult.UpdatedSkill);
                }
            }
        }
 
        DisplayInstalledSkillsSummary(installedSkills);
 
        // --- Phase 4: Handle Playwright CLI (installs binary + mirrors skill files to registered directories) ---
        if (selectedSkills.Contains(SkillDefinition.PlaywrightCli))
        {
            try
            {
                var (status, message) = await _playwrightCliInstaller.InstallSelectedAsync(selectedSkillDirs, cancellationToken);
                switch (status)
                {
                    case PlaywrightInstallStatus.Installed:
                        InteractionService.DisplayMessage(KnownEmojis.CheckMarkButton, AgentCommandStrings.InitCommand_InstalledPlaywrightCli);
                        break;
                    case PlaywrightInstallStatus.InstalledWithWarnings:
                        InteractionService.DisplayMessage(KnownEmojis.Warning, message!);
                        break;
                    case PlaywrightInstallStatus.Failed:
                        InteractionService.DisplayError(message!);
                        hasErrors = true;
                        break;
                    case PlaywrightInstallStatus.Skipped:
                        // npm is not available — not an error, just informational.
                        InteractionService.DisplaySubtleMessage(AgentCommandStrings.InitCommand_PlaywrightCliSkipped);
                        break;
                    default:
                        throw new UnreachableException($"Unexpected PlaywrightInstallStatus: {status}");
                }
            }
            catch (InvalidOperationException ex)
            {
                InteractionService.DisplayError(ex.Message);
                hasErrors = true;
            }
        }
 
        // --- Phase 5: Apply MCP server configuration if the user confirmed ---
        if (configureMcp)
        {
            var pathComparer = OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal;
            // Aliased paths can use distinct server keys; coalesce file I/O, not client formats.
            var targetsByPath = selectedClients.SelectMany(client =>
                client.GetMcpConfigPaths(scope, workspaceRoot, ExecutionContext, _environment)
                    .Select(target => (Client: client, target.Path, target.ServersPropertyName, target.ConfigureServer)))
                .GroupBy(static target => target.Path, pathComparer);
            foreach (var targets in targetsByPath)
            {
                var firstTarget = targets.First();
                var description = string.Format(CultureInfo.CurrentCulture,
                    AgentCommandStrings.InitCommand_McpConfigurationDescription, firstTarget.Client.DisplayName, targets.Key);
                try
                {
                    var (config, configFilePath) = await McpConfigFileHelper.ReadSelectedConfigAsync(
                        targets.Key, firstTarget.Client, scope, _environment, cancellationToken);
                    var notifications = new List<(KnownEmoji Emoji, string Message)>();
                    // Legacy repairs share the same selection, scope, and consent as new configuration.
                    foreach (var (client, _, serversPropertyName, configureServer) in targets)
                    {
                        var updated = McpConfigFileHelper.UpdateDeprecatedCommand(config, serversPropertyName);
                        var configured = configureServer && McpConfigFileHelper.ConfigureMcpServer(
                            config, client, scope, configFilePath);
 
                        if (updated)
                        {
                            notifications.Add((KnownEmojis.Wrench,
                                string.Format(CultureInfo.CurrentCulture, AgentCommandStrings.DeprecatedConfigUpdate_Description, client.DisplayName)));
                        }
 
                        if (configured)
                        {
                            notifications.Add((KnownEmojis.CheckMarkButton, string.Format(CultureInfo.CurrentCulture,
                                AgentCommandStrings.InitCommand_McpConfigurationDescription, client.DisplayName, configFilePath)));
                        }
                    }
 
                    if (notifications.Count > 0)
                    {
                        await ConfigurationHelper.WriteSettingsFileAsync(configFilePath, config, cancellationToken);
                    }
 
                    foreach (var (emoji, message) in notifications)
                    {
                        InteractionService.DisplayMessage(emoji, message);
                    }
                }
                catch (Exception ex) when (ex is InvalidOperationException or IOException or UnauthorizedAccessException)
                {
                    InteractionService.DisplayError(ex.Message);
                    if (ex.InnerException is JsonException)
                    {
                        InteractionService.DisplaySubtleMessage(
                            string.Format(CultureInfo.CurrentCulture, AgentCommandStrings.SkippedMalformedConfigFile, description));
                    }
                    hasErrors = true;
                }
            }
        }
 
        // --- Phase 6: Install agent telemetry hooks (default-on, parity with azure-skills) ---
        // Global setup instruments selected, detected supported environments only. Whether telemetry is actually
        // transmitted stays gated by the single ASPIRE_CLI_TELEMETRY_OPTOUT opt-out, which both the
        // hook scripts and the `aspire agent telemetry` command path re-check at runtime.
        if (scope is AgentConfigurationScope.Global)
        {
            var selectedEnvironments = selectedClients.Aggregate(AgentEnvironment.None,
                static (environments, client) => environments | client.Environments);
            await ConfigureTelemetryHooksAsync(detectedEnvironments.GetValueOrDefault() & selectedEnvironments, cancellationToken);
        }
 
        if (hasErrors)
        {
            InteractionService.DisplayMessage(KnownEmojis.Warning, AgentCommandStrings.ConfigurationCompletedWithErrors);
        }
        else
        {
            InteractionService.DisplaySuccess(McpCommandStrings.InitCommand_ConfigurationComplete);
        }
 
        return new(
            hasErrors ? CliExitCodes.InvalidCommand : CliExitCodes.Success,
            selectedClients,
            selectedSkills);
    }
 
    private async Task ConfigureTelemetryHooksAsync(AgentEnvironment detectedEnvironments, CancellationToken cancellationToken)
    {
        TelemetryHookConfigurationResult result;
        try
        {
            result = await _telemetryHookConfigurator.ConfigureAsync(detectedEnvironments, cancellationToken);
        }
        catch (Exception ex) when (ex is not OperationCanceledException)
        {
            // Hook installation is best-effort transparency tooling; never fail `agent init` over it.
            // This deliberately catches everything except cancellation: besides file IO failures, a
            // corrupted CLI build could surface a missing embedded hook script as an
            // InvalidOperationException, and that must not abort the whole command either.
            InteractionService.DisplaySubtleMessage(ex.Message);
            return;
        }
 
        if (result.ConfiguredEnvironments != AgentEnvironment.None)
        {
            var environmentNames = string.Join(", ", Enum.GetValues<AgentEnvironment>()
                .Where(environment => environment != AgentEnvironment.None && result.ConfiguredEnvironments.HasFlag(environment))
                .Select(GetEnvironmentDisplayName));
            InteractionService.DisplayMessage(
                KnownEmojis.BarChart,
                string.Format(CultureInfo.CurrentCulture, AgentCommandStrings.InitCommand_TelemetryHooksInstalled, environmentNames));
        }
 
        foreach (var skip in result.Skipped)
        {
            var environmentName = GetEnvironmentDisplayName(skip.Environment);
            var message = skip.Reason switch
            {
                TelemetryHookSkipReason.MalformedConfig => string.Format(CultureInfo.CurrentCulture, AgentCommandStrings.InitCommand_TelemetryHookSkippedMalformedConfig, environmentName),
                TelemetryHookSkipReason.UnexpectedConfigShape => string.Format(CultureInfo.CurrentCulture, AgentCommandStrings.InitCommand_TelemetryHookSkippedUnexpectedShape, environmentName),
                _ => string.Format(CultureInfo.CurrentCulture, AgentCommandStrings.InitCommand_TelemetryHookWriteFailed, environmentName),
            };
 
            // Skips are surfaced to the user but never treated as command failures: a user-owned
            // config we can't safely modify must not break `agent init`.
            InteractionService.DisplaySubtleMessage(message);
        }
    }
 
    private static string GetEnvironmentDisplayName(AgentEnvironment environment)
        => environment switch
        {
            AgentEnvironment.CopilotCli => "GitHub Copilot CLI",
            AgentEnvironment.CopilotApp => "GitHub Copilot App",
            AgentEnvironment.ClaudeCode => "Claude Code",
            AgentEnvironment.VsCode => "VS Code",
            AgentEnvironment.OpenCode => "OpenCode",
            _ => environment.ToString(),
        };
 
    private async Task<(IReadOnlyList<SkillDefinition> Skills, AspireSkillsBundle? Bundle, string? FailureMessage)> ResolveAvailableSkillsAsync(LanguageId? detectedLanguage, CancellationToken cancellationToken)
    {
        var skills = new List<SkillDefinition>();
        AspireSkillsBundle? bundle = null;
        string? failureMessage = null;
 
        var result = await _aspireSkillsInstaller.InstallAsync(cancellationToken);
        if (result.Status is AspireSkillsInstallStatus.Installed)
        {
            bundle = result.Bundle ?? throw new InvalidOperationException("Aspire skills installer returned an installed result without a bundle.");
            skills.AddRange(bundle.GetSkillDefinitions().Where(static skill => !IsCliDefinedSkillName(skill.Name)));
        }
        else
        {
            // Preserve the install failure so the caller can surface it only when the user
            // passed an explicit --skills value that names a bundle-only skill. Happy-path
            // (interactive prompt with the embedded fallback) stays silent.
            failureMessage = result.Message;
        }
 
        // When the bundle is unavailable (network failure, version mismatch, etc.), fall back
        // silently to the CLI-defined skills. The installer already logs the underlying cause
        // at debug level, so the user is not interrupted with a warning they cannot act on.
        skills.AddRange(SkillDefinition.CliDefined);
 
        return (skills
            .Where(s => s.IsApplicableToLanguage(detectedLanguage))
            .ToList(), bundle, failureMessage);
    }
 
    private static bool HasUnknownBundleSkillCandidate(string requestedSkills, IReadOnlyList<SkillDefinition> availableSkills)
    {
        // Tokens like "all" / "none" don't name skills, so the "looks like a bundle skill but missing"
        // diagnostic doesn't apply — let the normal validation path handle them.
        if (string.IsNullOrWhiteSpace(requestedSkills) ||
            string.Equals(requestedSkills, ConsoleInteractionService.AllChoice, StringComparison.OrdinalIgnoreCase) ||
            string.Equals(requestedSkills, ConsoleInteractionService.NoneChoice, StringComparison.OrdinalIgnoreCase))
        {
            return false;
        }
 
        var requested = requestedSkills.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
        foreach (var name in requested)
        {
            if (IsCliDefinedSkillName(name))
            {
                continue;
            }
 
            if (!availableSkills.Any(s => s.HasName(name, StringComparison.OrdinalIgnoreCase)))
            {
                // A non-CLI name that isn't in the catalog is exactly the case the bundle would have provided.
                return true;
            }
        }
 
        return false;
    }
 
    private static bool ShouldSkipBundleCatalogResolution(PromptBinding<string?> skillsBinding)
    {
        var (wasProvided, optionValue, _) = PromptBinding.Resolve(skillsBinding);
        if (!wasProvided)
        {
            return false;
        }
 
        return ShouldSkipBundleCatalogResolution(optionValue);
    }
 
    private static bool ShouldSkipBundleCatalogResolution(string? value)
    {
        if (string.Equals(value, ConsoleInteractionService.NoneChoice, StringComparison.OrdinalIgnoreCase))
        {
            return true;
        }
 
        if (string.IsNullOrWhiteSpace(value) ||
            string.Equals(value, ConsoleInteractionService.AllChoice, StringComparison.OrdinalIgnoreCase))
        {
            return false;
        }
 
        var selectedSkillNames = value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
        return selectedSkillNames.Length > 0 &&
               selectedSkillNames.All(static name => IsCliDefinedSkillName(name));
    }
 
    private static bool IsCliDefinedSkillName(string name)
    {
        return SkillDefinition.CliDefined.Any(skill => skill.HasName(name, StringComparison.OrdinalIgnoreCase));
    }
 
    /// <summary>
    /// Extracts the single short sentence from a skill description so the selection prompt
    /// stays readable.
    /// </summary>
    /// <remarks>
    /// Bundle manifest descriptions can include a bold skill-type prefix followed by a
    /// short tagline and additional usage guidance, for example:
    ///   "**WORKFLOW SKILL** - Top-level router for Aspire 13.4 distributed apps. Detects the AppHost. USE FOR: ..."
    /// This trims the prefix and returns only the first sentence. Inputs without the prefix
    /// or sentence terminator are returned trimmed-but-otherwise-unchanged so CLI-defined
    /// short descriptions are preserved as-is.
    /// </remarks>
    internal static string SimplifyDescription(string description)
    {
        if (string.IsNullOrWhiteSpace(description))
        {
            return description;
        }
 
        var simplified = description.Trim();
 
        // Strip the leading bold "TYPE SKILL" prefix when present, and only then strip the
        // separator characters that typically follow it. Gating the separator strip on the
        // prefix match avoids silently mutating descriptions that legitimately start with
        // a dash, em-dash, or colon (e.g. "-mode flag explained" or ":memo notes").
        var strippedBoldPrefix = false;
        if (simplified.StartsWith("**", StringComparison.Ordinal))
        {
            var endBold = simplified.IndexOf("**", 2, StringComparison.Ordinal);
            if (endBold > 0)
            {
                simplified = simplified[(endBold + 2)..].TrimStart();
                strippedBoldPrefix = true;
            }
        }
 
        if (strippedBoldPrefix)
        {
            // Separators that typically follow the bold prefix (" - ", " — ", " – ", ": ").
            while (simplified.Length > 0 && simplified[0] is '-' or '\u2013' or '\u2014' or ':')
            {
                simplified = simplified[1..].TrimStart();
            }
        }
 
        // Return up to and including the first sentence-ending punctuation followed by
        // whitespace or end-of-string. This avoids splitting on inline punctuation such
        // as "13.4" or "github.com" inside the first sentence.
        for (var i = 0; i < simplified.Length; i++)
        {
            if (simplified[i] is '.' or '!' or '?'
                && (i + 1 >= simplified.Length || char.IsWhiteSpace(simplified[i + 1])))
            {
                return simplified[..(i + 1)];
            }
        }
 
        return simplified;
    }
 
    /// <summary>
    /// Installs the files for a skill at the specified location, creating or updating them as needed.
    /// </summary>
    /// <returns>The install result, including the skill/location pair when files were updated.</returns>
    private async Task<SkillInstallResult> InstallSkillAsync(
        string skillBaseDirectory,
        string displayLocation,
        SkillDefinition skill,
        AspireSkillsBundle? aspireSkillsBundle,
        CancellationToken cancellationToken)
    {
        var fullSkillDirectoryPath = Path.Combine(skillBaseDirectory, skill.Name);
 
        try
        {
            var skillFiles = await GetSkillFilesAsync(skill, aspireSkillsBundle, cancellationToken);
            var anyFileUpdated = false;
 
            foreach (var skillFile in skillFiles)
            {
                var fullPath = Path.Combine(fullSkillDirectoryPath, skillFile.RelativePath);
                var directory = Path.GetDirectoryName(fullPath);
                if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
                {
                    Directory.CreateDirectory(directory);
                }
 
                if (File.Exists(fullPath))
                {
                    var existingContent = await File.ReadAllTextAsync(fullPath, cancellationToken);
                    if (string.Equals(existingContent.ReplaceLineEndings("\n"), skillFile.Content.ReplaceLineEndings("\n"), StringComparison.Ordinal))
                    {
                        continue;
                    }
                }
 
                await File.WriteAllTextAsync(fullPath, skillFile.Content, cancellationToken);
                anyFileUpdated = true;
            }
 
            if (!anyFileUpdated)
            {
                return new(Succeeded: true, UpdatedSkill: null);
            }
 
            return new(Succeeded: true, new InstalledSkillSummaryItem(skill.Name, displayLocation));
        }
        catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException)
        {
            InteractionService.DisplayError(
                string.Format(CultureInfo.CurrentCulture, AgentCommandStrings.InitCommand_FailedToInstallSkill, skill.Name, fullSkillDirectoryPath, ex.Message));
            return new(Succeeded: false, UpdatedSkill: null);
        }
    }
 
    private void DisplayInstalledSkillsSummary(IReadOnlyList<InstalledSkillSummaryItem> installedSkills)
    {
        if (installedSkills.Count == 0)
        {
            return;
        }
 
        var skillNames = string.Join(", ", GetUniqueValues(installedSkills.Select(static installedSkill => installedSkill.SkillName)));
        var locations = string.Join(", ", GetUniqueValues(installedSkills.Select(static installedSkill => installedSkill.DisplayLocation)));
        var message = string.Join(Environment.NewLine,
            AgentCommandStrings.InitCommand_InstalledSkillsSummary,
            $"  {string.Format(CultureInfo.CurrentCulture, AgentCommandStrings.InitCommand_InstalledSkillsSummarySkills, skillNames)}",
            $"  {string.Format(CultureInfo.CurrentCulture, AgentCommandStrings.InitCommand_InstalledSkillsSummaryLocations, locations)}");
 
        InteractionService.DisplayMessage(KnownEmojis.Robot, message);
    }
 
    private static IReadOnlyList<string> GetUniqueValues(IEnumerable<string> values)
    {
        var uniqueValues = new List<string>();
        var seenValues = new HashSet<string>(StringComparer.Ordinal);
 
        foreach (var value in values)
        {
            if (seenValues.Add(value))
            {
                uniqueValues.Add(value);
            }
        }
 
        return uniqueValues;
    }
 
    private string GetDisplayConfigurationPath(string path, AgentConfigurationScope scope, DirectoryInfo workspaceRoot)
    {
        var root = scope is AgentConfigurationScope.Project ? workspaceRoot : ExecutionContext.HomeDirectory;
        var relativePath = Path.GetRelativePath(root.FullName, path);
        if (Path.IsPathRooted(relativePath) || relativePath == ".." ||
            relativePath.StartsWith($"..{Path.DirectorySeparatorChar}", StringComparison.Ordinal))
        {
            return path;
        }
 
        var displayRelativePath = relativePath
            .Replace(Path.DirectorySeparatorChar, '/')
            .Replace(Path.AltDirectorySeparatorChar, '/');
 
        return scope is AgentConfigurationScope.Global ? $"~/{displayRelativePath}" : displayRelativePath;
    }
 
    private void DisplayInstallationLocations(IReadOnlyList<AgentClient> clients, AgentConfigurationScope scope, DirectoryInfo workspaceRoot, bool installSkills, bool configureMcp)
    {
        var lines = new List<string> { AgentCommandStrings.InitCommand_InstallationLocations };
        var pathComparer = OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal;
        var hasSharedLocation = false;
        if (installSkills)
        {
            var directories = clients.Select(client => client.GetSkillBaseDirectory(scope, workspaceRoot, ExecutionContext, _environment))
                .Distinct(pathComparer);
            foreach (var directory in directories)
            {
                AddLocation(AgentCommandStrings.InitCommand_SkillsLocationHint, directory,
                    [.. AgentClient.All.Where(client => pathComparer.Equals(directory,
                        client.GetSkillBaseDirectory(scope, workspaceRoot, ExecutionContext, _environment)))]);
            }
        }
 
        if (configureMcp)
        {
            var paths = clients.Select(client => client.GetMcpConfigPath(scope, workspaceRoot, ExecutionContext, _environment))
                .Distinct(pathComparer);
            foreach (var path in paths)
            {
                AddLocation(AgentCommandStrings.InitCommand_McpLocationHint, path,
                    [.. AgentClient.All.Where(client => pathComparer.Equals(path,
                        client.GetMcpConfigPath(scope, workspaceRoot, ExecutionContext, _environment)))]);
            }
        }
 
        if (hasSharedLocation)
        {
            lines.Add(AgentCommandStrings.InitCommand_SharedLocationNotice);
        }
 
        InteractionService.DisplaySubtleMessage(string.Join(Environment.NewLine, lines));
 
        void AddLocation(string format, string path, IReadOnlyList<AgentClient> sharedClients)
        {
            var location = GetDisplayConfigurationPath(path, scope, workspaceRoot);
            if (sharedClients.Count > 1)
            {
                hasSharedLocation = true;
                location = string.Format(CultureInfo.CurrentCulture, AgentCommandStrings.InitCommand_SharedLocation,
                    location, string.Join(", ", sharedClients.Select(static client => client.DisplayName)));
            }
 
            lines.Add($"  {string.Format(CultureInfo.CurrentCulture, format, location)}");
        }
    }
 
    private static async Task<IReadOnlyList<SkillAssetFile>> GetSkillFilesAsync(SkillDefinition skill, AspireSkillsBundle? aspireSkillsBundle, CancellationToken cancellationToken)
    {
        if (skill.SkillContent is not null)
        {
            return [new SkillAssetFile("SKILL.md", skill.SkillContent)];
        }
 
        if (skill.SourceKind is SkillSourceKind.AspireSkillsBundle)
        {
            if (aspireSkillsBundle is null)
            {
                throw new InvalidOperationException($"Aspire skills bundle was not resolved for skill '{skill.Name}'.");
            }
 
            return await aspireSkillsBundle.GetSkillFilesAsync(skill, cancellationToken);
        }
 
        throw new InvalidOperationException($"Skill '{skill.Name}' does not define installable files.");
    }
 
    private sealed record InstalledSkillSummaryItem(string SkillName, string DisplayLocation);
 
    private readonly record struct SkillInstallResult(bool Succeeded, InstalledSkillSummaryItem? UpdatedSkill);
}
 
internal readonly record struct AgentInitExecutionResult(
    int ExitCode,
    IReadOnlyList<AgentClient> SelectedClients,
    IReadOnlyList<SkillDefinition> SelectedSkills);