File: Program.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.Diagnostics;
using System.Globalization;
using System.Text;
using System.Text.Json;
using Aspire.Cli.Acquisition;
using Aspire.Cli.Agents;
using Aspire.Cli.Agents.AspireSkills;
using Aspire.Cli.Agents.ClaudeCode;
using Aspire.Cli.Agents.CopilotCli;
using Aspire.Cli.Agents.OpenCode;
using Aspire.Cli.Agents.Playwright;
using Aspire.Cli.Agents.VsCode;
using Aspire.Cli.Backchannel;
using Aspire.Cli.Bundles;
using Aspire.Cli.Caching;
using Aspire.Cli.Certificates;
using Aspire.Cli.Commands;
using Aspire.Cli.Commands.Sdk;
using Aspire.Cli.Configuration;
using Aspire.Cli.Diagnostics;
using Aspire.Cli.Documentation.ApiDocs;
using Aspire.Cli.Documentation.Docs;
using Aspire.Cli.DotNet;
using Aspire.Cli.Git;
using Aspire.Cli.Interaction;
using Aspire.Cli.Layout;
using Aspire.Cli.Mcp;
using Aspire.Cli.Migrations;
using Aspire.Cli.Npm;
using Aspire.Cli.NuGet;
using Aspire.Cli.Packaging;
using Aspire.Cli.Processes;
using Aspire.Cli.Profiling;
using Aspire.Cli.Projects;
using Aspire.Cli.Resources;
using Aspire.Cli.Scaffolding;
using Aspire.Cli.Secrets;
using Aspire.Cli.Telemetry;
using Aspire.Cli.Templating;
using Aspire.Cli.Utils;
using Aspire.Cli.Utils.EnvironmentChecker;
using Aspire.Hosting;
using Aspire.Shared;
using Microsoft.AspNetCore.Certificates.Generation;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Spectre.Console;
using RootCommand = Aspire.Cli.Commands.RootCommand;
 
namespace Aspire.Cli;
 
public class Program
{
    internal const string RootLoggerName = "Aspire.Cli";
 
    private static string GetUsersAspirePath(string? processPath = null)
    {
        return CliPathHelper.GetAspireHomeDirectory(processPath ?? Environment.ProcessPath);
    }
 
    /// <summary>
    /// Contains all logging-related options parsed from command-line arguments.
    /// </summary>
    /// <param name="ConsoleLogLevel">The console log level if specified via --log-level or --debug.</param>
    /// <param name="DebugMode">Whether --debug or -d was specified.</param>
    /// <param name="LogsDirectory">The directory where log files are stored.</param>
    /// <param name="LogFilePath">The full path to the current session's log file.</param>
    internal record CliLoggingOptions(LogLevel? ConsoleLogLevel, bool DebugMode, string LogsDirectory, string LogFilePath);
 
    /// <summary>
    /// Holds the objects created during early CLI startup, before DI is available.
    /// Disposes the logger factory, file logger provider, and error writer.
    /// </summary>
    internal sealed record CliStartupContext(
        CliLoggingOptions LoggingOptions,
        IStartupErrorWriter ErrorWriter,
        ILoggerFactory LoggerFactory,
        FileLoggerProvider FileLoggerProvider,
        ConsoleLogBufferContext LogBufferContext,
        ILogger Logger,
        ConsoleCancellationManager CancellationManager,
        IdentityChannelReader IdentityChannelReader) : IDisposable
    {
        public void Dispose()
        {
            FileLoggerProvider.Dispose();
            LoggerFactory.Dispose();
            ErrorWriter.Dispose();
        }
    }
 
    /// <summary>
    /// Parses logging options from command-line arguments.
    /// Returns all logging configuration including log level, debug mode, and file paths.
    /// </summary>
    internal static CliLoggingOptions ParseLoggingOptions(string[]? args, string? processPath = null)
    {
        LogLevel? logLevel = null;
        var debugMode = false;
 
        if (args is not null && args.Length > 0)
        {
            // Check for --debug or -d (backward compatibility)
            debugMode = args.Any(a => a == "--debug" || a == "-d");
 
            // Check for --log-level or -l
            for (var i = 0; i < args.Length; i++)
            {
                if ((args[i] == "--log-level" || args[i] == "-l") && i + 1 < args.Length)
                {
                    if (Enum.TryParse<LogLevel>(args[i + 1], ignoreCase: true, out var parsedLevel))
                    {
                        logLevel = parsedLevel;
                    }
                    break;
                }
            }
 
            // --debug implies Debug log level if --log-level not specified
            if (debugMode && logLevel is null)
            {
                logLevel = LogLevel.Debug;
            }
        }
 
        var logsDirectory = Path.Combine(GetUsersAspirePath(processPath), "logs");
        var logFilePath = ParseLogFileOption(args) ?? FileLoggerProvider.GenerateLogFilePath(logsDirectory, TimeProvider.System);
 
        return new CliLoggingOptions(logLevel, debugMode, logsDirectory, logFilePath);
    }
 
    /// <summary>
    /// Parses --log-file from raw args before the host is built.
    /// Used by --detach to tell the child CLI where to write its log.
    /// </summary>
    internal static string? ParseLogFileOption(string[]? args)
    {
        if (args is null)
        {
            return null;
        }
 
        for (var i = 0; i < args.Length; i++)
        {
            if (args[i] == "--")
            {
                break;
            }
 
            if (args[i] == "--log-file" && i + 1 < args.Length)
            {
                return args[i + 1];
            }
        }
 
        return null;
    }
 
    private static string GetGlobalSettingsPath(ILogger logger)
    {
        var usersAspirePath = GetUsersAspirePath();
        var newPath = Path.Combine(usersAspirePath, Configuration.AspireConfigFile.FileName);
 
        // TODO: Remove globalsettings.json migration once confident most users have migrated.
        // The old file is intentionally kept so older CLI versions continue to work during
        // the transition period. Tracked by https://github.com/microsoft/aspire/issues/15239
        var legacyPath = Path.Combine(usersAspirePath, "globalsettings.json");
        if (!File.Exists(newPath) && File.Exists(legacyPath))
        {
            try
            {
                var legacyJson = File.ReadAllText(legacyPath);
                var legacyConfig = JsonSerializer.Deserialize(legacyJson, JsonSourceGenerationContext.Default.AspireJsonConfiguration);
                var config = AspireConfigFile.FromLegacy(legacyConfig, profiles: null);
                // Drop the legacy global identity-channel field — the CLI's channel is now
                // baked into the binary (AspireCliChannel assembly metadata) and never read
                // from global config. The per-project channel migration in AspireConfigFile.FromLegacy
                // remains for project-local aspire.config.json files.
                config.Channel = null;
                config.Save(usersAspirePath);
            }
            catch (Exception ex)
            {
                // If migration fails, newPath will be created on first write.
                logger.LogError(ex, "Failed to migrate legacy globalsettings.json to {NewPath}.", newPath);
            }
        }
 
        return newPath;
    }
 
    internal static void WarnIfGlobalSettingsContainAppHostPath(FileInfo globalSettingsFile, IStartupErrorWriter errorWriter)
    {
        if (ConfigurationHelper.TryLoadSettingsFile(globalSettingsFile.FullName, out var globalSettings) &&
            AppHostPathConfigurationPolicy.TryFindAppHostPathKey(globalSettings, out var key))
        {
            var warning = string.Format(
                CultureInfo.CurrentCulture,
                ErrorStrings.GlobalAppHostPathIgnored,
                globalSettingsFile.FullName,
                key);
 
            errorWriter.WriteMarkup(warning.EscapeMarkup(), KnownEmojis.Warning);
        }
    }
 
