Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions src/Aspire.Cli/Agents/AspireSkills/AspireSkillsBundle.cs
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,21 @@ public async Task<IReadOnlyList<SkillAssetFile>> GetSkillFilesAsync(SkillDefinit
return files;
}

/// <summary>
/// Gets the installable skill definitions declared by the bundle manifest.
/// </summary>
public IReadOnlyList<SkillDefinition> GetSkillDefinitions()
{
return _manifest.Skills
.Select(static skill => SkillDefinition.CreateAspireSkillsBundle(
skill.Name!,
skill.Description!,
skill.IsDefault,
(skill.InstallExcludedRelativePaths ?? []).Select(NormalizeRelativePath).ToArray(),
skill.ApplicableLanguages ?? []))
.ToList();
}

private static void ValidateManifest(
DirectoryInfo bundleDirectory,
SkillBundleManifest manifest,
Expand Down Expand Up @@ -139,6 +154,11 @@ private static void ValidateManifest(
throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, "Aspire skills bundle manifest contains duplicate skill '{0}'.", skill.Name));
}

if (string.IsNullOrWhiteSpace(skill.Description))
{
throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, "Aspire skills bundle skill '{0}' must specify a description.", skill.Name));
}

if (skill.Files is not { Length: > 0 })
{
throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, "Aspire skills bundle skill '{0}' does not contain any files.", skill.Name));
Expand Down
72 changes: 36 additions & 36 deletions src/Aspire.Cli/Agents/SkillDefinition.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,30 +11,8 @@ namespace Aspire.Cli.Agents;
/// Represents a skill that can be installed into a skill location.
/// </summary>
[DebuggerDisplay("Name = {Name}, Description = {Description}, IsDefault = {IsDefault}")]
internal sealed class SkillDefinition
internal sealed class SkillDefinition : IEquatable<SkillDefinition>
{
/// <summary>
/// The Aspire skill for CLI commands and workflows.
/// </summary>
public static readonly SkillDefinition Aspire = new(
CommonAgentApplicators.AspireSkillName,
AgentCommandStrings.SkillDescription_Aspire,
skillContent: null,
sourceKind: SkillSourceKind.AspireSkillsBundle,
installExcludedRelativePaths: [Path.Combine("evals")],
isDefault: true);

/// <summary>
/// The Aspire deployment skill for target selection, preflight, publish, and deploy workflows.
/// </summary>
public static readonly SkillDefinition AspireDeployment = new(
CommonAgentApplicators.AspireDeploymentSkillName,
AgentCommandStrings.SkillDescription_AspireDeployment,
skillContent: null,
sourceKind: SkillSourceKind.AspireSkillsBundle,
installExcludedRelativePaths: [],
isDefault: true);

/// <summary>
/// The Playwright CLI skill for browser automation.
/// </summary>
Expand All @@ -59,17 +37,25 @@ internal sealed class SkillDefinition
isDefault: false,
applicableLanguages: [KnownLanguageId.CSharp]);

/// <summary>
/// One-time skill for completing Aspire initialization.
/// Installed by <c>aspire init</c> to scan the repo, wire up the AppHost, and configure dependencies.
/// </summary>
public static readonly SkillDefinition Aspireify = new(
CommonAgentApplicators.AspireifySkillName,
AgentCommandStrings.SkillDescription_Aspireify,
skillContent: null,
sourceKind: SkillSourceKind.AspireSkillsBundle,
installExcludedRelativePaths: [],
isDefault: true);
internal static SkillDefinition CreateAspireSkillsBundle(
string name,
string description,
bool isDefault,
IReadOnlyList<string>? installExcludedRelativePaths = null,
IReadOnlyList<string>? applicableLanguages = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(name);
ArgumentException.ThrowIfNullOrWhiteSpace(description);

return new(
name,
description,
skillContent: null,
sourceKind: SkillSourceKind.AspireSkillsBundle,
installExcludedRelativePaths: installExcludedRelativePaths ?? [],
isDefault,
applicableLanguages);
}

private SkillDefinition(string name, string description, string? skillContent, SkillSourceKind sourceKind, IReadOnlyList<string> installExcludedRelativePaths, bool isDefault, IReadOnlyList<string>? applicableLanguages = null)
{
Expand Down Expand Up @@ -161,6 +147,11 @@ public bool IsApplicableToLanguage(LanguageId? detectedLanguage)
return ApplicableLanguages.Any(l => string.Equals(l, detectedLanguage.Value.Value, StringComparison.OrdinalIgnoreCase));
}

