// 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<T>.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;
}
}