    /// <summary>
    /// Creates and configures an <see cref="ILoggerFactory"/> for the CLI application.
    /// Sets up OpenTelemetry logging, file logging, console logging, log-level filters,
    /// and MCP console logging based on command-line arguments.
    /// </summary>
    /// <returns>A tuple containing the configured <see cref="ILoggerFactory"/> and the <see cref="FileLoggerProvider"/> used for file logging.</returns>
    internal static (ILoggerFactory LoggerFactory, FileLoggerProvider FileLoggerProvider) CreateLoggerFactory(string[] args, CliLoggingOptions loggingOptions, IStartupErrorWriter errorWriter, ConsoleLogBufferContext logBufferContext)
    {
        var consoleLogLevel = loggingOptions.ConsoleLogLevel;
 
        var isMcpStartCommand = args?.Length >= 2 &&
            ((args[0] == "mcp" && args[1] == "start") || (args[0] == "agent" && args[1] == "mcp"));
 
        // Create file logger provider from pre-computed path info
        var fileLoggerProvider = new FileLoggerProvider(loggingOptions.LogFilePath, errorWriter);
 
        var factory = LoggerFactory.Create(builder =>
        {
            // Always configure OpenTelemetry.
            builder.AddOpenTelemetry(logging =>
            {
                logging.IncludeFormattedMessage = true;
                logging.IncludeScopes = true;
            });
 
            // Always capture complete CLI session details to disk for diagnostics
            builder.AddProvider(fileLoggerProvider);
 
            // Configure log-level filters based on --log-level or --debug
            if (consoleLogLevel is not null)
            {
                builder.AddFilter("Aspire.Cli", consoleLogLevel.Value);
                builder.AddFilter("Microsoft.Hosting.Lifetime", LogLevel.Warning);
            }
            else
            {
                // Default writing debug level logs to file.
                builder.AddFilter<FileLoggerProvider>(null, LogLevel.Debug);
 
                // These categories are very verbose at Debug; suppress their Debug output by
                // raising the minimum to Information unless an explicit log level is requested.
                builder.AddFilter<FileLoggerProvider>("Microsoft.Extensions.Http.DefaultHttpClientFactory", LogLevel.Information);
                builder.AddFilter<FileLoggerProvider>("Aspire.Cli.Certificates.NativeCertificateToolRunner", LogLevel.Information);
            }
 
            // Configure console logging based on --verbosity or --debug.
            // When the CLI is hosted by the VS Code extension, stderr is captured line-by-line
            // and surfaced in the Debug Console, so debug logs still need to flow to stderr there;
            // suppressing them only because an extension endpoint is present meant that
            // `--debug` produced no visible output under F5, even though it works in a terminal.
            if (consoleLogLevel is not null && !isMcpStartCommand)
            {
                // Use custom Spectre Console logger for clean debug output to stderr
                builder.AddProvider(new SpectreConsoleLoggerProvider(Console.Error, logBufferContext));
            }
 
            // For MCP start command, configure console logger to route all logs to stderr
            // This keeps stdout clean for MCP protocol JSON-RPC messages
            if (isMcpStartCommand)
            {
                builder.AddConsole(consoleLogOptions =>
                {
                    // Configure all logs to go to stderr
                    consoleLogOptions.LogToStandardErrorThreshold = LogLevel.Trace;
                });
            }
        });
 
        return (factory, fileLoggerProvider);
    }
 