/// <summary>
/// Returns whether this skill has the specified name.
/// </summary>
public bool HasName(string name) => string.Equals(Name, name, StringComparison.Ordinal);

private static bool PathMatchesOrIsUnder(string relativePath, string excludedPath)
{
if (string.Equals(relativePath, excludedPath, StringComparison.Ordinal))
Expand All @@ -177,9 +168,18 @@ private static bool PathMatchesOrIsUnder(string relativePath, string excludedPat
}

/// <summary>
/// Gets all available skill definitions.
/// Gets CLI-defined skills that are not sourced from the Aspire skills bundle.
/// </summary>
public static IReadOnlyList<SkillDefinition> All { get; } = [Aspire, Aspireify, AspireDeployment, PlaywrightCli, DotnetInspect];
public static IReadOnlyList<SkillDefinition> CliDefined { get; } = [PlaywrightCli, DotnetInspect];

/// <inheritdoc />
public bool Equals(SkillDefinition? other) => other is not null && string.Equals(Name, other.Name, StringComparison.Ordinal);

/// <inheritdoc />
public override bool Equals(object? obj) => obj is SkillDefinition other && Equals(other);

/// <inheritdoc />
public override int GetHashCode() => StringComparer.Ordinal.GetHashCode(Name);

/// <inheritdoc />
public override string ToString() => Name;
Expand Down
143 changes: 116 additions & 27 deletions src/Aspire.Cli/Commands/AgentInitCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ public AgentInitCommand(
private static readonly Option<string?> s_skillsOption = new("--skills")
{
Description = string.Format(CultureInfo.InvariantCulture, AgentCommandStrings.InitCommand_SkillsOptionDescription,
string.Join(",", SkillDefinition.All.Select(s => s.Name)),
string.Join(",", SkillDefinition.CliDefined.Select(s => s.Name)),
ConsoleInteractionService.AllChoice,
ConsoleInteractionService.NoneChoice)
};
Expand All @@ -102,11 +102,31 @@ internal Task<CommandResult> ExecuteCommandAsync(ParseResult parseResult, Cancel
/// Prompts the user to run agent init after a successful command, then chains into agent init if accepted.
/// Used by commands (e.g. <c>aspire init</c>, <c>aspire new</c>) to offer agent init as a follow-up step.
/// </summary>
internal Task<AgentInitExecutionResult> PromptAndChainAsync(
IInteractionService interactionService,
int previousResultExitCode,
DirectoryInfo workspaceRoot,
PromptBinding<bool> agentInitBinding,
CancellationToken cancellationToken)
{
return PromptAndChainAsync(
interactionService,
previousResultExitCode,
workspaceRoot,
agentInitBinding,
AgentInitSkillDefaultMode.Standard,
cancellationToken);
}

/// <summary>
/// Prompts the user to run agent init using command-specific default skill selection.
/// </summary>
internal async Task<AgentInitExecutionResult> PromptAndChainAsync(
IInteractionService interactionService,
int previousResultExitCode,
DirectoryInfo workspaceRoot,
PromptBinding<bool> agentInitBinding,
AgentInitSkillDefaultMode defaultSkillMode,
CancellationToken cancellationToken)
{
if (previousResultExitCode != CliExitCodes.Success)
Expand All @@ -124,7 +144,7 @@ internal async Task<AgentInitExecutionResult> PromptAndChainAsync(

if (runAgentInit)
{
return await ExecuteAgentInitAsync(workspaceRoot, parseResult: null, cancellationToken);
return await ExecuteAgentInitAsync(workspaceRoot, parseResult: null, defaultSkillMode, cancellationToken);
}

return new(CliExitCodes.Success, [], []);
Expand All @@ -133,7 +153,7 @@ internal async Task<AgentInitExecutionResult> PromptAndChainAsync(
protected override async Task<CommandResult> ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken)
{
var workspaceRoot = await PromptForWorkspaceRootAsync(parseResult, cancellationToken);
var result = await ExecuteAgentInitAsync(workspaceRoot, parseResult, cancellationToken);
var result = await ExecuteAgentInitAsync(workspaceRoot, parseResult, AgentInitSkillDefaultMode.Standard, cancellationToken);
return CommandResult.FromExitCode(result.ExitCode);
}

Expand Down Expand Up @@ -167,7 +187,7 @@ private async Task<DirectoryInfo> PromptForWorkspaceRootAsync(ParseResult parseR
return new DirectoryInfo(workspaceRootPath);
}

private async Task<AgentInitExecutionResult> ExecuteAgentInitAsync(DirectoryInfo workspaceRoot, ParseResult? parseResult, CancellationToken cancellationToken)
private async Task<AgentInitExecutionResult> ExecuteAgentInitAsync(DirectoryInfo workspaceRoot, ParseResult? parseResult, AgentInitSkillDefaultMode defaultSkillMode, CancellationToken cancellationToken)
{
var context = new AgentEnvironmentScanContext
{
Expand All @@ -184,11 +204,6 @@ private async Task<AgentInitExecutionResult> ExecuteAgentInitAsync(DirectoryInfo
// 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();
Expand Down Expand Up @@ -224,11 +239,24 @@ private async Task<AgentInitExecutionResult> ExecuteAgentInitAsync(DirectoryInfo

// --- Phase 2: Skill and MCP server selection (only if locations were selected) ---
IReadOnlyList<SkillDefinition> selectedSkills = [];
AspireSkillsBundle? aspireSkillsBundle = null;
AgentEnvironmentApplicator? combinedMcpApplicator = null;
var mcpApplicators = userChoices.Where(a => a.PromptGroup == McpInitPromptGroup.AgentEnvironments).ToList();

if (selectedLocations.Count > 0)
{
IReadOnlyList<SkillDefinition> availableSkills;
if (ShouldSkipBundleCatalogResolution(parseResult))
{
availableSkills = SkillDefinition.CliDefined
.Where(s => s.IsApplicableToLanguage(detectedLanguage))
.ToList();
}
else
{
(availableSkills, aspireSkillsBundle) = await ResolveAvailableSkillsAsync(detectedLanguage, cancellationToken);
}

// Build prompt items: skills first, then MCP as a separate non-default item
var skillChoices = new List<object>();
skillChoices.AddRange(availableSkills);
Expand All @@ -250,10 +278,11 @@ private async Task<AgentInitExecutionResult> ExecuteAgentInitAsync(DirectoryInfo
}

var preSelectedItems = new List<object>();
preSelectedItems.AddRange(availableSkills.Where(s => s.IsDefault));
var defaultSkills = GetDefaultSkills(availableSkills, defaultSkillMode);
preSelectedItems.AddRange(defaultSkills);
// MCP is intentionally NOT pre-selected

var defaultSkillNames = string.Join(",", availableSkills.Where(s => s.IsDefault).Select(s => s.Name));
var defaultSkillNames = string.Join(",", defaultSkills.Select(s => s.Name));
var skillsBinding = parseResult is not null
? PromptBinding.Create(parseResult, s_skillsOption, defaultSkillNames)
: PromptBinding.CreateDefault<string?>(defaultSkillNames);
Expand Down Expand Up @@ -286,22 +315,6 @@ private async Task<AgentInitExecutionResult> ExecuteAgentInitAsync(DirectoryInfo
// Each skill file write is fast (small markdown files), so sequential execution
// is fine — parallelizing would complicate error handling for no meaningful gain.
var hasErrors = false;
AspireSkillsBundle? aspireSkillsBundle = null;
if (selectedLocations.Count > 0 && selectedSkills.Any(static skill => skill.SourceKind is SkillSourceKind.AspireSkillsBundle))
{
var result = await _aspireSkillsInstaller.InstallAsync(cancellationToken);
if (result.Status is AspireSkillsInstallStatus.Installed)
{
aspireSkillsBundle = result.Bundle;
}
else
{
_interactionService.DisplayMessage(KnownEmojis.Warning, result.Message!);
selectedSkills = selectedSkills
.Where(static skill => skill.SourceKind is not SkillSourceKind.AspireSkillsBundle)
.ToList();
}
}

var installedSkills = new List<InstalledSkillSummaryItem>();

Expand Down Expand Up @@ -427,6 +440,76 @@ private async Task<AgentInitExecutionResult> ExecuteAgentInitAsync(DirectoryInfo
selectedSkills);
}

private async Task<(IReadOnlyList<SkillDefinition> Skills, AspireSkillsBundle? Bundle)> ResolveAvailableSkillsAsync(LanguageId? detectedLanguage, CancellationToken cancellationToken)
{
var skills = new List<SkillDefinition>();
AspireSkillsBundle? bundle = null;

var result = await _aspireSkillsInstaller.InstallAsync(cancellationToken);
if (result.Status is AspireSkillsInstallStatus.Installed)
{
bundle = result.Bundle ?? throw new InvalidOperationException("Aspire skills installer returned an installed result without a bundle.");
skills.AddRange(bundle.GetSkillDefinitions());
}
else
{
_interactionService.DisplayMessage(KnownEmojis.Warning, result.Message!);
}

skills.AddRange(SkillDefinition.CliDefined);

return (skills
.Where(s => s.IsApplicableToLanguage(detectedLanguage))
.ToList(), bundle);
}
Comment thread
IEvangelist marked this conversation as resolved.
Outdated

private static bool ShouldSkipBundleCatalogResolution(ParseResult? parseResult)
{
if (parseResult is null)
{
return false;
}

var optionResult = parseResult.GetResult(s_skillsOption);
if (optionResult is null || optionResult.Implicit)
{
return false;
}

var value = parseResult.GetValue(s_skillsOption);
if (string.Equals(value, ConsoleInteractionService.NoneChoice, StringComparison.OrdinalIgnoreCase))
{
return true;
}

if (string.IsNullOrWhiteSpace(value) ||
string.Equals(value, ConsoleInteractionService.AllChoice, StringComparison.OrdinalIgnoreCase))
{
return false;
}

var selectedSkillNames = value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
return selectedSkillNames.Length > 0 &&
selectedSkillNames.All(name => SkillDefinition.CliDefined.Any(skill => skill.HasName(name)));
Comment thread
IEvangelist marked this conversation as resolved.
Outdated
}

private static IReadOnlyList<SkillDefinition> GetDefaultSkills(IEnumerable<SkillDefinition> availableSkills, AgentInitSkillDefaultMode defaultSkillMode)
{
return availableSkills
.Where(skill => IsDefaultSkill(skill, defaultSkillMode))
.ToList();
}

private static bool IsDefaultSkill(SkillDefinition skill, AgentInitSkillDefaultMode defaultSkillMode)
{
if (skill.HasName(CommonAgentApplicators.AspireifySkillName))
{
return defaultSkillMode is AgentInitSkillDefaultMode.IncludeAspireify && skill.IsDefault;
}

return skill.IsDefault;
}

/// <summary>
/// Installs the files for a skill at the specified location, creating or updating them as needed.
/// </summary>
Expand Down Expand Up @@ -556,3 +639,9 @@ internal readonly record struct AgentInitExecutionResult(
int ExitCode,
IReadOnlyList<SkillLocation> SelectedLocations,
IReadOnlyList<SkillDefinition> SelectedSkills);

internal enum AgentInitSkillDefaultMode
{
Standard,
IncludeAspireify
}
10 changes: 8 additions & 2 deletions src/Aspire.Cli/Commands/InitCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -168,11 +168,17 @@ protected override async Task<CommandResult> ExecuteAsync(ParseResult parseResul
// This prompt lets users choose which skills to install — including aspireify.
var workspaceRoot = solutionFile?.Directory ?? workingDirectory;
var agentInitBinding = PromptBinding.CreateInvertedBoolConfirm(parseResult, NewCommand.s_suppressAgentInitOption, defaultValue: true);
var agentInitResult = await _agentInitCommand.PromptAndChainAsync(InteractionService, CliExitCodes.Success, workspaceRoot, agentInitBinding, cancellationToken);
var agentInitResult = await _agentInitCommand.PromptAndChainAsync(
InteractionService,
CliExitCodes.Success,
workspaceRoot,
agentInitBinding,
AgentInitSkillDefaultMode.IncludeAspireify,
cancellationToken);

// Step 5: Print follow-up commands only when the user selected the one-time init skill.
if (agentInitResult.ExitCode == CliExitCodes.Success &&
agentInitResult.SelectedSkills.Contains(SkillDefinition.Aspireify))
agentInitResult.SelectedSkills.Any(static skill => skill.HasName(CommonAgentApplicators.AspireifySkillName)))
{
var commands = GetAspireifyCommands(agentInitResult.SelectedLocations);
if (commands.Count > 0)
Expand Down
2 changes: 1 addition & 1 deletion src/Aspire.Cli/Resources/AgentCommandStrings.Designer.cs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading