// 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.Json;
using Aspire.Cli.Agents;
using Aspire.Cli.Agents.Playwright;
using Aspire.Cli.Configuration;
using Aspire.Cli.Git;
using Aspire.Cli.Interaction;
using Aspire.Cli.NuGet;
using Aspire.Cli.Projects;
using Aspire.Cli.Resources;
using Aspire.Cli.Telemetry;
using Aspire.Cli.Utils;
using Spectre.Console;
namespace Aspire.Cli.Commands;
///
/// Command that initializes agent environment configuration for detected agents.
/// This is the new command under 'aspire agent init'.
///
internal sealed class AgentInitCommand : BaseCommand, IPackageMetaPrefetchingCommand
{
private readonly IInteractionService _interactionService;
private readonly IAgentEnvironmentDetector _agentEnvironmentDetector;
private readonly PlaywrightCliInstaller _playwrightCliInstaller;
private readonly IGitRepository _gitRepository;
private readonly ILanguageDiscovery _languageDiscovery;
///
/// AgentInitCommand does not need template package metadata prefetching.
///
public bool PrefetchesTemplatePackageMetadata => false;
///
/// AgentInitCommand does not need CLI package metadata prefetching.
///
public bool PrefetchesCliPackageMetadata => false;
public AgentInitCommand(
IInteractionService interactionService,
IFeatures features,
ICliUpdateNotifier updateNotifier,
CliExecutionContext executionContext,
IAgentEnvironmentDetector agentEnvironmentDetector,
PlaywrightCliInstaller playwrightCliInstaller,
IGitRepository gitRepository,
ILanguageDiscovery languageDiscovery,
AspireCliTelemetry telemetry)
: base("init", AgentCommandStrings.InitCommand_Description, features, updateNotifier, executionContext, interactionService, telemetry)
{
_interactionService = interactionService;
_agentEnvironmentDetector = agentEnvironmentDetector;
_playwrightCliInstaller = playwrightCliInstaller;
_gitRepository = gitRepository;
_languageDiscovery = languageDiscovery;
Options.Add(s_workspaceRootOption);
Options.Add(s_skillLocationsOption);
Options.Add(s_skillsOption);
}
private static readonly Option s_workspaceRootOption = new("--workspace-root")
{
Description = AgentCommandStrings.InitCommand_WorkspaceRootOptionDescription
};
private static readonly Option s_skillLocationsOption = new("--skill-locations")
{
Description = string.Format(CultureInfo.InvariantCulture, AgentCommandStrings.InitCommand_SkillLocationsOptionDescription,
string.Join(",", SkillLocation.All.Select(l => l.Id)),
ConsoleInteractionService.AllChoice,
ConsoleInteractionService.NoneChoice)
};
private static readonly Option s_skillsOption = new("--skills")
{
Description = string.Format(CultureInfo.InvariantCulture, AgentCommandStrings.InitCommand_SkillsOptionDescription,
string.Join(",", SkillDefinition.All.Select(s => s.Name)),
ConsoleInteractionService.AllChoice,
ConsoleInteractionService.NoneChoice)
};
protected override bool UpdateNotificationsEnabled => false;
///
/// Public entry point for executing the init command.
/// This allows McpInitCommand to delegate to this implementation.
///
internal Task ExecuteCommandAsync(ParseResult parseResult, CancellationToken cancellationToken)
{
return ExecuteAsync(parseResult, cancellationToken);
}
///
/// Prompts the user to run agent init after a successful command, then chains into agent init if accepted.
/// Used by commands (e.g. aspire init, aspire new) to offer agent init as a follow-up step.
///
internal async Task PromptAndChainAsync(
IInteractionService interactionService,
int previousResultExitCode,
DirectoryInfo workspaceRoot,
PromptBinding agentInitBinding,
CancellationToken cancellationToken)
{
if (previousResultExitCode != ExitCodeConstants.Success)
{
return new(previousResultExitCode, [], []);
}
// Add a separating line between prompt and previous work in aspire new and aspire init.
interactionService.DisplayEmptyLine();
var runAgentInit = await interactionService.PromptConfirmAsync(
SharedCommandStrings.PromptRunAgentInit,
binding: agentInitBinding,
cancellationToken: cancellationToken);
if (runAgentInit)
{
return await ExecuteAgentInitAsync(workspaceRoot, parseResult: null, cancellationToken);
}
return new(ExitCodeConstants.Success, [], []);
}
protected override async Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken)
{
var workspaceRoot = await PromptForWorkspaceRootAsync(parseResult, cancellationToken);
var result = await ExecuteAgentInitAsync(workspaceRoot, parseResult, cancellationToken);
return result.ExitCode;
}
private async Task PromptForWorkspaceRootAsync(ParseResult parseResult, CancellationToken cancellationToken)
{
// Try to discover the git repository root to use as the default workspace root
var gitRoot = await _gitRepository.GetRootAsync(cancellationToken);
var defaultWorkspaceRoot = gitRoot ?? ExecutionContext.WorkingDirectory;
// Prompt the user for the workspace root
var workspaceRootPath = await _interactionService.PromptForFilePathAsync(
McpCommandStrings.InitCommand_WorkspaceRootPrompt,
binding: PromptBinding.Create(parseResult, s_workspaceRootOption, defaultWorkspaceRoot.FullName),
validator: path =>
{
if (string.IsNullOrWhiteSpace(path))
{
return ValidationResult.Error(McpCommandStrings.InitCommand_WorkspaceRootRequired);
}
if (!Directory.Exists(path))
{
return ValidationResult.Error(string.Format(CultureInfo.InvariantCulture, McpCommandStrings.InitCommand_WorkspaceRootNotFound, path));
}
return ValidationResult.Success();
},
directory: true,
cancellationToken: cancellationToken);
return new DirectoryInfo(workspaceRootPath);
}
private async Task ExecuteAgentInitAsync(DirectoryInfo workspaceRoot, ParseResult? parseResult, CancellationToken cancellationToken)
{
var context = new AgentEnvironmentScanContext
{
WorkingDirectory = ExecutionContext.WorkingDirectory,
RepositoryRoot = workspaceRoot
};
var applicators = await _interactionService.ShowStatusAsync(
McpCommandStrings.InitCommand_DetectingAgentEnvironments,
async () => await _agentEnvironmentDetector.DetectAsync(context, cancellationToken));
// Detect the AppHost language to determine which skills to offer.
// When no language is detected (e.g., standalone `aspire agent init`), language-restricted skills are excluded.
var detectedLanguage = await _languageDiscovery.DetectLanguageRecursiveAsync(workspaceRoot, cancellationToken);
// Filter skills based on language applicability
var availableSkills = SkillDefinition.All
.Where(s => s.IsApplicableToLanguage(detectedLanguage))
.ToList();
// Apply deprecated config migrations silently (these are fixes, not choices)
var configUpdates = applicators.Where(a => a.PromptGroup == McpInitPromptGroup.ConfigUpdates).ToList();
var userChoices = applicators.Where(a => a.PromptGroup != McpInitPromptGroup.ConfigUpdates).ToList();
foreach (var update in configUpdates)
{
try
{
await update.ApplyAsync(cancellationToken);
_interactionService.DisplayMessage(KnownEmojis.Wrench, update.Description);
}
catch (InvalidOperationException ex)
{
_interactionService.DisplayError(ex.Message);
}
}
// --- Phase 1: Skill location selection ---
var defaultLocationIds = string.Join(",", SkillLocation.All.Where(l => l.IsDefault).Select(l => l.Id));
var skillLocationsBinding = parseResult is not null
? PromptBinding.Create(parseResult, s_skillLocationsOption, defaultLocationIds)
: PromptBinding.CreateDefault(defaultLocationIds);
var selectedLocations = await _interactionService.PromptForSelectionsAsync(
AgentCommandStrings.InitCommand_SelectSkillLocations,
SkillLocation.All,
loc => $"{loc.DisplayName} — {loc.Description}",
preSelected: SkillLocation.All.Where(l => l.IsDefault),
optional: true,
binding: skillLocationsBinding,
cancellationToken: cancellationToken);
// --- Phase 2: Skill and MCP server selection (only if locations were selected) ---
IReadOnlyList selectedSkills = [];
AgentEnvironmentApplicator? combinedMcpApplicator = null;
var mcpApplicators = userChoices.Where(a => a.PromptGroup == McpInitPromptGroup.AgentEnvironments).ToList();
if (selectedLocations.Count > 0)
{
// Build prompt items: skills first, then MCP as a separate non-default item
var skillChoices = new List