    internal static async Task<IHost> BuildApplicationAsync(string[] args, CliStartupContext startupContext, Dictionary<string, string?>? configurationValues = null)
    {
        // Check for --non-interactive flag early
        var nonInteractive = args?.Any(a => a == CommonOptionNames.NonInteractive) ?? false;
 
        // Check if running MCP start command - all logs should go to stderr to keep stdout clean for MCP protocol
        // Support both old 'mcp start' and new 'agent mcp' commands
        var isMcpStartCommand = args?.Length >= 2 &&
            ((args[0] == "mcp" && args[1] == "start") || (args[0] == "agent" && args[1] == "mcp"));
 
        var settings = new HostApplicationBuilderSettings
        {
            Configuration = new ConfigurationManager()
        };
        settings.Configuration.AddEnvironmentVariables();
 
        if (configurationValues is not null)
        {
            settings.Configuration.AddInMemoryCollection(configurationValues);
        }
 
        var builder = Host.CreateEmptyApplicationBuilder(settings);
 
        // Set up settings with appropriate paths.
        var globalSettingsFilePath = GetGlobalSettingsPath(startupContext.Logger);
        var globalSettingsFile = new FileInfo(globalSettingsFilePath);
        var workingDirectory = new DirectoryInfo(Environment.CurrentDirectory);
        ConfigurationHelper.RegisterSettingsFiles(builder.Configuration, workingDirectory, globalSettingsFile);
 
        TrySetLocaleOverride(LocaleHelpers.GetLocaleOverride(builder.Configuration), startupContext.Logger, startupContext.ErrorWriter);
        WarnIfGlobalSettingsContainAppHostPath(globalSettingsFile, startupContext.ErrorWriter);
 
#if !DEBUG
        // In release builds, limit shutdown wait time for telemetry flush to 200ms
        // to ensure the CLI exits quickly even if waiting on shutdown tasks.
        builder.Services.Configure<HostOptions>(options =>
        {
            options.ShutdownTimeout = TimeSpan.FromMilliseconds(200);
        });
#endif
 
        // Register the provided logger factory, replacing host defaults
        builder.Services.AddSingleton<ILoggerFactory>(startupContext.LoggerFactory);
        builder.Services.TryAddSingleton(typeof(ILogger<>), typeof(Logger<>));
 
        // Register the cancellation manager so commands can observe forced-termination signals.
        // It is registered as the existing instance (not a type) so disposal ownership stays with
        // Program.Main, which owns its lifetime via a `using` statement.
        builder.Services.AddSingleton(startupContext.CancellationManager);
 
        // Register file logger provider for components that write directly to the log file
        builder.Services.AddSingleton(startupContext.FileLoggerProvider);
 
        // Register the log buffer context so the interaction service can pause logging during prompts
        builder.Services.AddSingleton(startupContext.LogBufferContext);
 
        // Register logging options so components can read the user's chosen log level
        builder.Services.AddSingleton(startupContext.LoggingOptions);
 
        // The graceful-shutdown window is the same object as the CCM (CCM owns the OS-signal
        // registration AND the graceful budget/clock/token). Map the consumer-facing interface to
        // the CCM singleton so per-child shutdown ladders depend on the narrow contract rather than
        // the whole signal manager.
        builder.Services.AddSingleton<IGracefulShutdownWindow>(sp => sp.GetRequiredService<ConsoleCancellationManager>());
 
        // Configure OpenTelemetry tracing. TelemetryManager reads configuration and creates
        // separate TracerProvider instances:
        // - Azure Monitor provider with filtering (only exports activities with EXTERNAL_TELEMETRY=true)
        // - Profiling provider for explicit startup profiling OTLP export
        // - Diagnostic provider for DEBUG-only diagnostics
        builder.Services.AddSingleton(sp => TelemetryConfiguration.Create(sp.GetRequiredService<IConfiguration>(), args));
        builder.Services.AddSingleton<TelemetryManager>();
 
        // Shared services.
        builder.Services.AddSingleton<IProcessPathProvider, EnvironmentProcessPathProvider>();
        // Two identity readers coexist by design. `IdentityChannelReader` is constructed early in
        // CliStartupContext so the channel can be logged at startup before DI is fully wired, and it
        // continues to power that early startup log. `IIdentityResolver` is the richer reader that
        // also resolves sidecar/env overrides for version, commit, and the NuGet service index; it
        // powers `CliExecutionContext` identity population.
        builder.Services.AddSingleton<IIdentityChannelReader>(startupContext.IdentityChannelReader);
        builder.Services.AddSingleton<IEnvironment, HostEnvironment>();
        builder.Services.AddSingleton<IWindowsRegistryReader>(sp =>
        {
            var environment = sp.GetRequiredService<IEnvironment>();
            return environment.IsWindows()
                ? new WindowsRegistryReader()
                : new NullWindowsRegistryReader();
        });
        builder.Services.AddSingleton<WingetFirstRunProbe>();
        builder.Services.AddSingleton<IIdentityResolver>(sp =>
        {
            // Binary dir is the directory containing the running executable.
            // We pass it explicitly because the sidecar reader resolves the
            // sidecar path relative to it (<binaryDir>/.aspire-install.json).
            //
            // This is the right anchor for every shipping install route because
            // IProcessPathProvider resolves to the actual native CLI binary
            // that is running, and each route either co-locates its sidecar next
            // to that binary or intentionally ships none (see
            // docs/specs/install-routes.md):
            //   - dotnet tool: the RID-specific tool nupkg payload-embeds
            //     .aspire-install.json next to the native binary (staged by
            //     Aspire.Cli.csproj _PreparePreBuiltCliBinaryForPackTool), so
            //     ProcessPath points at the payload binary and the sidecar is found.
            //   - npm: eng/clipack/npm/aspire.js spawns the extracted native binary
            //     directly (child_process.spawn), so ProcessPath is that native exe.
            //     The npm package consumes the sidecar-free shared archive and there
            //     is no npm install source, so no sidecar is found and identity falls
            //     back to the assembly stamp — correct for an official npm build.
            //   - managed-host launch (dotnet aspire.dll in tests/dev) or a null
            //     ProcessPath: the resolver simply skips the sidecar layer and the
            //     env → assembly → terminal default fallbacks still apply.
            var processPathProvider = sp.GetRequiredService<IProcessPathProvider>();
            var binaryDir = processPathProvider.ProcessPath is { Length: > 0 } p
                ? Path.GetDirectoryName(p)
                : null;
            return new IdentityResolver(
                sp.GetRequiredService<IInstallSidecarReader>(),
                typeof(Program).Assembly,
                binaryDir,
                sp.GetRequiredService<IEnvironment>());
        });
        builder.Services.AddSingleton(sp =>
        {
            // Use the resolver overload so env/sidecar overrides apply to
            // version, commit, and the nuget service-index URL — not just
            // the channel. The IdentityChannelReader registered above
            // continues to serve callers that only need the channel string.
            var resolver = sp.GetRequiredService<IIdentityResolver>();
            return BuildCliExecutionContext(
                startupContext.LoggingOptions.DebugMode,
                startupContext.LoggingOptions.ConsoleLogLevel,
                startupContext.LoggingOptions.LogsDirectory,
                startupContext.LoggingOptions.LogFilePath,
                resolver);
        });
        builder.Services.AddSingleton(s => new ConsoleEnvironment(
            BuildAnsiConsole(s, Console.Out),
            BuildAnsiConsole(s, Console.Error)));
        builder.Services.AddSingleton(s => s.GetRequiredService<ConsoleEnvironment>().Out);
        builder.Services.AddSingleton<ICliHostEnvironment>(provider =>
        {
            var configuration = provider.GetRequiredService<IConfiguration>();
            return new CliHostEnvironment(configuration, nonInteractive);
        });
        builder.Services.AddSingleton(TimeProvider.System);
        AddInteractionServices(builder);
        builder.Services.AddSingleton<IAppHostCandidateFinder, AppHostCandidateFinder>();
        builder.Services.AddSingleton<IProjectLocator, ProjectLocator>();
        builder.Services.AddSingleton<ISolutionLocator, SolutionLocator>();
        builder.Services.AddSingleton<ILanguageService, LanguageService>();
        builder.Services.AddSingleton<IScaffoldingService, ScaffoldingService>();
        builder.Services.AddSingleton<FallbackProjectParser>();
        builder.Services.AddSingleton<IProjectUpdater, ProjectUpdater>();
        builder.Services.AddSingleton<INewCommandPrompter, NewCommandPrompter>();
        builder.Services.AddSingleton<ITemplateVersionPrompter>(sp => (ITemplateVersionPrompter)sp.GetRequiredService<INewCommandPrompter>());
        builder.Services.AddSingleton<IAddCommandPrompter, AddCommandPrompter>();
        builder.Services.AddSingleton<IPublishCommandPrompter, PublishCommandPrompter>();
        builder.Services.AddSingleton<ICertificateService, CertificateService>();
        builder.Services.AddSingleton(BuildConfigurationService);
        builder.Services.AddSingleton<IFeatures, Features>();
        builder.Services.AddTelemetryServices();
        builder.Services.AddTransient<IProcessExecutionFactory, ProcessExecutionFactory>();
        // Windows-only crash-time safety net for interactive children spawned by
        // IsolatedProcess is provided by WindowsConsoleProcessJob.Shared — a process-wide
        // job created on first isolated spawn. The OS closes the job handle automatically on
        // process exit, firing KILL_ON_JOB_CLOSE on any assigned children that haven't already
        // exited (e.g. orphaned tsx after the CLI crashes). On non-Windows, process-group
        // reparenting + ordinary signal delivery cover the same case, so nothing is needed.
        builder.Services.AddTransient<LayoutProcessRunner>();
        builder.Services.AddTransient<ProcessTreeGracefulShutdownService>();
        // Forward the interface to the existing concrete service so consumers can depend on the
        // abstraction (used by AppHostServerSession + GuestLaunchOptions in the aspire run path).
        builder.Services.AddTransient<IProcessTreeGracefulShutdownSignaler>(sp => sp.GetRequiredService<ProcessTreeGracefulShutdownService>());
        // Forward the AppHost-stop abstraction to the same concrete service (used by OrphanedAppHostCollector).
        builder.Services.AddTransient<IAppHostStopper>(sp => sp.GetRequiredService<ProcessTreeGracefulShutdownService>());
        // On-demand collector for AppHost trees whose launching CLI has died (used by `aspire ps` and `aspire stop --all`).
        builder.Services.AddTransient<OrphanedAppHostCollector>();
 
        // Register certificate tool runner - uses native CertificateManager directly (no subprocess needed)
        builder.Services.AddSingleton(sp => CertificateManager.Create(sp.GetRequiredService<ILogger<NativeCertificateToolRunner>>(), sp.GetRequiredService<IEnvironment>()));
        builder.Services.AddSingleton<ICertificateToolRunner, NativeCertificateToolRunner>();
 
        builder.Services.AddTransient<IDotNetCliRunner, DotNetCliRunner>();
        builder.Services.AddSingleton<IDiskCache, DiskCache>();
        builder.Services.AddSingleton<IAppHostInfoDiskCache, AppHostInfoDiskCache>();
        builder.Services.AddSingleton<IAppHostInfoResolver, AppHostInfoResolver>();
        builder.Services.AddSingleton<IDotNetSdkInstaller, DotNetSdkInstaller>();
        builder.Services.AddTransient<IAppHostCliBackchannel, AppHostCliBackchannel>();
 
        // Register both NuGetPackageCache implementations - factory chooses based on embedded bundle
        builder.Services.AddSingleton<NuGetPackageCache>();
        builder.Services.AddSingleton<BundleNuGetPackageCache>();
        builder.Services.AddSingleton<INuGetPackageCache>(sp =>
        {
            if (sp.GetRequiredService<IBundleService>().IsBundle)
            {
                return sp.GetRequiredService<BundleNuGetPackageCache>();
            }
 
            // Fall back to SDK-based cache
            return sp.GetRequiredService<NuGetPackageCache>();
        });
 
        builder.Services.AddSingleton<NuGetPackagePrefetcher>();
        builder.Services.AddHostedService(sp => sp.GetRequiredService<NuGetPackagePrefetcher>());
        builder.Services.AddSingleton<AuxiliaryBackchannelMonitor>();
        builder.Services.AddSingleton<IAuxiliaryBackchannelMonitor>(sp => sp.GetRequiredService<AuxiliaryBackchannelMonitor>());
        builder.Services.AddHostedService(sp => sp.GetRequiredService<AuxiliaryBackchannelMonitor>());
        builder.Services.AddTransient<AppHostConnectionResolver>();
        builder.Services.AddSingleton<ICliUpdateNotifier, CliUpdateNotifier>();
        builder.Services.AddSingleton<IPackagingService, PackagingService>();
        builder.Services.AddSingleton<IBundlePayloadProvider, EmbeddedBundlePayloadProvider>();
        builder.Services.AddSingleton<IInstallSidecarReader, InstallSidecarReader>();
        builder.Services.AddSingleton<IPeerInstallProbe, PeerInstallProbe>();
        builder.Services.AddSingleton<IInstallationCandidateSource, PathInstallationCandidateSource>();
        builder.Services.AddSingleton<IInstallationCandidateSource, ReleasePrefixInstallationCandidateSource>();
        builder.Services.AddSingleton<IInstallationCandidateSource, DogfoodInstallationCandidateSource>();
        builder.Services.AddSingleton<IInstallationCandidateSource, DotnetToolStoreInstallationCandidateSource>();
        builder.Services.AddSingleton<IInstallationDiscovery, InstallationDiscovery>();
        builder.Services.AddSingleton<IBundleService, BundleService>();
        builder.Services.AddSingleton<ProfileCaptureState>();
        builder.Services.AddSingleton<ProfileCaptureService>();
        builder.Services.AddSingleton<IAppHostServerProjectFactory, AppHostServerProjectFactory>();
        builder.Services.AddSingleton<IAppHostServerSessionFactory, AppHostServerSessionFactory>();
        builder.Services.AddSingleton<ICliDownloader, CliDownloader>();
        builder.Services.AddSingleton<IFirstTimeUseNoticeSentinel>(_ => new FirstTimeUseNoticeSentinel(GetUsersAspirePath()));
        builder.Services.AddSingleton<IBannerService, BannerService>();
        builder.Services.AddSingleton<ResourceColorMap>();
        builder.Services.AddMemoryCache();
 
        // aspire.dev documentation services.
        builder.Services.AddSingleton<IDocsCache, DocsCache>();
        builder.Services.AddHttpClient<IDocsFetcher, DocsFetcher>();
        builder.Services.AddSingleton<IDocsIndexService, DocsIndexService>();
        builder.Services.AddSingleton<IDocsSearchService, DocsSearchService>();
        builder.Services.AddSingleton<IApiDocsCache, ApiDocsCache>();
        builder.Services.AddHttpClient<IApiDocsFetcher, ApiDocsFetcher>();
        builder.Services.AddSingleton<IApiDocsIndexService, ApiDocsIndexService>();
 
        // Bundle layout services (for polyglot apphost without .NET SDK).
        // Registered before NuGetPackageCache so the factory can choose implementation.
        builder.Services.AddSingleton<ILayoutDiscovery, LayoutDiscovery>();
        builder.Services.AddSingleton<BundleNuGetService>();
 
        // Git repository operations.
        builder.Services.AddSingleton<IGitRepository, GitRepository>();
 
        // OpenCode CLI operations.
        builder.Services.AddSingleton<IOpenCodeCliRunner, OpenCodeCliRunner>();
 
        // Claude Code CLI operations.
        builder.Services.AddSingleton<IClaudeCodeCliRunner, ClaudeCodeCliRunner>();
 
        // VS Code CLI operations.
        builder.Services.AddSingleton<IVsCodeCliRunner, VsCodeCliRunner>();
        builder.Services.AddSingleton<ICopilotCliRunner, CopilotCliRunner>();
 
        // Npm and Playwright CLI operations.
        builder.Services.AddSingleton<INpmRunner, NpmRunner>();
        builder.Services.AddHttpClient<INpmProvenanceChecker, SigstoreNpmProvenanceChecker>();
        builder.Services.AddHttpClient<IGitHubArtifactAttestationVerifier, GitHubArtifactAttestationVerifier>();
        builder.Services.AddSingleton<IAspireSkillsBundleProvider, AspireSkillsBundleProvider>();
        builder.Services.AddSingleton<IEmbeddedAspireSkillsBundleProvider, EmbeddedAspireSkillsBundleProvider>();
        builder.Services.AddSingleton<IAspireSkillsInstaller, AspireSkillsInstaller>();
        builder.Services.AddSingleton<IPlaywrightCliRunner, PlaywrightCliRunner>();
        builder.Services.AddSingleton<PlaywrightCliInstaller>();
 
        // Agent environment detection.
        builder.Services.AddSingleton<IAgentEnvironmentDetector, AgentEnvironmentDetector>();
        builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton<IAgentEnvironmentScanner, VsCodeAgentEnvironmentScanner>());
        builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton<IAgentEnvironmentScanner, CopilotCliAgentEnvironmentScanner>());
        builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton<IAgentEnvironmentScanner, OpenCodeAgentEnvironmentScanner>());
        builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton<IAgentEnvironmentScanner, ClaudeCodeAgentEnvironmentScanner>());
        builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton<IAgentEnvironmentScanner, DeprecatedMcpCommandScanner>());
 
        // Agent telemetry hook installation/configuration.
        builder.Services.AddSingleton<Aspire.Cli.Agents.Hooks.ITelemetryHookInstaller, Aspire.Cli.Agents.Hooks.TelemetryHookInstaller>();
        builder.Services.AddSingleton<Aspire.Cli.Agents.Hooks.ITelemetryHookConfigurator, Aspire.Cli.Agents.Hooks.TelemetryHookConfigurator>();
 
        // Template factories.
        builder.Services.AddSingleton<TemplateNuGetConfigService>();
        builder.Services.AddSingleton<ITemplateProvider, TemplateProvider>();
        builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton<ITemplateFactory, DotNetTemplateFactory>());
        builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton<ITemplateFactory, CliTemplateFactory>());
 
        // Language discovery for polyglot support.
        builder.Services.AddSingleton<ILanguageDiscovery, DefaultLanguageDiscovery>();
 
        // AppHost project handlers.
        builder.Services.AddSingleton<DotNetAppHostProject>();
        builder.Services.AddSingleton<Func<LanguageInfo, GuestAppHostProject>>(sp =>
        {
            return language => ActivatorUtilities.CreateInstance<GuestAppHostProject>(sp, language);
        });
        builder.Services.AddSingleton<IAppHostProjectFactory, AppHostProjectFactory>();
 
        // Environment checking services.
        builder.Services.AddSingleton<IEnvironmentCheck, AspireVersionCheck>();
        builder.Services.AddSingleton<IEnvironmentCheck, OperatingSystemCheck>();
        builder.Services.AddSingleton<IEnvironmentCheck, WslEnvironmentCheck>();
        builder.Services.AddSingleton<IEnvironmentCheck, DotNetSdkCheck>();
        builder.Services.AddSingleton<IEnvironmentCheck, TypeScriptAppHostToolingCheck>();
        builder.Services.AddSingleton<IEnvironmentCheck, DeprecatedWorkloadCheck>();
        builder.Services.AddSingleton<IEnvironmentCheck, DevCertsCheck>();
        builder.Services.AddSingleton<IEnvironmentCheck, ContainerRuntimeCheck>();
        builder.Services.AddSingleton<IDcpConnectionChecker, DcpConnectionChecker>();
        builder.Services.AddSingleton<IEnvironmentCheck, DcpConnectionHealthCheck>();
        builder.Services.AddSingleton<IEnvironmentCheck, DeprecatedAgentConfigCheck>();
        builder.Services.AddSingleton<IEnvironmentCheck, LegacySettingsFileCheck>();
        builder.Services.AddSingleton<IEnvironmentCheck, PendingMigrationsCheck>();
        builder.Services.AddSingleton<IEnvironmentCheck, VsCodeExtensionCheck>();
        builder.Services.AddSingleton<IEnvironmentChecker, EnvironmentChecker>();
 
        // MCP server transport factory - creates transport only when needed to avoid
        // capturing stdin/stdout before the MCP server command is actually executed.
        builder.Services.AddSingleton<IMcpTransportFactory, StdioMcpTransportFactory>();
 
        // Commands.
        builder.Services.AddSingleton<CommonCommandServices>();
        builder.Services.AddTransient<AppHostLauncher>();
        builder.Services.AddTransient<DcpWorkloadCleanupService>();
        builder.Services.AddTransient<NewCommand>();
        builder.Services.AddTransient<InitCommand>();
        builder.Services.AddTransient<RunCommand>();
        builder.Services.AddTransient<StopCommand>();
        builder.Services.AddTransient<StartCommand>();
        builder.Services.AddTransient<WaitCommand>();
        builder.Services.AddTransient<LsCommand>();
        builder.Services.AddTransient<ResourceCommand>();
        builder.Services.AddTransient<PsCommand>();
        builder.Services.AddTransient<DescribeCommand>();
        builder.Services.AddTransient<LogsCommand>();
        builder.Services.AddTransient<IntegrationPackageSearchService>();
        builder.Services.AddTransient<IntegrationCommand>();
        builder.Services.AddTransient<IntegrationListCommand>();
        builder.Services.AddTransient<IntegrationSearchCommand>();
        builder.Services.AddTransient<TerminalCommand>();
        builder.Services.AddTransient<TerminalAttachCommand>();
        builder.Services.AddTransient<TerminalPsCommand>();
        builder.Services.AddTransient<AddCommand>();
        builder.Services.AddTransient<PublishCommand>();
        builder.Services.AddTransient<ConfigCommand>();
        builder.Services.AddTransient<CacheCommand>();
        builder.Services.AddTransient<CertificatesCommand>();
        builder.Services.AddTransient<CertificatesCleanCommand>();
        builder.Services.AddTransient<CertificatesTrustCommand>();
        builder.Services.AddTransient<DoctorCommand>();
        builder.Services.AddTransient<DashboardCommand>();
        builder.Services.AddTransient<DashboardRunCommand>();
        builder.Services.AddTransient<UpdateCommand>();
        builder.Services.AddTransient<DeployCommand>();
        builder.Services.AddTransient<DestroyCommand>();
        builder.Services.AddTransient<DoCommand>();
        builder.Services.AddTransient<McpCommand>();
        builder.Services.AddTransient<McpStartCommand>();
        builder.Services.AddTransient<McpInitCommand>();
        builder.Services.AddTransient<McpToolsCommand>();
        builder.Services.AddTransient<McpCallCommand>();
        builder.Services.AddTransient<AgentCommand>();
        builder.Services.AddTransient<AgentMcpCommand>();
        builder.Services.AddTransient<AgentInitCommand>();
        builder.Services.AddTransient<AgentTelemetryCommand>();
        builder.Services.AddTransient<TelemetryCommand>();
        builder.Services.AddTransient<TelemetryLogsCommand>();
        builder.Services.AddTransient<TelemetrySpansCommand>();
        builder.Services.AddTransient<TelemetryTracesCommand>();
        builder.Services.AddTransient<ExportCommand>();
        builder.Services.AddTransient<ApiCommand>();
        builder.Services.AddTransient<ApiListCommand>();
        builder.Services.AddTransient<ApiSearchCommand>();
        builder.Services.AddTransient<ApiGetCommand>();
        builder.Services.AddTransient<DocsCommand>();
        builder.Services.AddTransient<DocsListCommand>();
        builder.Services.AddTransient<DocsSearchCommand>();
        builder.Services.AddTransient<DocsGetCommand>();
        builder.Services.AddTransient<SecretCommand>();
        builder.Services.AddTransient<SecretSetCommand>();
        builder.Services.AddTransient<SecretGetCommand>();
        builder.Services.AddTransient<SecretListCommand>();
        builder.Services.AddTransient<SecretPathCommand>();
        builder.Services.AddTransient<SecretDeleteCommand>();
        builder.Services.AddTransient<SecretStoreResolver>();
        builder.Services.AddTransient<SdkCommand>();
        builder.Services.AddTransient<SdkGenerateCommand>();
        builder.Services.AddTransient<SdkDumpCommand>();
        builder.Services.AddTransient<SdkExportCommand>();
        builder.Services.AddTransient<RestoreCommand>();
        builder.Services.AddSingleton<IMigration, TypeScriptAppHostMigration>();
        builder.Services.AddTransient<SetupCommand>();
#if DEBUG
        builder.Services.AddTransient<RenderCommand>();
#endif
        builder.Services.AddTransient<RootCommand>();
        builder.Services.AddTransient<ExtensionInternalCommand>();
 
        var app = builder.Build();
        return app;
    }
 
    private static DirectoryInfo GetHivesDirectory(string? processPath = null)
    {
        var homeDirectory = GetUsersAspirePath(processPath);
        var hivesDirectory = Path.Combine(homeDirectory, "hives");
        return new DirectoryInfo(hivesDirectory);
    }
 
    private static DirectoryInfo GetSdksDirectory(string? processPath = null)
    {
        var homeDirectory = GetUsersAspirePath(processPath);
        var sdksPath = Path.Combine(homeDirectory, "sdks");
        return new DirectoryInfo(sdksPath);
    }
 
    internal static CliExecutionContext BuildCliExecutionContext(bool debugMode, LogLevel? consoleLogLevel, string logsDirectory, string logFilePath, IIdentityResolver identityResolver, string? processPath = null)
    {
        ArgumentNullException.ThrowIfNull(identityResolver);
 
        var workingDirectory = new DirectoryInfo(Environment.CurrentDirectory);
        var hivesDirectory = GetHivesDirectory(processPath);
        var cacheDirectory = GetCacheDirectory(processPath);
        var sdksDirectory = GetSdksDirectory(processPath);
        var packagesDirectory = GetPackagesDirectory(processPath);
        var aspireHomeDirectory = new DirectoryInfo(GetUsersAspirePath(processPath));
 
        var channel = identityResolver.ResolveChannel();
        var version = identityResolver.ResolveVersion();
        var commit = identityResolver.ResolveCommit();
        var nugetServiceIndexOverride = identityResolver.ResolveNuGetServiceIndexOverride();
        var packagesOverride = identityResolver.ResolvePackagesDirectory();
 
        static bool IsOverride(IdentitySource source) => source is IdentitySource.Environment or IdentitySource.Sidecar;
        var identityOverridden = IsOverride(channel.Source) || IsOverride(version.Source) || IsOverride(commit.Source) || IsOverride(nugetServiceIndexOverride.Source) || IsOverride(packagesOverride.Source);
 
        // Installer-authored channel/version/commit fields describe the installed CLI and should not
        // be presented as diagnostic emulation. Environment variables always require a notice, as do
        // the sidecar-only package and service-index knobs used to redirect package resolution.
        static bool IsEnvironmentOverride(IdentitySource source) => source is IdentitySource.Environment;
        static bool IsDeveloperSidecarOverride(IdentitySource source) => source is IdentitySource.Sidecar;
        var identityOverrideNoticeRequired =
            IsEnvironmentOverride(channel.Source) ||
            IsEnvironmentOverride(version.Source) ||
            IsEnvironmentOverride(commit.Source) ||
            IsEnvironmentOverride(nugetServiceIndexOverride.Source) ||
            IsEnvironmentOverride(packagesOverride.Source) ||
            IsDeveloperSidecarOverride(nugetServiceIndexOverride.Source) ||
            IsDeveloperSidecarOverride(packagesOverride.Source);
 
        // A null/whitespace value means "no override"; only materialize a DirectoryInfo when a real
        // path was supplied. PackagingService validates existence + uniqueness when it consumes this.
        var identityPackagesDirectory = string.IsNullOrWhiteSpace(packagesOverride.Value)
            ? null
            : new DirectoryInfo(packagesOverride.Value);
 
        return new CliExecutionContext(
            workingDirectory,
            hivesDirectory,
            cacheDirectory,
            sdksDirectory,
            new DirectoryInfo(logsDirectory),
            logFilePath,
            identityChannel: channel.Value,
            identityVersion: version.Value,
            identityCommit: commit.Value,
            nugetServiceIndexOverride: nugetServiceIndexOverride.Value,
            identityOverridden: identityOverridden,
            identityPackagesDirectory: identityPackagesDirectory,
            identityOverrideNoticeRequired: identityOverrideNoticeRequired,
            debugMode: debugMode,
            consoleLogLevel: consoleLogLevel,
            packagesDirectory: packagesDirectory,
            aspireHomeDirectory: aspireHomeDirectory);
    }
 
    private static DirectoryInfo GetCacheDirectory(string? processPath = null)
    {
        var homeDirectory = GetUsersAspirePath(processPath);
        var cacheDirectoryPath = Path.Combine(homeDirectory, "cache");
        return new DirectoryInfo(cacheDirectoryPath);
    }
 
    private static DirectoryInfo GetPackagesDirectory(string? processPath = null)
    {
        var homeDirectory = GetUsersAspirePath(processPath);
        var packagesDirectoryPath = Path.Combine(homeDirectory, "packages");
        return new DirectoryInfo(packagesDirectoryPath);
    }
 
    private static void TrySetLocaleOverride(string? localeOverride, ILogger logger, IStartupErrorWriter errorWriter)
    {
        if (localeOverride is not null)
        {
            var result = LocaleHelpers.TrySetLocaleOverride(localeOverride);
 
            string errorMessage;
            switch (result)
            {
                case SetLocaleResult.Success:
                    return;
                case SetLocaleResult.InvalidLocale:
                    errorMessage = string.Format(CultureInfo.CurrentCulture, ErrorStrings.UnsupportedLocaleProvided, localeOverride, string.Join(", ", LocaleHelpers.SupportedLocales));
                    break;
                case SetLocaleResult.UnsupportedLocale:
                    errorMessage = string.Format(CultureInfo.CurrentCulture, ErrorStrings.InvalidLocaleProvided, localeOverride);
                    break;
                default:
                    throw new InvalidOperationException($"Unexpected result: {result}");
            }
 
            logger.LogError("Locale override failed: {ErrorMessage}", errorMessage);
            errorWriter.WriteLine(errorMessage);
        }
    }
 
    private static IConfigurationService BuildConfigurationService(IServiceProvider serviceProvider)
    {
        var configuration = serviceProvider.GetRequiredService<IConfiguration>();
        var executionContext = serviceProvider.GetRequiredService<CliExecutionContext>();
        var logger = serviceProvider.GetRequiredService<ILogger<ConfigurationService>>();
        var globalSettingsFile = new FileInfo(GetGlobalSettingsPath(logger));
        return new ConfigurationService(configuration, executionContext, globalSettingsFile, logger);
    }
 
    internal static async Task DisplayFirstTimeUseNoticeIfNeededAsync(IServiceProvider serviceProvider, string[] args, CancellationToken cancellationToken = default)
    {
        var configuration = serviceProvider.GetRequiredService<IConfiguration>();
        var isInformationalCommand = ContainsRootOption(args, CommonOptionNames.InformationalOptionNames.Contains);
        var isMachineReadableOutput = HasMachineReadableOutput(args);
        var noLogo = ContainsRootOption(args, a => a == CommonOptionNames.NoLogo)
            || configuration.GetBool(CliConfigNames.NoLogo, defaultValue: false)
            || isInformationalCommand
            || isMachineReadableOutput;
        var showBanner = ContainsRootOption(args, a => a == CommonOptionNames.Banner);
 
        var sentinel = serviceProvider.GetRequiredService<IFirstTimeUseNoticeSentinel>();
        var isFirstRun = !sentinel.Exists();
 
        var hostEnvironment = serviceProvider.GetRequiredService<ICliHostEnvironment>();
 
        // Show banner if explicitly requested OR on first run (unless suppressed by noLogo).
        // Always require interactive output support — the animated banner uses Spectre.Console's Live display
        // which manipulates the cursor and fails when console handles are invalid (e.g., stdout is redirected).
        if ((showBanner || (isFirstRun && !noLogo)) && hostEnvironment.SupportsInteractiveOutput)
        {
            var bannerService = serviceProvider.GetRequiredService<IBannerService>();
            await bannerService.DisplayBannerAsync(cancellationToken);
        }
 
        // Only show telemetry notice on first run (not when banner is explicitly requested)
        if (isFirstRun)
        {
            if (!noLogo)
            {
                // Write to stderr to avoid interfering with tools that parse stdout
                var consoleEnvironment = serviceProvider.GetRequiredService<ConsoleEnvironment>();
                var interactionService = serviceProvider.GetRequiredService<IInteractionService>();
 
                const string telemetryUrl = "https://aka.ms/aspire/cli-telemetry";
 
                consoleEnvironment.Error.WriteLine();
                consoleEnvironment.Error.MarkupLine(string.Format(CultureInfo.CurrentCulture, RootCommandStrings.FirstTimeUseTelemetryNotice, MarkupHelpers.SafeLink(interactionService, telemetryUrl)));
                consoleEnvironment.Error.WriteLine();
            }
 
            // Don't persist the sentinel for informational commands (--version, --help, etc.)
            // or for machine-readable invocations (--format json, including the hidden
            // `doctor --self --format json` peer probe). Otherwise an automation invocation
            // or peer probe — which deliberately suppressed the user-facing notice — would
            // silently consume the first-run slot, and the next interactive invocation by
            // the same user would never see the telemetry notice.
            if (!isInformationalCommand && !isMachineReadableOutput)
            {
                sentinel.CreateIfNotExists();
            }
        }
 
        // Surface a notice whenever the CLI is emulating another build via ASPIRE_CLI_* env vars
        // or developer-only sidecar overrides, so a diagnostic run is never mistaken for a real install.
        // This is independent of first-run/banner state but is suppressed for machine-readable
        // output so structured payloads stay clean. Written to stderr for the same reason.
        var executionContext = serviceProvider.GetRequiredService<CliExecutionContext>();
        if (executionContext.IdentityOverrideNoticeRequired && !isMachineReadableOutput)
        {
            var consoleEnvironment = serviceProvider.GetRequiredService<ConsoleEnvironment>();
            var interactionService = serviceProvider.GetRequiredService<IInteractionService>();
            var notice = string.Format(
                CultureInfo.CurrentCulture,
                RootCommandStrings.IdentityOverrideNotice,
                executionContext.IdentityChannel,
                executionContext.IdentityVersion);
 
            // Route through the interaction service with a warning icon, but force the notice
            // to stderr (ConsoleOutput.Error) so it never contaminates stdout for callers that
            // parse it. The surrounding blank lines stay on the same stderr stream for spacing.
            consoleEnvironment.Error.WriteLine();
            interactionService.DisplayMessage(KnownEmojis.Warning, $"[yellow]{notice.EscapeMarkup()}[/]", allowMarkup: true, consoleOverride: ConsoleOutput.Error);
            consoleEnvironment.Error.WriteLine();
        }
    }
 
    private static bool ContainsRootOption(string[] args, Func<string, bool> predicate)
    {
        foreach (var arg in args)
        {
            if (arg == "--")
            {
                return false;
            }
 
            if (predicate(arg))
            {
                return true;
            }
        }
 
        return false;
    }
 
    // Machine-readable output flags should never have welcome/telemetry text
    // interleaved with the structured payload. Stop at the command argument
    // delimiter so application/resource arguments named like CLI flags don't
    // accidentally suppress the first-run experience.
    private static bool HasMachineReadableOutput(string[] args)
    {
        if (IsLegacyExtensionGetAppHostsCommand(args))
        {
            return true;
        }
 
        for (var i = 0; i < args.Length; i++)
        {
            var arg = args[i];
            if (arg == "--")
            {
                return false;
            }
 
            if (arg.Equals("--format=json", StringComparison.OrdinalIgnoreCase))
            {
                return true;
            }
 
            // `--load-arguments` is treated as machine-readable specifically because of the
            // `aspire resource <name> <command> --load-arguments` contract: that flag emits the
            // dynamic argument-metadata JSON the VS Code extension parses. If a future command
            // reuses the flag name with different semantics, that command should opt out here
            // explicitly so its output is not silently treated as JSON.
            if (arg.Equals("--json", StringComparison.OrdinalIgnoreCase) ||
                arg.Equals("--load-arguments", StringComparison.OrdinalIgnoreCase))
            {
                return true;
            }
 
            if (arg.Equals("--format", StringComparison.OrdinalIgnoreCase)
                && i + 1 < args.Length
                && args[i + 1].Equals("json", StringComparison.OrdinalIgnoreCase))
            {
                return true;
            }
        }
 
        return false;
    }
 
    private static bool IsLegacyExtensionGetAppHostsCommand(string[] args)
    {
        return args.Length >= 2
            && args[0].Equals("extension", StringComparison.OrdinalIgnoreCase)
            && args[1].Equals("get-apphosts", StringComparison.OrdinalIgnoreCase);
    }
 
    private static IAnsiConsole BuildAnsiConsole(IServiceProvider serviceProvider, TextWriter writer)
    {
        var configuration = serviceProvider.GetRequiredService<IConfiguration>();
        var hostEnvironment = serviceProvider.GetRequiredService<ICliHostEnvironment>();
        var isPlayground = CliHostEnvironment.IsPlaygroundMode(configuration);
        var supportsAnsi = hostEnvironment.SupportsAnsi;
        var supportsInteractiveOutput = hostEnvironment.SupportsInteractiveOutput;
        var usePlaygroundFormatting = isPlayground && supportsAnsi && supportsInteractiveOutput;
 
        // Create custom output that handles width detection better in CI environments
        // and encapsulates ASPIRE_CONSOLE_WIDTH environment variable handling
        var output = new AspireAnsiConsoleOutput(writer, configuration);
 
        var settings = new AnsiConsoleSettings()
        {
            Ansi = supportsAnsi ? AnsiSupport.Yes : AnsiSupport.No,
            Interactive = supportsInteractiveOutput ? InteractionSupport.Detect : InteractionSupport.No,
            ColorSystem = supportsAnsi ? ColorSystemSupport.EightBit : ColorSystemSupport.NoColors,
            Out = output,
        };
 
        if (usePlaygroundFormatting)
        {
            settings.Interactive = InteractionSupport.Yes;
 
            // Enrichers interfere with interactive playground experience so
            // this suppresses the default enrichers so that the CLI experience
            // is more like what we would get in an interactive experience.
            settings.Enrichment.UseDefaultEnrichers = false;
            settings.Enrichment.Enrichers = new()
            {
                new AspirePlaygroundEnricher()
            };
        }
 
        return AnsiConsole.Create(settings);
    }
 
    public static async Task<int> Main(string[] args)
    {
        // Re-enable CTRL+C delivery for ourselves and any process we subsequently spawn.
        // Per https://learn.microsoft.com/windows/console/setconsolectrlhandler, the "ignore
        // CTRL+C" state is process-level and inherited across CreateProcess. If our parent was
        // started with CREATE_NEW_PROCESS_GROUP (or otherwise called SetConsoleCtrlHandler(NULL,
        // TRUE)), we inherit "CTRL+C disabled" and the kernel will silently drop CTRL_C_EVENT
        // for both us and our descendants — including the AppHost and DCP-launched services.
        // Calling SetConsoleCtrlHandler(NULL, FALSE) once at startup clears that inherited
        // state so the CLI's signal ladder (CCM → AppHost SIGINT → DCP stop-process-tree)
        // can actually deliver. The runtime/Spectre still own the actual CTRL+C handler chain;
        // we only flip the inherited "ignored" attribute.
        if (OperatingSystem.IsWindows())
        {
            WindowsProcessInterop.SetConsoleCtrlHandler(nint.Zero, false);
        }
 
        // A detached CLI can be launched from a bundle version that the parent resolved before
        // forking. Acquire the lease immediately from the handoff environment so setup/upgrade
        // cleanup cannot remove the running CLI's layout.
        BundleVersionLease? acquiredBundleLease;
        try
        {
            acquiredBundleLease = AcquireBundleLeaseFromEnvironment(args);
        }
        catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or DirectoryNotFoundException or ArgumentException or NotSupportedException)
        {
            Console.Error.WriteLine($"Failed to acquire Aspire bundle lease: {ex.Message}");
            return CliExitCodes.FailedToStartCli;
        }
 
        using var bundleLease = acquiredBundleLease;
 
        // Setup handling of CTRL-C and SIGTERM as early as possible so that if
        // we get a signal anywhere that is not handled by Spectre Console
        // already that we know to trigger cancellation. The cancellation manager
        // owns both the OS-signal registration and the graceful-shutdown budget,
        // clock, and token. It is registered as a DI singleton below via
        // AddSingleton(instance) so the container does not take disposal ownership.
        using var cancellationManager = new ConsoleCancellationManager(finalDrainBudget: TimeSpan.FromSeconds(5));
 
        Console.OutputEncoding = Encoding.UTF8;
 
        // Parse this before building the host because TelemetryManager reads OTEL configuration
        // during DI startup. Waiting for System.CommandLine binding would be too late: the CLI
        // profiling ActivitySource would already have been configured without the private exporter.
        var profileCaptureOptions = ProfileCaptureOptions.TryCreate(args, TimeProvider.System, new DirectoryInfo(Environment.CurrentDirectory));
        using var profileCaptureEnvironment = profileCaptureOptions is not null
            ? ProfileCaptureEnvironment.Apply(profileCaptureOptions)
            : null;
 
        var loggingOptions = ParseLoggingOptions(args);
        var errorWriter = new StartupErrorWriter(loggingOptions.LogFilePath);
        var logBufferContext = new ConsoleLogBufferContext();
        var (loggerFactory, fileLoggerProvider) = CreateLoggerFactory(args, loggingOptions, errorWriter, logBufferContext);
        var logger = loggerFactory.CreateLogger(RootLoggerName);
        cancellationManager.SetLogger(logger);
        var identityChannelReader = new IdentityChannelReader(typeof(Program).Assembly);
        using var startupContext = new CliStartupContext(loggingOptions, errorWriter, loggerFactory, fileLoggerProvider, logBufferContext, logger, cancellationManager, identityChannelReader);
 
        logger.LogInformation("Aspire CLI version: {Version}", AspireCliTelemetry.GetCliVersion());
        logger.LogInformation("Aspire CLI build ID: {BuildId}", AspireCliTelemetry.GetCliBuildId());
        logger.LogInformation("Aspire CLI path: {CliPath}", Environment.ProcessPath);
        if (identityChannelReader.TryReadChannel(out var channel, out var channelError))
        {
            logger.LogInformation("Aspire CLI channel: {Channel}", channel);
        }
        else
        {
            logger.LogWarning("Failed to resolve CLI channel: {Error}", channelError);
        }
        logger.LogInformation("Working directory: {WorkingDirectory}", Environment.CurrentDirectory);
        // Logging the log file path is useful so that when console logging is enabled (for example with --log-level debug),
        // the path is written to the console logger (stderr) for easier discovery.
        logger.LogInformation("Log file: {LogFilePath}", loggingOptions.LogFilePath);
        logger.LogInformation("CLI process ID: {ProcessId}", Environment.ProcessId);
 
        IHost? app = null;
        try
        {
            app = await BuildApplicationAsync(args, startupContext);
            await app.StartAsync().ConfigureAwait(false);
        }
        catch (Exception ex)
        {
            app?.Dispose();
 
            logger.LogError(ex, "Failed to load configuration or start CLI.");
            errorWriter.WriteLine(ex.Message);
            return CliExitCodes.FailedToStartCli;
        }
 
        // Ensure dispose of app when Main exits.
        using var _ = app;
 
        // Immediately get telemetry and telemetry manager so they are created by DI and telemetry is configured.
        var telemetry = app.Services.GetRequiredService<AspireCliTelemetry>();
        var telemetryManager = app.Services.GetRequiredService<TelemetryManager>();
        var profilingTelemetry = app.Services.GetRequiredService<ProfilingTelemetry>();
        var profileCaptureState = app.Services.GetRequiredService<ProfileCaptureState>();
 
        // Log feature state at startup for diagnostics
        app.Services.GetRequiredService<IFeatures>().LogFeatureState();
 
        // The agent telemetry command is invoked fire-and-forget by the agent telemetry hook
        // scripts on every PostToolUse event. It emits its own dedicated reported span, so the
        // generic aspire/cli/main span is suppressed below to avoid double-counting CLI usage, and
        // the first-run telemetry notice is skipped so a background hook cannot silently consume the
        // notice the user is meant to see on their first interactive command.
        var isAgentTelemetryInvocation = AgentTelemetryInvocation.Matches(args);
 
        // Display first run experience if this is the first time the CLI is run on this machine
        if (!isAgentTelemetryInvocation)
        {
            await DisplayFirstTimeUseNoticeIfNeededAsync(app.Services, args, cancellationManager.Token);
        }
 
        var rootCommand = app.Services.GetRequiredService<RootCommand>();
        var invokeConfig = new InvocationConfiguration()
        {
            // Disable default exception handler so we can log exceptions to telemetry.
            EnableDefaultExceptionHandler = false,
            // Set timeout to null so that System.Commandline doesn't manage cancellation tokens or timeouts for us.
            ProcessTerminationTimeout = null
        };
 
        app.Services.GetRequiredService<CliExecutionContext>();
 
        // Suppress the generic main span for the agent telemetry command path: that command emits
        // its own aspire/cli/agent_telemetry span, and creating the main span too would record a
        // second span (inflating ordinary CLI-usage metrics) for every hook event.
        using var mainActivity = isAgentTelemetryInvocation
            ? null
            : telemetry.StartReportedActivity(TelemetryConstants.Activities.Main, ActivityKind.Internal);
        ProfileCaptureService.ProfileCaptureSession? profileCaptureSession = null;
 
        if (mainActivity != null)
        {
            var currentProcess = Process.GetCurrentProcess();
            mainActivity.SetStartTime(currentProcess.StartTime);
            mainActivity.AddTag(TelemetryConstants.Tags.ProcessPid, currentProcess.Id);
            mainActivity.AddTag(TelemetryConstants.Tags.ProcessExecutableName, "aspire");
        }
 
        try
        {
            var exitCode = CliExitCodes.Success;
            try
            {
                if (profileCaptureOptions is not null)
                {
                    profileCaptureSession = await app.Services.GetRequiredService<ProfileCaptureService>().StartAsync(profileCaptureOptions, cancellationManager.Token).ConfigureAwait(false);
                }
 
                // Parse before logging. `aspire run --ApiKey sk-live-...` forwards unmatched tokens
                // to the AppHost even though the user never typed a `--` separator, so the parse
                // tree is the only reliable way to tell CLI-owned tokens from AppHost input.
                // Reordering is safe because Parse collects errors into the result instead of
                // throwing, and nothing between here and the original call site inspects args.
                var parseResult = rootCommand.Parse(args);
 
                // Log command invocation details for debugging. Anything forwarded to the AppHost
                // can contain secrets, so it is redacted.
                var loggableArgs = ParseResultHelper.GetLoggableArguments(parseResult);
                var commandLine = loggableArgs.Length > 0 ? $"aspire {loggableArgs}" : "aspire";
                logger.LogInformation("Command: {CommandLine}", commandLine);
 
                logger.LogDebug("Parsing arguments: {Args}", loggableArgs);
 
#if DEBUG
                WaitForDebuggerIfRequested(parseResult, app.Services, WaitForDebugger);
#endif
 
                var commandName = GetCommandName(parseResult);
                logger.LogDebug("Executing command: {CommandName}", commandName);
 
                mainActivity?.SetTag(TelemetryConstants.Tags.CommandName, commandName);
 
                ProfilingTelemetry.ActivityScope profileCommandActivity = default;
                try
                {
                    if (profileCaptureOptions is not null)
                    {
                        profileCommandActivity = profilingTelemetry.StartCommand(commandName);
                    }
 
                    // Parse commandline and invoke the handler.
                    exitCode = await parseResult.InvokeAsync(invokeConfig, cancellationManager.Token).ConfigureAwait(false);
 
                    // Set telemetry tags based on how the command completed.
                    profileCommandActivity.SetProcessExitCode(exitCode);
                    if (exitCode != CliExitCodes.Success)
                    {
                        profileCommandActivity.SetError($"Command exited with code {exitCode}.");
                    }
                }
                finally
                {
                    profileCommandActivity.Dispose();
                }
 
                // Log exit code for debugging
                logger.LogInformation("Exit code: {ExitCode}", exitCode);
            }
            catch (OperationCanceledException)
            {
                // The command observed cancellation and propagated OCE rather than returning a
                // normal exit code. Internal failures `return X` directly from the command, so
                // anything reaching here is user-initiated cancellation (Ctrl+C / SIGTERM).
                exitCode = CliExitCodes.Cancelled;
                logger.LogInformation("Command cancelled. Exit code: {ExitCode}", exitCode);
            }
            catch (Exception ex)
            {
                // Should never get here because RootCommand's handler should catch all exceptions, but log just in case.
                exitCode = CliExitCodes.InvalidCommand;
 
                logger.LogError(ex, "An unexpected error occurred.");
                telemetry.RecordError("An unexpected error occurred.", ex);
                errorWriter.WriteLine(string.Format(CultureInfo.CurrentCulture, InteractionServiceStrings.UnexpectedErrorOccurred, ex.Message));
            }
            finally
            {
                mainActivity?.SetTag(TelemetryConstants.Tags.ProcessExitCode, exitCode);
                mainActivity?.Stop();
            }
 
            // The agent telemetry command runs fire-and-forget from an agent hook and the process
            // exits immediately after. The short Release shutdown flush window is not enough to
            // reliably export the single just-created span, so force a bounded reported-provider
            // flush here before returning. This is a no-op when telemetry is opted out (no provider).
            if (isAgentTelemetryInvocation)
            {
                try
                {
                    await telemetryManager.ForceFlushReportedAsync().ConfigureAwait(false);
                }
                catch
                {
                    // A telemetry flush failure must never change the hook's exit code.
                }
            }
 
            // This state is only consulted when the parent started a capture session. A successful
            // extension handoff transfers export to the child, while the parent still disposes its session.
            if (profileCaptureSession is not null && !profileCaptureState.IsTransferred)
            {
                try
                {
                    await telemetryManager.ForceFlushProfilingAsync().ConfigureAwait(false);
                    var exportExitCode = await profileCaptureSession.ExportAsync(cancellationManager.Token).ConfigureAwait(false);
                    if (exitCode == CliExitCodes.Success && exportExitCode != CliExitCodes.Success)
                    {
                        exitCode = exportExitCode;
                    }
                }
                catch (Exception ex)
                {
                    logger.LogError(ex, "Failed to export profile capture.");
                    errorWriter.WriteLine(string.Format(CultureInfo.CurrentCulture, ExportCommandStrings.FailedToExport, ex.Message));
                    if (exitCode == CliExitCodes.Success)
                    {
                        exitCode = CliExitCodes.DashboardFailure;
                    }
                }
            }
 
            return exitCode;
        }
        finally
        {
            if (profileCaptureSession is not null)
            {
                await profileCaptureSession.DisposeAsync().ConfigureAwait(false);
            }
 
            // Shutting down telemetry manager to flush any remaining telemetry and will take time.
            // Start shutdown of telemetry manager immediately and run concurrently with app shutdown.
            var shutdownTelemetryTask = telemetryManager.ShutdownAsync();
 
            await app.StopAsync().ConfigureAwait(false);
            await shutdownTelemetryTask;
        }
    }
 
    internal static BundleVersionLease? AcquireBundleLeaseFromEnvironment(string[] args)
        => BundleVersionLease.TryAcquireFromEnvironment("aspire-cli", args.FirstOrDefault());
 
    private static string GetCommandName(ParseResult r)
    {
        // Walk the parent command tree to find the top-level command name and get the full command name for this parseresult.
        var parentNames = new List<string> { r.CommandResult.Command.Name };
        var current = r.CommandResult.Parent;
        while (current is System.CommandLine.Parsing.CommandResult parentCommandResult)
        {
            parentNames.Add(parentCommandResult.Command.Name);
            current = parentCommandResult.Parent;
        }
        parentNames.Reverse();
        return string.Join(' ', parentNames);
    }
 
#if DEBUG
    /// <summary>
    /// Waits for a debugger to attach if --cli-wait-for-debugger was passed.
    /// </summary>
    /// <remarks>
    /// This is handled here rather than as an option or command validator because:
    /// (1) adding a validator to the static CliWaitForDebuggerOption causes a thread-safety
    ///     race (concurrent List&lt;T&gt;.Add from parallel test classes that each construct a
    ///     Transient RootCommand), and
    /// (2) command-level validators only run for the innermost command — not for RootCommand
    ///     when a subcommand is invoked (e.g. "aspire run --cli-wait-for-debugger").
    /// </remarks>
    internal static void WaitForDebuggerIfRequested(ParseResult parseResult, IServiceProvider services, Action waitAction)
    {
        if (!parseResult.GetValue(RootCommand.CliWaitForDebuggerOption))
        {
            return;
        }
 
        var interactionService = services.GetRequiredService<IInteractionService>();
        interactionService.ShowStatus(
            string.Format(CultureInfo.CurrentCulture, RootCommandStrings.WaitingForDebugger, Environment.ProcessId),
            waitAction, emoji: KnownEmojis.Bug);
    }
 
    private static void WaitForDebugger()
    {
        while (!Debugger.IsAttached)
        {
            Thread.Sleep(1000);
        }
 
        Debugger.Break();
    }
#endif
 
    private static void AddInteractionServices(HostApplicationBuilder builder)
    {
        var extensionEndpoint = builder.Configuration[KnownConfigNames.ExtensionEndpoint];
 
        if (extensionEndpoint is not null)
        {
            builder.Services.AddSingleton<IExtensionRpcTarget, ExtensionRpcTarget>();
            builder.Services.AddSingleton<IExtensionBackchannel, ExtensionBackchannel>();
 
            var extensionPromptEnabled = builder.Configuration[KnownConfigNames.ExtensionPromptEnabled] is "true";
            builder.Services.AddSingleton<IInteractionService>(provider =>
            {
                var consoleEnvironment = provider.GetRequiredService<ConsoleEnvironment>();
                consoleEnvironment.Out.Profile.Width = 256; // VS code terminal will handle wrapping so set a large width here.
                var executionContext = provider.GetRequiredService<CliExecutionContext>();
                var hostEnvironment = provider.GetRequiredService<ICliHostEnvironment>();
                var processPathProvider = provider.GetRequiredService<IProcessPathProvider>();
                var loggerFactory = provider.GetRequiredService<ILoggerFactory>();
                var logBufferCtx = provider.GetRequiredService<ConsoleLogBufferContext>();
                var consoleInteractionService = new ConsoleInteractionService(consoleEnvironment, executionContext, hostEnvironment, processPathProvider, loggerFactory, logBufferCtx);
                return new ExtensionInteractionService(consoleInteractionService,
                    provider.GetRequiredService<IExtensionBackchannel>(),
                    extensionPromptEnabled,
                    logger: provider.GetRequiredService<ILogger<ExtensionInteractionService>>());
            });
        }
        else
        {
            builder.Services.AddSingleton<IInteractionService>(provider =>
            {
                var consoleEnvironment = provider.GetRequiredService<ConsoleEnvironment>();
                var executionContext = provider.GetRequiredService<CliExecutionContext>();
                var hostEnvironment = provider.GetRequiredService<ICliHostEnvironment>();
                var processPathProvider = provider.GetRequiredService<IProcessPathProvider>();
                var loggerFactory = provider.GetRequiredService<ILoggerFactory>();
                var logBufferCtx = provider.GetRequiredService<ConsoleLogBufferContext>();
                return new ConsoleInteractionService(consoleEnvironment, executionContext, hostEnvironment, processPathProvider, loggerFactory, logBufferCtx);
            });
        }
    }
}
 
internal class AspirePlaygroundEnricher : IProfileEnricher
{
    public string Name => "Aspire Playground";
 
    public bool Enabled(IDictionary<string, string> environmentVariables)
    {
        if (!environmentVariables.TryGetValue("ASPIRE_PLAYGROUND", out var value))
        {
            return false;
        }
 
        if (!bool.TryParse(value, out var isEnabled))
        {
            return false;
        }
 
        return isEnabled;
    }
 
    public void Enrich(Profile profile)
    {
        profile.Capabilities.Interactive = true;
    }
}