From 3c0f90d18d1124bb1fac2b8f21ba57febb230930 Mon Sep 17 00:00:00 2001 From: IEvangelist <7679720+IEvangelist@users.noreply.github.com> Date: Fri, 29 May 2026 12:50:40 -0500 Subject: [PATCH] Backport PR #17553 to release/13.4 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Agents/AspireSkills/AspireSkillsBundle.cs | 49 +- .../AspireSkills/AspireSkillsInstaller.cs | 87 ++- .../AspireSkills/SkillBundleManifest.cs | 2 - src/Aspire.Cli/Agents/SkillDefinition.cs | 62 +- src/Aspire.Cli/Commands/AgentInitCommand.cs | 288 +++++++-- src/Aspire.Cli/Commands/InitCommand.cs | 11 +- src/Aspire.Cli/Commands/NewCommand.cs | 4 +- .../Interaction/ConsoleInteractionService.cs | 20 +- .../ExtensionInteractionService.cs | 7 +- .../Interaction/IInteractionService.cs | 2 +- src/Aspire.Cli/KnownFeatures.cs | 8 +- .../Resources/AgentCommandStrings.Designer.cs | 2 +- .../Resources/AgentCommandStrings.resx | 2 +- .../Resources/xlf/AgentCommandStrings.cs.xlf | 4 +- .../Resources/xlf/AgentCommandStrings.de.xlf | 4 +- .../Resources/xlf/AgentCommandStrings.es.xlf | 4 +- .../Resources/xlf/AgentCommandStrings.fr.xlf | 4 +- .../Resources/xlf/AgentCommandStrings.it.xlf | 4 +- .../Resources/xlf/AgentCommandStrings.ja.xlf | 4 +- .../Resources/xlf/AgentCommandStrings.ko.xlf | 4 +- .../Resources/xlf/AgentCommandStrings.pl.xlf | 4 +- .../xlf/AgentCommandStrings.pt-BR.xlf | 4 +- .../Resources/xlf/AgentCommandStrings.ru.xlf | 4 +- .../Resources/xlf/AgentCommandStrings.tr.xlf | 4 +- .../xlf/AgentCommandStrings.zh-Hans.xlf | 4 +- .../xlf/AgentCommandStrings.zh-Hant.xlf | 4 +- .../AgentCommandTests.cs | 116 +++- .../Agents/AspireSkillsBundleTests.cs | 190 +++++- .../Agents/AspireSkillsInstallerTests.cs | 162 ++++- .../Agents/CommonAgentApplicatorsTests.cs | 58 +- .../Commands/AgentInitCommandTests.cs | 573 ++++++++++++++++-- .../Commands/InitCommandTests.cs | 12 +- .../Commands/NewCommandTests.cs | 11 +- ...PublishCommandPromptingIntegrationTests.cs | 2 +- .../Commands/UpdateCommandTests.cs | 12 +- .../ConsoleInteractionServiceTests.cs | 61 ++ .../Projects/ExtensionGuestLauncherTests.cs | 2 +- .../Templating/DotNetTemplateFactoryTests.cs | 2 +- .../TestServices/FakePlaywrightServices.cs | 45 +- .../TestExtensionInteractionService.cs | 2 +- .../TestServices/TestInteractionService.cs | 2 +- 41 files changed, 1573 insertions(+), 273 deletions(-) diff --git a/src/Aspire.Cli/Agents/AspireSkills/AspireSkillsBundle.cs b/src/Aspire.Cli/Agents/AspireSkills/AspireSkillsBundle.cs index 49c726ea960..a0691f22058 100644 --- a/src/Aspire.Cli/Agents/AspireSkills/AspireSkillsBundle.cs +++ b/src/Aspire.Cli/Agents/AspireSkills/AspireSkillsBundle.cs @@ -41,13 +41,24 @@ public static async Task LoadAsync(DirectoryInfo bundleDirec bundleDirectory, VersionHelper.GetDefaultSdkVersion(), VersionHelper.GetDefaultSdkVersion(), + skipCompatibilityCheck: false, cancellationToken).ConfigureAwait(false); } + internal static Task LoadAsync( + DirectoryInfo bundleDirectory, + string currentCliVersion, + string currentSdkVersion, + CancellationToken cancellationToken) + { + return LoadAsync(bundleDirectory, currentCliVersion, currentSdkVersion, skipCompatibilityCheck: false, cancellationToken); + } + internal static async Task LoadAsync( DirectoryInfo bundleDirectory, string currentCliVersion, string currentSdkVersion, + bool skipCompatibilityCheck, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(bundleDirectory); @@ -71,7 +82,7 @@ internal static async Task LoadAsync( throw new InvalidOperationException("Aspire skills bundle manifest is empty or invalid."); } - ValidateManifest(bundleDirectory, manifest, currentCliVersion, currentSdkVersion); + ValidateManifest(bundleDirectory, manifest, currentCliVersion, currentSdkVersion, skipCompatibilityCheck); return new AspireSkillsBundle(bundleDirectory, manifest); } @@ -107,18 +118,43 @@ public async Task> GetSkillFilesAsync(SkillDefinit return files; } + /// + /// Gets the installable skill definitions declared by the bundle manifest. + /// + public IReadOnlyList GetSkillDefinitions() + { + return _manifest.Skills + .Select(static skill => SkillDefinition.CreateAspireSkillsBundle( + skill.Name!, + skill.Description!, + (skill.InstallExcludedRelativePaths ?? []).Select(NormalizeRelativePath).ToArray(), + skill.ApplicableLanguages ?? [])) + .ToList(); + } + private static void ValidateManifest( DirectoryInfo bundleDirectory, SkillBundleManifest manifest, string currentCliVersion, - string currentSdkVersion) + string currentSdkVersion, + bool skipCompatibilityCheck) { if (string.IsNullOrWhiteSpace(manifest.Version)) { throw new InvalidOperationException("Aspire skills bundle manifest must specify a version."); } - ValidateCompatibility(manifest.Supports, currentCliVersion, currentSdkVersion); + // The bundle's `supports` range gates whether a bundle pulled fresh from GitHub + // is allowed at runtime. For bundles we already trust locally — the snapshot + // embedded in the CLI binary, and bundles already written to our own cache — + // we skip the range check because the CLI's effective version may have moved + // past the snapshot's stamped range (e.g., a dogfood build of 13.5.x using a + // bundle whose supports declares ">=13.4.0 <13.5.0"). The bundle's `version` + // field plus the version-keyed cache directory still gate matching content. + if (!skipCompatibilityCheck) + { + ValidateCompatibility(manifest.Supports, currentCliVersion, currentSdkVersion); + } var skills = manifest.Skills; if (skills is not { Length: > 0 }) @@ -126,7 +162,7 @@ private static void ValidateManifest( throw new InvalidOperationException("Aspire skills bundle manifest must contain at least one skill."); } - var skillNames = new HashSet(StringComparer.Ordinal); + var skillNames = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var skill in skills) { if (string.IsNullOrWhiteSpace(skill.Name)) @@ -139,6 +175,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)); diff --git a/src/Aspire.Cli/Agents/AspireSkills/AspireSkillsInstaller.cs b/src/Aspire.Cli/Agents/AspireSkills/AspireSkillsInstaller.cs index dec2cca8e41..42fbb10a90d 100644 --- a/src/Aspire.Cli/Agents/AspireSkills/AspireSkillsInstaller.cs +++ b/src/Aspire.Cli/Agents/AspireSkills/AspireSkillsInstaller.cs @@ -8,9 +8,11 @@ using System.Net; using System.Security.Cryptography; using System.Text.Json; +using Aspire.Cli.Configuration; using Aspire.Cli.Interaction; using Aspire.Cli.Resources; using Aspire.Cli.Telemetry; +using Aspire.Cli.Utils; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; @@ -26,6 +28,7 @@ internal sealed class AspireSkillsInstaller( IInteractionService interactionService, CliExecutionContext executionContext, IConfiguration configuration, + IFeatures features, AspireCliTelemetry telemetry, ILogger logger) : IAspireSkillsInstaller { @@ -72,16 +75,33 @@ private async Task InstallCoreAsync(CancellationToken var validationDisabled = string.Equals(configuration[DisablePackageValidationKey], "true", StringComparison.OrdinalIgnoreCase); - var githubResult = await InstallFromGitHubAsync(cacheRoot, effectiveVersion, validationDisabled, activity, cancellationToken).ConfigureAwait(false); - if (githubResult.Status == AcquisitionStatus.Installed) + // The remote fetch path is opt-in. Ship 13.4 with this disabled so users only + // get the embedded snapshot (no unattended network call out to GitHub on every + // `aspire agent init`). Toggle the feature on to opt in to the GitHub release path, + // which still falls back to the embedded snapshot if the network call fails. + var remoteFetchEnabled = features.IsFeatureEnabled( + KnownFeatures.AspireSkillsRemoteFetchEnabled, + KnownFeatures.GetFeatureMetadata(KnownFeatures.AspireSkillsRemoteFetchEnabled)!.DefaultValue); + activity?.SetTag("aspire.skills.remote_fetch_enabled", remoteFetchEnabled); + + AcquisitionResult? githubResult = null; + if (remoteFetchEnabled) { - CleanupStaleCacheEntries(cacheRoot, effectiveVersion); - return AspireSkillsInstallResult.Installed(githubResult.Bundle!); - } + githubResult = await InstallFromGitHubAsync(cacheRoot, effectiveVersion, validationDisabled, activity, cancellationToken).ConfigureAwait(false); + if (githubResult.Status == AcquisitionStatus.Installed) + { + CleanupStaleCacheEntries(cacheRoot, effectiveVersion); + return AspireSkillsInstallResult.Installed(githubResult.Bundle!); + } - if (githubResult.Status == AcquisitionStatus.Failed) + if (githubResult.Status == AcquisitionStatus.Failed) + { + logger.LogDebug("Aspire skills GitHub acquisition failed for version {Version}; falling back to embedded snapshot. Failure: {Failure}", effectiveVersion, githubResult.Message); + } + } + else { - logger.LogDebug("Aspire skills GitHub acquisition failed for version {Version}; falling back to embedded snapshot. Failure: {Failure}", effectiveVersion, githubResult.Message); + logger.LogDebug("Aspire skills remote fetch feature '{Feature}' is disabled; using the embedded snapshot.", KnownFeatures.AspireSkillsRemoteFetchEnabled); } var embeddedResult = await InstallFromEmbeddedAsync(cacheRoot, effectiveVersion, activity, cancellationToken).ConfigureAwait(false); @@ -93,8 +113,8 @@ private async Task InstallCoreAsync(CancellationToken var failureMessage = embeddedResult.Status == AcquisitionStatus.Failed ? embeddedResult.Message ?? AgentCommandStrings.AspireSkillsInstaller_GitHubUnavailable - : githubResult.Status == AcquisitionStatus.Failed - ? githubResult.Message ?? AgentCommandStrings.AspireSkillsInstaller_GitHubUnavailable + : githubResult is { Status: AcquisitionStatus.Failed, Message: { } githubMessage } + ? githubMessage : AgentCommandStrings.AspireSkillsInstaller_GitHubUnavailable; activity?.SetStatus(ActivityStatusCode.Error, failureMessage); @@ -158,14 +178,15 @@ private async Task InstallFromGitHubAsync( try { - var bundle = await CacheArchiveAsync(cacheRoot, archivePath, version, cancellationToken).ConfigureAwait(false); + var bundle = await CacheArchiveAsync(cacheRoot, archivePath, version, skipCompatibilityCheck: false, cancellationToken).ConfigureAwait(false); activity?.SetTag("aspire.skills.source", "github"); activity?.SetTag("aspire.skills.cache_hit", false); return AcquisitionResult.Installed(bundle); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException or InvalidOperationException) { - logger.LogWarning(ex, "Downloaded Aspire skills GitHub release asset {AssetName} is invalid.", asset.Name); + // Includes version-mismatch failures from ValidateCompatibility, which fall back to the embedded snapshot. + logger.LogDebug(ex, "Downloaded Aspire skills GitHub release asset {AssetName} is invalid.", asset.Name); return AcquisitionResult.Failed(string.Format(CultureInfo.CurrentCulture, AgentCommandStrings.AspireSkillsInstaller_InvalidBundle, ex.Message)); } } @@ -230,14 +251,20 @@ private async Task InstallFromEmbeddedAsync( try { - var bundle = await CacheArchiveAsync(cacheRoot, archivePath, version, cancellationToken).ConfigureAwait(false); + // The embedded snapshot ships inside the CLI binary as the trusted last-resort + // fallback. Its `supports` range is stamped at the time the snapshot was built, + // which can lag the actual CLI version (especially for prerelease/dogfood builds) + // and would otherwise reject a perfectly usable local copy. Skip the bundle's + // CLI/SDK compatibility check here so the embedded skills are always offered when + // the network path is unavailable. + var bundle = await CacheArchiveAsync(cacheRoot, archivePath, version, skipCompatibilityCheck: true, cancellationToken).ConfigureAwait(false); activity?.SetTag("aspire.skills.source", "embedded"); activity?.SetTag("aspire.skills.cache_hit", false); return AcquisitionResult.Installed(bundle); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException or InvalidOperationException) { - logger.LogWarning(ex, "Embedded Aspire skills bundle {AssetName} is invalid.", metadata.AssetName); + logger.LogDebug(ex, "Embedded Aspire skills bundle {AssetName} is invalid.", metadata.AssetName); return AcquisitionResult.Failed(string.Format(CultureInfo.CurrentCulture, AgentCommandStrings.AspireSkillsInstaller_InvalidBundle, ex.Message)); } } @@ -434,7 +461,12 @@ private static HttpRequestMessage CreateGitHubRequest(string url) try { - var bundle = await AspireSkillsBundle.LoadAsync(new DirectoryInfo(cacheDirectory), cancellationToken).ConfigureAwait(false); + // Cached bundles are written by this installer (from GitHub or embedded sources). + // The cache directory is keyed by bundle version, which is the right invalidation + // signal, so skip the `supports` range check here — a previously-embedded snapshot + // whose range no longer covers the current CLI is still the local artifact we + // chose to use and should not be re-evicted on every invocation. + var bundle = await LoadCachedBundleAsync(cacheDirectory, cancellationToken).ConfigureAwait(false); ValidateBundleVersion(bundle, version); TouchLastUsed(cacheDirectory); activity?.SetTag("aspire.skills.cache_hit", true); @@ -448,10 +480,21 @@ private static HttpRequestMessage CreateGitHubRequest(string url) } } + private static Task LoadCachedBundleAsync(string cacheDirectory, CancellationToken cancellationToken) + { + return AspireSkillsBundle.LoadAsync( + new DirectoryInfo(cacheDirectory), + VersionHelper.GetDefaultSdkVersion(), + VersionHelper.GetDefaultSdkVersion(), + skipCompatibilityCheck: true, + cancellationToken); + } + private async Task CacheArchiveAsync( string cacheRoot, string archivePath, string version, + bool skipCompatibilityCheck, CancellationToken cancellationToken) { var extractDir = Path.Combine(cacheRoot, $".extract-{Guid.NewGuid():N}"); @@ -465,7 +508,7 @@ private async Task CacheArchiveAsync( var bundleRoot = FindBundleRoot(extractDir); CopyDirectory(bundleRoot.FullName, stageDir); - var stagedBundle = await AspireSkillsBundle.LoadAsync(new DirectoryInfo(stageDir), cancellationToken).ConfigureAwait(false); + var stagedBundle = await LoadStagedBundleAsync(stageDir, skipCompatibilityCheck, cancellationToken).ConfigureAwait(false); ValidateBundleVersion(stagedBundle, version); await using var cacheLock = await AcquireCacheLockAsync(cacheRoot, version, cancellationToken).ConfigureAwait(false); @@ -474,7 +517,7 @@ private async Task CacheArchiveAsync( { try { - var existingBundle = await AspireSkillsBundle.LoadAsync(new DirectoryInfo(targetDir), cancellationToken).ConfigureAwait(false); + var existingBundle = await LoadCachedBundleAsync(targetDir, cancellationToken).ConfigureAwait(false); ValidateBundleVersion(existingBundle, version); TouchLastUsed(targetDir); return existingBundle; @@ -493,7 +536,7 @@ private async Task CacheArchiveAsync( Directory.Move(stageDir, targetDir); TouchLastUsed(targetDir); - var installedBundle = await AspireSkillsBundle.LoadAsync(new DirectoryInfo(targetDir), cancellationToken).ConfigureAwait(false); + var installedBundle = await LoadCachedBundleAsync(targetDir, cancellationToken).ConfigureAwait(false); ValidateBundleVersion(installedBundle, version); return installedBundle; @@ -505,6 +548,16 @@ private async Task CacheArchiveAsync( } } + private static Task LoadStagedBundleAsync(string stageDir, bool skipCompatibilityCheck, CancellationToken cancellationToken) + { + return AspireSkillsBundle.LoadAsync( + new DirectoryInfo(stageDir), + VersionHelper.GetDefaultSdkVersion(), + VersionHelper.GetDefaultSdkVersion(), + skipCompatibilityCheck, + cancellationToken); + } + private static async Task AcquireCacheLockAsync(string cacheRoot, string version, CancellationToken cancellationToken) { var lockPath = Path.Combine(cacheRoot, $".{GetSafeFileName(version)}.lock"); diff --git a/src/Aspire.Cli/Agents/AspireSkills/SkillBundleManifest.cs b/src/Aspire.Cli/Agents/AspireSkills/SkillBundleManifest.cs index 5e48ed3f573..6f5a469deae 100644 --- a/src/Aspire.Cli/Agents/AspireSkills/SkillBundleManifest.cs +++ b/src/Aspire.Cli/Agents/AspireSkills/SkillBundleManifest.cs @@ -37,8 +37,6 @@ internal sealed class SkillBundleSkill public string? Description { get; init; } - public bool IsDefault { get; init; } - public string[] ApplicableLanguages { get; init; } = []; public string[] InstallExcludedRelativePaths { get; init; } = []; diff --git a/src/Aspire.Cli/Agents/SkillDefinition.cs b/src/Aspire.Cli/Agents/SkillDefinition.cs index b9a05156657..f2afd03936e 100644 --- a/src/Aspire.Cli/Agents/SkillDefinition.cs +++ b/src/Aspire.Cli/Agents/SkillDefinition.cs @@ -13,28 +13,6 @@ namespace Aspire.Cli.Agents; [DebuggerDisplay("Name = {Name}, Description = {Description}, IsDefault = {IsDefault}")] internal sealed class SkillDefinition { - /// - /// The Aspire skill for CLI commands and workflows. - /// - public static readonly SkillDefinition Aspire = new( - CommonAgentApplicators.AspireSkillName, - AgentCommandStrings.SkillDescription_Aspire, - skillContent: null, - sourceKind: SkillSourceKind.AspireSkillsBundle, - installExcludedRelativePaths: [Path.Combine("evals")], - isDefault: true); - - /// - /// The Aspire deployment skill for target selection, preflight, publish, and deploy workflows. - /// - public static readonly SkillDefinition AspireDeployment = new( - CommonAgentApplicators.AspireDeploymentSkillName, - AgentCommandStrings.SkillDescription_AspireDeployment, - skillContent: null, - sourceKind: SkillSourceKind.AspireSkillsBundle, - installExcludedRelativePaths: [], - isDefault: true); - /// /// The Playwright CLI skill for browser automation. /// @@ -60,16 +38,29 @@ internal sealed class SkillDefinition applicableLanguages: [KnownLanguageId.CSharp]); /// - /// One-time skill for completing Aspire initialization. - /// Installed by aspire init to scan the repo, wire up the AppHost, and configure dependencies. + /// Creates a skill definition sourced from the Aspire skills bundle. All bundle-sourced + /// skills are pre-selected by default in the install prompt; callers like aspire new + /// and standalone aspire agent init can still narrow that set with a predicate + /// (see AgentInitCommand.ExcludeOneTimeSetupSkillsFromDefaults). /// - 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, + IReadOnlyList? installExcludedRelativePaths = null, + IReadOnlyList? applicableLanguages = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentException.ThrowIfNullOrWhiteSpace(description); + + return new( + name, + description, + skillContent: null, + sourceKind: SkillSourceKind.AspireSkillsBundle, + installExcludedRelativePaths: installExcludedRelativePaths ?? [], + isDefault: true, + applicableLanguages); + } private SkillDefinition(string name, string description, string? skillContent, SkillSourceKind sourceKind, IReadOnlyList installExcludedRelativePaths, bool isDefault, IReadOnlyList? applicableLanguages = null) { @@ -161,6 +152,11 @@ public bool IsApplicableToLanguage(LanguageId? detectedLanguage) return ApplicableLanguages.Any(l => string.Equals(l, detectedLanguage.Value.Value, StringComparison.OrdinalIgnoreCase)); } + /// + /// Returns whether this skill has the specified name. + /// + public bool HasName(string name, StringComparison comparison = StringComparison.Ordinal) => string.Equals(Name, name, comparison); + private static bool PathMatchesOrIsUnder(string relativePath, string excludedPath) { if (string.Equals(relativePath, excludedPath, StringComparison.Ordinal)) @@ -177,9 +173,9 @@ private static bool PathMatchesOrIsUnder(string relativePath, string excludedPat } /// - /// Gets all available skill definitions. + /// Gets CLI-defined skills that are not sourced from the Aspire skills bundle. /// - public static IReadOnlyList All { get; } = [Aspire, Aspireify, AspireDeployment, PlaywrightCli, DotnetInspect]; + public static IReadOnlyList CliDefined { get; } = [PlaywrightCli, DotnetInspect]; /// public override string ToString() => Name; diff --git a/src/Aspire.Cli/Commands/AgentInitCommand.cs b/src/Aspire.Cli/Commands/AgentInitCommand.cs index 5788f880841..64d1e77d5c7 100644 --- a/src/Aspire.Cli/Commands/AgentInitCommand.cs +++ b/src/Aspire.Cli/Commands/AgentInitCommand.cs @@ -84,7 +84,7 @@ public AgentInitCommand( private static readonly Option 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) }; @@ -101,13 +101,18 @@ internal Task 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. aspire init, aspire new) to offer agent init as a follow-up step. + /// When is every bundle-sourced skill is + /// pre-selected, which is what aspire init wants because aspireify is the natural follow-up. + /// Other callers (e.g. aspire new) can pass a predicate to additionally filter out skills that + /// don't fit their context (such as one-time setup skills after a template has already produced the AppHost). /// internal async Task PromptAndChainAsync( IInteractionService interactionService, int previousResultExitCode, DirectoryInfo workspaceRoot, PromptBinding agentInitBinding, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + Func? selectByDefault = null) { if (previousResultExitCode != CliExitCodes.Success) { @@ -124,7 +129,7 @@ internal async Task PromptAndChainAsync( if (runAgentInit) { - return await ExecuteAgentInitAsync(workspaceRoot, parseResult: null, AgentInitErrorMode.BestEffort, cancellationToken); + return await ExecuteAgentInitAsync(workspaceRoot, parseResult: null, selectByDefault, cancellationToken); } return new(CliExitCodes.Success, [], []); @@ -133,10 +138,39 @@ internal async Task PromptAndChainAsync( protected override async Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) { var workspaceRoot = await PromptForWorkspaceRootAsync(parseResult, cancellationToken); - var result = await ExecuteAgentInitAsync(workspaceRoot, parseResult, AgentInitErrorMode.Strict, cancellationToken); + // Standalone `aspire agent init` is typically run against an existing project, so don't + // pre-select the one-time aspireify wiring skill even though every other bundle skill + // is default-on. Users can still opt into it from the prompt or via --skills. + var result = await ExecuteAgentInitAsync(workspaceRoot, parseResult, ExcludeOneTimeSetupSkillsFromDefaults, cancellationToken); return CommandResult.FromExitCode(result.ExitCode); } + /// + /// Names of bundle skills that perform one-time workspace setup and should NOT be + /// pre-selected after a workspace was just produced by a template flow such as + /// aspire new or after standalone aspire agent init (typically run + /// against an existing project). + /// + /// + /// This is the single source of truth the CLI consults when filtering bundle skills out + /// of the auto-preselection set. All bundle skills are default-on, so if the bundle ships + /// a new wiring or bootstrap-style skill that should NOT auto-run in an already-bootstrapped + /// workspace, add its name here. + /// + internal static readonly IReadOnlySet s_oneTimeSetupSkillNames = new HashSet(StringComparer.OrdinalIgnoreCase) + { + CommonAgentApplicators.AspireifySkillName, + }; + + /// + /// Default-skill predicate used by flows that do not want one-time setup skills + /// pre-selected — namely aspire new (template already created the AppHost) and + /// standalone aspire agent init (typically run against an existing project). + /// Skills filtered here remain available to opt into from the prompt or via --skills. + /// + internal static bool ExcludeOneTimeSetupSkillsFromDefaults(SkillDefinition skill) + => skill.IsDefault && !s_oneTimeSetupSkillNames.Contains(skill.Name); + private async Task PromptForWorkspaceRootAsync(ParseResult parseResult, CancellationToken cancellationToken) { // Try to discover the git repository root to use as the default workspace root @@ -167,7 +201,7 @@ private async Task PromptForWorkspaceRootAsync(ParseResult parseR return new DirectoryInfo(workspaceRootPath); } - private async Task ExecuteAgentInitAsync(DirectoryInfo workspaceRoot, ParseResult? parseResult, AgentInitErrorMode errorMode, CancellationToken cancellationToken) + private async Task ExecuteAgentInitAsync(DirectoryInfo workspaceRoot, ParseResult? parseResult, Func? selectByDefault, CancellationToken cancellationToken) { var context = new AgentEnvironmentScanContext { @@ -184,11 +218,6 @@ private async Task 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(); @@ -224,11 +253,30 @@ private async Task ExecuteAgentInitAsync(DirectoryInfo // --- Phase 2: Skill and MCP server selection (only if locations were selected) --- IReadOnlyList selectedSkills = []; + AspireSkillsBundle? aspireSkillsBundle = null; + string? bundleInstallFailureMessage = null; AgentEnvironmentApplicator? combinedMcpApplicator = null; var mcpApplicators = userChoices.Where(a => a.PromptGroup == McpInitPromptGroup.AgentEnvironments).ToList(); if (selectedLocations.Count > 0) { + IReadOnlyList availableSkills; + if (ShouldSkipBundleCatalogResolution(parseResult)) + { + availableSkills = SkillDefinition.CliDefined + .Where(s => s.IsApplicableToLanguage(detectedLanguage)) + .ToList(); + } + else + { + (availableSkills, aspireSkillsBundle, bundleInstallFailureMessage) = await ResolveAvailableSkillsAsync(detectedLanguage, cancellationToken); + } + + // Order the merged catalog deterministically by name so the prompt is stable + // regardless of manifest order. OrdinalIgnoreCase matches the case-insensitive + // --skills parsing used elsewhere. + availableSkills = [.. availableSkills.OrderBy(static s => s.Name, StringComparer.OrdinalIgnoreCase)]; + // Build prompt items: skills first, then MCP as a separate non-default item var skillChoices = new List(); skillChoices.AddRange(availableSkills); @@ -250,26 +298,46 @@ private async Task ExecuteAgentInitAsync(DirectoryInfo } var preSelectedItems = new List(); - preSelectedItems.AddRange(availableSkills.Where(s => s.IsDefault)); + var defaultSkills = GetDefaultSkills(availableSkills, selectByDefault); + 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(defaultSkillNames); + // When the bundle failed to install and the caller passed an explicit --skills value + // that names a bundle-only skill, the upcoming MatchChoicesOrThrow will reject the + // value as "not a valid choice" with no hint that the underlying cause was the + // bundle. Surface the install failure first so users can see why the catalog is short. + // We only do this when the value contains a name that is not in the available catalog + // and not a CLI-defined skill, so happy-path runs stay silent. + if (bundleInstallFailureMessage is not null) + { + var (wasProvided, requestedSkills, _) = PromptBinding.Resolve(skillsBinding); + if (wasProvided && requestedSkills is not null && HasUnknownBundleSkillCandidate(requestedSkills, availableSkills)) + { + _interactionService.DisplayError(bundleInstallFailureMessage); + } + } + var selectedItems = await _interactionService.PromptForSelectionsAsync( AgentCommandStrings.InitCommand_SelectSkills, skillChoices, item => item switch { - SkillDefinition skill => $"{skill.Name} — {skill.Description}", + SkillDefinition skill => $"{skill.Name.EscapeMarkup()} — {SimplifyDescription(skill.Description).EscapeMarkup()}", AgentEnvironmentApplicator app => $"[bold]{app.Description}[/] [dim]{AgentCommandStrings.InitCommand_ConfiguresDetectedAgentEnvironments}[/]", _ => item.ToString()! }, preSelected: preSelectedItems, optional: true, binding: skillsBinding, + // The MCP applicator participates in the interactive multi-select prompt for UX, + // but it is not a skill and must not be addressable via `--skills`. Restrict + // non-interactive validation to the actual SkillDefinition catalog. + bindingChoices: availableSkills.Cast(), echoSelected: false, cancellationToken: cancellationToken); @@ -286,30 +354,6 @@ private async Task 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 - { - if (errorMode is AgentInitErrorMode.Strict) - { - _interactionService.DisplayError(result.Message!); - hasErrors = true; - } - else - { - _interactionService.DisplayMessage(KnownEmojis.Warning, result.Message!); - selectedSkills = selectedSkills - .Where(static skill => skill.SourceKind is not SkillSourceKind.AspireSkillsBundle) - .ToList(); - } - } - } var installedSkills = new List(); @@ -435,6 +479,170 @@ private async Task ExecuteAgentInitAsync(DirectoryInfo selectedSkills); } + private async Task<(IReadOnlyList Skills, AspireSkillsBundle? Bundle, string? FailureMessage)> ResolveAvailableSkillsAsync(LanguageId? detectedLanguage, CancellationToken cancellationToken) + { + var skills = new List(); + AspireSkillsBundle? bundle = null; + string? failureMessage = null; + + var result = await _aspireSkillsInstaller.InstallAsync(cancellationToken); + if (result.Status is AspireSkillsInstallStatus.Installed) + { + bundle = result.Bundle ?? throw new InvalidOperationException("Aspire skills installer returned an installed result without a bundle."); + skills.AddRange(bundle.GetSkillDefinitions().Where(static skill => !IsCliDefinedSkillName(skill.Name))); + } + else + { + // Preserve the install failure so the caller can surface it only when the user + // passed an explicit --skills value that names a bundle-only skill. Happy-path + // (interactive prompt with the embedded fallback) stays silent. + failureMessage = result.Message; + } + + // When the bundle is unavailable (network failure, version mismatch, etc.), fall back + // silently to the CLI-defined skills. The installer already logs the underlying cause + // at debug level, so the user is not interrupted with a warning they cannot act on. + skills.AddRange(SkillDefinition.CliDefined); + + return (skills + .Where(s => s.IsApplicableToLanguage(detectedLanguage)) + .ToList(), bundle, failureMessage); + } + + private static bool HasUnknownBundleSkillCandidate(string requestedSkills, IReadOnlyList availableSkills) + { + // Tokens like "all" / "none" don't name skills, so the "looks like a bundle skill but missing" + // diagnostic doesn't apply — let the normal validation path handle them. + if (string.IsNullOrWhiteSpace(requestedSkills) || + string.Equals(requestedSkills, ConsoleInteractionService.AllChoice, StringComparison.OrdinalIgnoreCase) || + string.Equals(requestedSkills, ConsoleInteractionService.NoneChoice, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + var requested = requestedSkills.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + foreach (var name in requested) + { + if (IsCliDefinedSkillName(name)) + { + continue; + } + + if (!availableSkills.Any(s => s.HasName(name, StringComparison.OrdinalIgnoreCase))) + { + // A non-CLI name that isn't in the catalog is exactly the case the bundle would have provided. + return true; + } + } + + return false; + } + + private static bool ShouldSkipBundleCatalogResolution(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(static name => IsCliDefinedSkillName(name)); + } + + private static bool IsCliDefinedSkillName(string name) + { + return SkillDefinition.CliDefined.Any(skill => skill.HasName(name, StringComparison.OrdinalIgnoreCase)); + } + + /// + /// Extracts the single short sentence from a skill description so the selection prompt + /// stays readable. + /// + /// + /// Bundle manifest descriptions can include a bold skill-type prefix followed by a + /// short tagline and additional usage guidance, for example: + /// "**WORKFLOW SKILL** - Top-level router for Aspire 13.4 distributed apps. Detects the AppHost. USE FOR: ..." + /// This trims the prefix and returns only the first sentence. Inputs without the prefix + /// or sentence terminator are returned trimmed-but-otherwise-unchanged so CLI-defined + /// short descriptions are preserved as-is. + /// + internal static string SimplifyDescription(string description) + { + if (string.IsNullOrWhiteSpace(description)) + { + return description; + } + + var simplified = description.Trim(); + + // Strip the leading bold "TYPE SKILL" prefix when present, and only then strip the + // separator characters that typically follow it. Gating the separator strip on the + // prefix match avoids silently mutating descriptions that legitimately start with + // a dash, em-dash, or colon (e.g. "-mode flag explained" or ":memo notes"). + var strippedBoldPrefix = false; + if (simplified.StartsWith("**", StringComparison.Ordinal)) + { + var endBold = simplified.IndexOf("**", 2, StringComparison.Ordinal); + if (endBold > 0) + { + simplified = simplified[(endBold + 2)..].TrimStart(); + strippedBoldPrefix = true; + } + } + + if (strippedBoldPrefix) + { + // Separators that typically follow the bold prefix (" - ", " — ", " – ", ": "). + while (simplified.Length > 0 && simplified[0] is '-' or '\u2013' or '\u2014' or ':') + { + simplified = simplified[1..].TrimStart(); + } + } + + // Return up to and including the first sentence-ending punctuation followed by + // whitespace or end-of-string. This avoids splitting on inline punctuation such + // as "13.4" or "github.com" inside the first sentence. + for (var i = 0; i < simplified.Length; i++) + { + if (simplified[i] is '.' or '!' or '?' + && (i + 1 >= simplified.Length || char.IsWhiteSpace(simplified[i + 1]))) + { + return simplified[..(i + 1)]; + } + } + + return simplified; + } + + private static IReadOnlyList GetDefaultSkills(IEnumerable availableSkills, Func? selectByDefault) + { + // When the caller doesn't customize default selection, fall back to SkillDefinition.IsDefault. + // Bundle-sourced skills are uniformly IsDefault=true; CLI-defined skills (playwright-cli, + // dotnet-inspect) are IsDefault=false so they stay opt-in. Callers like `aspire new` pass + // a predicate to additionally filter out skills that don't fit their flow. + var predicate = selectByDefault ?? (static skill => skill.IsDefault); + return availableSkills.Where(predicate).ToList(); + } + /// /// Installs the files for a skill at the specified location, creating or updating them as needed. /// @@ -555,12 +763,6 @@ private static async Task> GetSkillFilesAsync(Skil throw new InvalidOperationException($"Skill '{skill.Name}' does not define installable files."); } - private enum AgentInitErrorMode - { - Strict, - BestEffort - } - private sealed record InstalledSkillSummaryItem(string SkillName, string DisplayLocation); private readonly record struct SkillInstallResult(bool Succeeded, InstalledSkillSummaryItem? UpdatedSkill); diff --git a/src/Aspire.Cli/Commands/InitCommand.cs b/src/Aspire.Cli/Commands/InitCommand.cs index 7397dbd192c..4e8eae7e996 100644 --- a/src/Aspire.Cli/Commands/InitCommand.cs +++ b/src/Aspire.Cli/Commands/InitCommand.cs @@ -168,11 +168,18 @@ protected override async Task 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); + // aspire init creates an AppHost in an existing repo, so pre-select every bundle skill + // (which includes aspireify as the natural follow-up wiring skill). + var agentInitResult = await _agentInitCommand.PromptAndChainAsync( + InteractionService, + CliExitCodes.Success, + workspaceRoot, + agentInitBinding, + 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) diff --git a/src/Aspire.Cli/Commands/NewCommand.cs b/src/Aspire.Cli/Commands/NewCommand.cs index 92e79ba658d..9a2f47624df 100644 --- a/src/Aspire.Cli/Commands/NewCommand.cs +++ b/src/Aspire.Cli/Commands/NewCommand.cs @@ -528,7 +528,9 @@ protected override async Task ExecuteAsync(ParseResult parseResul var workspaceRoot = new DirectoryInfo(templateResult.OutputPath ?? ExecutionContext.WorkingDirectory.FullName); var agentInitBinding = PromptBinding.CreateInvertedBoolConfirm(parseResult, s_suppressAgentInitOption, defaultValue: true); - var agentInitResult = await _agentInitCommand.PromptAndChainAsync(InteractionService, templateResult.ExitCode, workspaceRoot, agentInitBinding, cancellationToken); + // The template already produced the AppHost, so don't pre-select the one-time aspireify + // wiring skill — users can still opt into it from the prompt. + var agentInitResult = await _agentInitCommand.PromptAndChainAsync(InteractionService, templateResult.ExitCode, workspaceRoot, agentInitBinding, cancellationToken, AgentInitCommand.ExcludeOneTimeSetupSkillsFromDefaults); if (templateResult.OutputPath is not null && ExtensionHelper.IsExtensionHost(InteractionService, out var extensionInteractionService, out _)) { diff --git a/src/Aspire.Cli/Interaction/ConsoleInteractionService.cs b/src/Aspire.Cli/Interaction/ConsoleInteractionService.cs index ce88d074460..18345c835da 100644 --- a/src/Aspire.Cli/Interaction/ConsoleInteractionService.cs +++ b/src/Aspire.Cli/Interaction/ConsoleInteractionService.cs @@ -347,7 +347,7 @@ public async Task PromptForSelectionAsync(string promptText, IEnumerable> PromptForSelectionsAsync(string promptText, IEnumerable choices, Func choiceFormatter, IEnumerable? preSelected = null, bool optional = false, PromptBinding? binding = null, bool echoSelected = true, CancellationToken cancellationToken = default) where T : notnull + public async Task> PromptForSelectionsAsync(string promptText, IEnumerable choices, Func choiceFormatter, IEnumerable? preSelected = null, bool optional = false, PromptBinding? binding = null, bool echoSelected = true, IEnumerable? bindingChoices = null, CancellationToken cancellationToken = default) where T : notnull { ArgumentNullException.ThrowIfNull(promptText, nameof(promptText)); ArgumentNullException.ThrowIfNull(choices, nameof(choices)); @@ -356,10 +356,18 @@ public async Task> PromptForSelectionsAsync(string promptTex // Materialize once to avoid re-enumerating the choices enumerable. var choicesList = choices as IReadOnlyList ?? choices.ToList(); + // The non-interactive validation set defaults to the visible choices, but callers + // can pass a narrower bindingChoices subset when some visible items should never + // be addressable from the command-line option (e.g., a UX-only "configure MCP + // server" entry that lives in the same multi-select prompt as the real catalog). + var bindingChoicesList = bindingChoices is null + ? choicesList + : bindingChoices as IReadOnlyList ?? bindingChoices.ToList(); + var (wasProvided, value, defaultValue) = PromptBinding.Resolve(binding); if (wasProvided && value is not null) { - return MatchChoicesOrThrow(value, binding!, choicesList, choiceFormatter); + return MatchChoicesOrThrow(value, binding!, bindingChoicesList, choiceFormatter); } if (!_hostEnvironment.SupportsInteractiveInput) @@ -368,7 +376,7 @@ public async Task> PromptForSelectionsAsync(string promptTex { if (binding.NonInteractiveDefaultValue != null) { - return MatchChoicesOrThrow(binding.NonInteractiveDefaultValue, binding, choicesList, choiceFormatter); + return MatchChoicesOrThrow(binding.NonInteractiveDefaultValue, binding, bindingChoicesList, choiceFormatter); } ThrowNonInteractiveError(binding.SymbolDisplayName); @@ -792,7 +800,11 @@ internal void ValidateResolvedStringValue(string value, bool required, Func(string value, string symbolDisplayName, IEnumerable choices, Func choiceFormatter) where T : notnull { DisplayError(string.Format(CultureInfo.CurrentCulture, InteractionServiceStrings.NonInteractiveInvalidValue, value, symbolDisplayName)); - var availableChoices = string.Join(", ", choices.Select(c => choiceFormatter(c))); + // Strip Spectre markup from each formatted choice so non-interactive callers see plain + // text. Some choice formatters intentionally include [bold]/[dim]/etc. tokens for the + // interactive multi-select renderer; those tokens would otherwise leak verbatim through + // DisplaySubtleMessage and confuse anyone diagnosing a typoed --option value. + var availableChoices = string.Join(", ", choices.Select(c => choiceFormatter(c).RemoveSpectreFormatting())); DisplaySubtleMessage(string.Format(CultureInfo.CurrentCulture, InteractionServiceStrings.NonInteractiveAvailableValues, availableChoices)); throw new NonInteractiveException(symbolDisplayName); } diff --git a/src/Aspire.Cli/Interaction/ExtensionInteractionService.cs b/src/Aspire.Cli/Interaction/ExtensionInteractionService.cs index 195b0d6312b..6ffe3bd1d34 100644 --- a/src/Aspire.Cli/Interaction/ExtensionInteractionService.cs +++ b/src/Aspire.Cli/Interaction/ExtensionInteractionService.cs @@ -314,12 +314,13 @@ await _extensionTaskChannel.Writer.WriteAsync(async () => } public async Task> PromptForSelectionsAsync(string promptText, IEnumerable choices, Func choiceFormatter, - IEnumerable? preSelected = null, bool optional = false, PromptBinding? binding = null, bool echoSelected = true, CancellationToken cancellationToken = default) where T : notnull + IEnumerable? preSelected = null, bool optional = false, PromptBinding? binding = null, bool echoSelected = true, IEnumerable? bindingChoices = null, CancellationToken cancellationToken = default) where T : notnull { var (wasProvided, value, _) = PromptBinding.Resolve(binding); if (wasProvided && value is not null) { - return _consoleInteractionService.MatchChoicesOrThrow(value, binding!, choices, choiceFormatter); + var validationChoices = bindingChoices ?? choices; + return _consoleInteractionService.MatchChoicesOrThrow(value, binding!, validationChoices, choiceFormatter); } if (_extensionPromptEnabled) @@ -349,7 +350,7 @@ await _extensionTaskChannel.Writer.WriteAsync(async () => } else { - return await _consoleInteractionService.PromptForSelectionsAsync(promptText, choices, choiceFormatter, preSelected, optional, binding, echoSelected, cancellationToken); + return await _consoleInteractionService.PromptForSelectionsAsync(promptText, choices, choiceFormatter, preSelected, optional, binding, echoSelected, bindingChoices, cancellationToken); } } diff --git a/src/Aspire.Cli/Interaction/IInteractionService.cs b/src/Aspire.Cli/Interaction/IInteractionService.cs index 9f453e9079e..df40e0b12bc 100644 --- a/src/Aspire.Cli/Interaction/IInteractionService.cs +++ b/src/Aspire.Cli/Interaction/IInteractionService.cs @@ -26,7 +26,7 @@ internal interface IInteractionService Task PromptForFilePathAsync(string promptText, Func? validator = null, bool directory = false, bool required = false, PromptBinding? binding = null, CancellationToken cancellationToken = default); public Task PromptConfirmAsync(string promptText, PromptBinding? binding = null, CancellationToken cancellationToken = default); Task PromptForSelectionAsync(string promptText, IEnumerable choices, Func choiceFormatter, PromptBinding? binding = null, bool echoSelected = true, CancellationToken cancellationToken = default) where T : notnull; - Task> PromptForSelectionsAsync(string promptText, IEnumerable choices, Func choiceFormatter, IEnumerable? preSelected = null, bool optional = false, PromptBinding? binding = null, bool echoSelected = true, CancellationToken cancellationToken = default) where T : notnull; + Task> PromptForSelectionsAsync(string promptText, IEnumerable choices, Func choiceFormatter, IEnumerable? preSelected = null, bool optional = false, PromptBinding? binding = null, bool echoSelected = true, IEnumerable? bindingChoices = null, CancellationToken cancellationToken = default) where T : notnull; int DisplayIncompatibleVersionError(AppHostIncompatibleException ex, string appHostHostingVersion); void DisplayError(string errorMessage, bool allowMarkup = false); void DisplayMessage(KnownEmoji emoji, string message, bool allowMarkup = false, ConsoleOutput? consoleOverride = null); diff --git a/src/Aspire.Cli/KnownFeatures.cs b/src/Aspire.Cli/KnownFeatures.cs index dc0f3202b23..d830b694490 100644 --- a/src/Aspire.Cli/KnownFeatures.cs +++ b/src/Aspire.Cli/KnownFeatures.cs @@ -29,6 +29,7 @@ internal static class KnownFeatures public static string ExperimentalPolyglotGo => "experimentalPolyglot:go"; public static string ExperimentalPolyglotPython => "experimentalPolyglot:python"; public static string NuGetSignatureVerificationEnabled => "nugetSignatureVerificationEnabled"; + public static string AspireSkillsRemoteFetchEnabled => "aspireSkillsRemoteFetchEnabled"; private static readonly Dictionary s_featureMetadata = new() { @@ -80,7 +81,12 @@ internal static class KnownFeatures [NuGetSignatureVerificationEnabled] = new( NuGetSignatureVerificationEnabled, "Enable or disable defaulting the DOTNET_NUGET_SIGNATURE_VERIFICATION environment variable for spawned processes", - DefaultValue: true) + DefaultValue: true), + + [AspireSkillsRemoteFetchEnabled] = new( + AspireSkillsRemoteFetchEnabled, + "(Preview) Allow the Aspire CLI to download the aspire-skills bundle from GitHub. When disabled (the 13.4 default), the CLI only uses the cached bundle and the embedded snapshot baked into the CLI; toggle on to opt in to the remote fetch path.", + DefaultValue: false) }; /// diff --git a/src/Aspire.Cli/Resources/AgentCommandStrings.Designer.cs b/src/Aspire.Cli/Resources/AgentCommandStrings.Designer.cs index 6133c22ac5e..ce85dae225d 100644 --- a/src/Aspire.Cli/Resources/AgentCommandStrings.Designer.cs +++ b/src/Aspire.Cli/Resources/AgentCommandStrings.Designer.cs @@ -484,7 +484,7 @@ internal static string InitCommand_SkillLocationsOptionDescription { } /// - /// Looks up a localized string similar to Comma-separated list of skills to install (e.g. {0}), '{1}', or '{2}'. + /// Looks up a localized string similar to Comma-separated list of skills to install. Bundle skills are loaded dynamically; CLI-provided skills include {0}. Use '{1}' or '{2}'. /// internal static string InitCommand_SkillsOptionDescription { get { diff --git a/src/Aspire.Cli/Resources/AgentCommandStrings.resx b/src/Aspire.Cli/Resources/AgentCommandStrings.resx index 2364ff91d8d..b05f1f72458 100644 --- a/src/Aspire.Cli/Resources/AgentCommandStrings.resx +++ b/src/Aspire.Cli/Resources/AgentCommandStrings.resx @@ -202,6 +202,6 @@ Comma-separated list of skill locations to install (e.g. {0}), '{1}', or '{2}' - Comma-separated list of skills to install (e.g. {0}), '{1}', or '{2}' + Comma-separated list of skills to install. Bundle skills are loaded dynamically; CLI-provided skills include {0}. Use '{1}' or '{2}' diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.cs.xlf index 7139b9c497a..df08463f3fb 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.cs.xlf @@ -83,8 +83,8 @@ - Comma-separated list of skills to install (e.g. {0}), '{1}', or '{2}' - Comma-separated list of skills to install (e.g. {0}), '{1}', or '{2}' + Comma-separated list of skills to install. Bundle skills are loaded dynamically; CLI-provided skills include {0}. Use '{1}' or '{2}' + Comma-separated list of skills to install. Bundle skills are loaded dynamically; CLI-provided skills include {0}. Use '{1}' or '{2}' diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.de.xlf index a09a22f0b5d..bba085c2c41 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.de.xlf @@ -83,8 +83,8 @@ - Comma-separated list of skills to install (e.g. {0}), '{1}', or '{2}' - Comma-separated list of skills to install (e.g. {0}), '{1}', or '{2}' + Comma-separated list of skills to install. Bundle skills are loaded dynamically; CLI-provided skills include {0}. Use '{1}' or '{2}' + Comma-separated list of skills to install. Bundle skills are loaded dynamically; CLI-provided skills include {0}. Use '{1}' or '{2}' diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.es.xlf index 416a73644e6..eeed9427c05 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.es.xlf @@ -83,8 +83,8 @@ - Comma-separated list of skills to install (e.g. {0}), '{1}', or '{2}' - Comma-separated list of skills to install (e.g. {0}), '{1}', or '{2}' + Comma-separated list of skills to install. Bundle skills are loaded dynamically; CLI-provided skills include {0}. Use '{1}' or '{2}' + Comma-separated list of skills to install. Bundle skills are loaded dynamically; CLI-provided skills include {0}. Use '{1}' or '{2}' diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.fr.xlf index 38117b59c88..6dadce5acca 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.fr.xlf @@ -83,8 +83,8 @@ - Comma-separated list of skills to install (e.g. {0}), '{1}', or '{2}' - Comma-separated list of skills to install (e.g. {0}), '{1}', or '{2}' + Comma-separated list of skills to install. Bundle skills are loaded dynamically; CLI-provided skills include {0}. Use '{1}' or '{2}' + Comma-separated list of skills to install. Bundle skills are loaded dynamically; CLI-provided skills include {0}. Use '{1}' or '{2}' diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.it.xlf index 670db6c9e77..419da678d4a 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.it.xlf @@ -83,8 +83,8 @@ - Comma-separated list of skills to install (e.g. {0}), '{1}', or '{2}' - Comma-separated list of skills to install (e.g. {0}), '{1}', or '{2}' + Comma-separated list of skills to install. Bundle skills are loaded dynamically; CLI-provided skills include {0}. Use '{1}' or '{2}' + Comma-separated list of skills to install. Bundle skills are loaded dynamically; CLI-provided skills include {0}. Use '{1}' or '{2}' diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ja.xlf index 297b0f9eb54..1136e60b057 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ja.xlf @@ -83,8 +83,8 @@ - Comma-separated list of skills to install (e.g. {0}), '{1}', or '{2}' - Comma-separated list of skills to install (e.g. {0}), '{1}', or '{2}' + Comma-separated list of skills to install. Bundle skills are loaded dynamically; CLI-provided skills include {0}. Use '{1}' or '{2}' + Comma-separated list of skills to install. Bundle skills are loaded dynamically; CLI-provided skills include {0}. Use '{1}' or '{2}' diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ko.xlf index 05a8359420d..3339af60aad 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ko.xlf @@ -83,8 +83,8 @@ - Comma-separated list of skills to install (e.g. {0}), '{1}', or '{2}' - Comma-separated list of skills to install (e.g. {0}), '{1}', or '{2}' + Comma-separated list of skills to install. Bundle skills are loaded dynamically; CLI-provided skills include {0}. Use '{1}' or '{2}' + Comma-separated list of skills to install. Bundle skills are loaded dynamically; CLI-provided skills include {0}. Use '{1}' or '{2}' diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.pl.xlf index d87eccc448a..147afe86cd2 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.pl.xlf @@ -83,8 +83,8 @@ - Comma-separated list of skills to install (e.g. {0}), '{1}', or '{2}' - Comma-separated list of skills to install (e.g. {0}), '{1}', or '{2}' + Comma-separated list of skills to install. Bundle skills are loaded dynamically; CLI-provided skills include {0}. Use '{1}' or '{2}' + Comma-separated list of skills to install. Bundle skills are loaded dynamically; CLI-provided skills include {0}. Use '{1}' or '{2}' diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.pt-BR.xlf index 66b4e8820f1..a0b404b68e7 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.pt-BR.xlf @@ -83,8 +83,8 @@ - Comma-separated list of skills to install (e.g. {0}), '{1}', or '{2}' - Comma-separated list of skills to install (e.g. {0}), '{1}', or '{2}' + Comma-separated list of skills to install. Bundle skills are loaded dynamically; CLI-provided skills include {0}. Use '{1}' or '{2}' + Comma-separated list of skills to install. Bundle skills are loaded dynamically; CLI-provided skills include {0}. Use '{1}' or '{2}' diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ru.xlf index 76dc2665fa2..7ea833b2e9a 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ru.xlf @@ -83,8 +83,8 @@ - Comma-separated list of skills to install (e.g. {0}), '{1}', or '{2}' - Comma-separated list of skills to install (e.g. {0}), '{1}', or '{2}' + Comma-separated list of skills to install. Bundle skills are loaded dynamically; CLI-provided skills include {0}. Use '{1}' or '{2}' + Comma-separated list of skills to install. Bundle skills are loaded dynamically; CLI-provided skills include {0}. Use '{1}' or '{2}' diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.tr.xlf index 65b85538393..8e7822dfebf 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.tr.xlf @@ -83,8 +83,8 @@ - Comma-separated list of skills to install (e.g. {0}), '{1}', or '{2}' - Comma-separated list of skills to install (e.g. {0}), '{1}', or '{2}' + Comma-separated list of skills to install. Bundle skills are loaded dynamically; CLI-provided skills include {0}. Use '{1}' or '{2}' + Comma-separated list of skills to install. Bundle skills are loaded dynamically; CLI-provided skills include {0}. Use '{1}' or '{2}' diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.zh-Hans.xlf index f4c08201732..8e37e2ccd1c 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.zh-Hans.xlf @@ -83,8 +83,8 @@ - Comma-separated list of skills to install (e.g. {0}), '{1}', or '{2}' - Comma-separated list of skills to install (e.g. {0}), '{1}', or '{2}' + Comma-separated list of skills to install. Bundle skills are loaded dynamically; CLI-provided skills include {0}. Use '{1}' or '{2}' + Comma-separated list of skills to install. Bundle skills are loaded dynamically; CLI-provided skills include {0}. Use '{1}' or '{2}' diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.zh-Hant.xlf index 62de972aa38..2e7d6672223 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.zh-Hant.xlf @@ -83,8 +83,8 @@ - Comma-separated list of skills to install (e.g. {0}), '{1}', or '{2}' - Comma-separated list of skills to install (e.g. {0}), '{1}', or '{2}' + Comma-separated list of skills to install. Bundle skills are loaded dynamically; CLI-provided skills include {0}. Use '{1}' or '{2}' + Comma-separated list of skills to install. Bundle skills are loaded dynamically; CLI-provided skills include {0}. Use '{1}' or '{2}' diff --git a/tests/Aspire.Cli.EndToEnd.Tests/AgentCommandTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/AgentCommandTests.cs index 917c86c5d67..ca023986c70 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/AgentCommandTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/AgentCommandTests.cs @@ -222,6 +222,63 @@ await auto.WaitUntilAsync( Assert.Contains("Aspire Deployment", deploymentFileContent); } + /// + /// Regression guard for the original bug: bundle-only skill names (aspire-init, + /// aspire-monitoring, aspire-orchestration) were not surfaced by the CLI because the + /// install prompt was driven by a hardcoded list. End-to-end this means passing those + /// names to aspire agent init --skills must materialize their SKILL.md files. + /// The CLI-hardcoded skills (aspire/aspireify/aspire-deployment) worked before, so they + /// aren't part of the regression and are covered by the broader integration test. + /// + [Fact] + public async Task AgentInitCommand_NonInteractive_BundleOnlySkillsBeyondCliCatalog_AreInstallable() + { + var repoRoot = CliE2ETestHelpers.GetRepoRoot(); + var strategy = CliInstallStrategy.Detect(output.WriteLine); + var workspace = TemporaryWorkspace.Create(output); + + using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, workspace: workspace); + + var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); + + var counter = new SequenceCounter(); + var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + + await auto.PrepareDockerEnvironmentAsync(counter, workspace); + + await auto.InstallAspireCliAsync(strategy, counter); + + // Seed the user-level cache with the six bundle skills so the CLI exercises the cached + // path without needing the unpublished npm package. The fixture mirrors the published + // bundle's manifest shape. + await SeedAspireSkillsBundleCacheAsync(auto, workspace, counter); + + // The names below are the ones the original bug hid from the CLI. Naming them explicitly + // (rather than `--skills all`) avoids pulling in playwright/dotnet-inspect, which would + // attempt real npm registry calls inside the container, and keeps the assertion narrowly + // focused on the regression. Extra skills added to the bundle in the future are + // intentionally outside the scope of this snapshot test. + var bundleOnlySkills = new[] { "aspire-init", "aspire-monitoring", "aspire-orchestration" }; + var skillsArg = string.Join(",", bundleOnlySkills); + + await auto.TypeAsync($"aspire agent init --workspace-root . --skill-locations standard --skills {skillsArg}"); + await auto.EnterAsync(); + await auto.WaitUntilTextAsync("configuration complete", timeout: TimeSpan.FromSeconds(60)); + await auto.WaitForSuccessPromptAsync(counter); + + var skillsRoot = Path.Combine(workspace.WorkspaceRoot.FullName, ".agents", "skills"); + foreach (var skillName in bundleOnlySkills) + { + var skillFile = Path.Combine(skillsRoot, skillName, "SKILL.md"); + Assert.True(File.Exists(skillFile), $"Expected {skillName} SKILL.md at {skillFile}"); + } + + await auto.TypeAsync("exit"); + await auto.EnterAsync(); + + await pendingRun; + } + private static async Task SeedAspireSkillsBundleCacheAsync(Hex1bTerminalAutomator auto, TemporaryWorkspace workspace, SequenceCounter counter) { const string aspireSkillsVersion = "0.0.1"; @@ -237,7 +294,10 @@ private static async Task SeedAspireSkillsBundleCacheAsync(Hex1bTerminalAutomato "$cache/skills/aspire/references" \ "$cache/skills/aspire/evals" \ "$cache/skills/aspireify" \ - "$cache/skills/aspire-deployment/references" + "$cache/skills/aspire-deployment/references" \ + "$cache/skills/aspire-init" \ + "$cache/skills/aspire-monitoring" \ + "$cache/skills/aspire-orchestration" cat > "$cache/skills/aspire/SKILL.md" <<'SKILL' --- @@ -271,12 +331,42 @@ private static async Task SeedAspireSkillsBundleCacheAsync(Hex1bTerminalAutomato SKILL printf '%s\n' '# Preflight' > "$cache/skills/aspire-deployment/references/preflight.md" + cat > "$cache/skills/aspire-init/SKILL.md" <<'SKILL' + --- + name: aspire-init + description: "First-run flow for adding Aspire to a repo" + --- + + # Aspire Init + SKILL + + cat > "$cache/skills/aspire-monitoring/SKILL.md" <<'SKILL' + --- + name: aspire-monitoring + description: "Observe Aspire apps with logs, traces, metrics, and resource state" + --- + + # Aspire Monitoring + SKILL + + cat > "$cache/skills/aspire-orchestration/SKILL.md" <<'SKILL' + --- + name: aspire-orchestration + description: "Manage Aspire AppHost lifecycle and resource commands" + --- + + # Aspire Orchestration + SKILL + aspire_skill_hash="$(sha256sum "$cache/skills/aspire/SKILL.md" | awk '{print $1}')" aspire_commands_hash="$(sha256sum "$cache/skills/aspire/references/app-commands.md" | awk '{print $1}')" aspire_evals_hash="$(sha256sum "$cache/skills/aspire/evals/evals.json" | awk '{print $1}')" aspireify_skill_hash="$(sha256sum "$cache/skills/aspireify/SKILL.md" | awk '{print $1}')" deployment_skill_hash="$(sha256sum "$cache/skills/aspire-deployment/SKILL.md" | awk '{print $1}')" deployment_preflight_hash="$(sha256sum "$cache/skills/aspire-deployment/references/preflight.md" | awk '{print $1}')" + init_skill_hash="$(sha256sum "$cache/skills/aspire-init/SKILL.md" | awk '{print $1}')" + monitoring_skill_hash="$(sha256sum "$cache/skills/aspire-monitoring/SKILL.md" | awk '{print $1}')" + orchestration_skill_hash="$(sha256sum "$cache/skills/aspire-orchestration/SKILL.md" | awk '{print $1}')" cat > "$cache/skill-manifest.json" < SkillDefinition.CreateAspireSkillsBundle( + CommonAgentApplicators.AspireSkillName, + AspireSkillDescription, + installExcludedRelativePaths: ["evals"]); + + private static SkillDefinition AspireifySkillDefinition => SkillDefinition.CreateAspireSkillsBundle( + CommonAgentApplicators.AspireifySkillName, + AspireifySkillDescription); + [Fact] public async Task LoadAsync_ValidatesManifestAndReturnsInstallableFiles() { @@ -25,7 +37,7 @@ public async Task LoadAsync_ValidatesManifestAndReturnsInstallableFiles() }); var bundle = await AspireSkillsBundle.LoadAsync(new DirectoryInfo(bundleDirectory), CancellationToken.None); - var files = await bundle.GetSkillFilesAsync(SkillDefinition.Aspire, CancellationToken.None); + var files = await bundle.GetSkillFilesAsync(AspireSkillDefinition, CancellationToken.None); Assert.Equal(AspireSkillsInstaller.Version, bundle.Version); Assert.Contains(files, file => file.RelativePath == "SKILL.md"); @@ -38,6 +50,35 @@ public async Task LoadAsync_ValidatesManifestAndReturnsInstallableFiles() } } + [Fact] + public async Task GetSkillDefinitions_ReturnsManifestSkills() + { + var bundleDirectory = CreateTempDirectory(); + + try + { + await CreateBundleAsync(bundleDirectory, new Dictionary + { + ["SKILL.md"] = CreateSkillFileContent(), + ["references/app-commands.md"] = "# App commands" + }); + + var bundle = await AspireSkillsBundle.LoadAsync(new DirectoryInfo(bundleDirectory), CancellationToken.None); + var skill = Assert.Single(bundle.GetSkillDefinitions()); + + Assert.Equal(CommonAgentApplicators.AspireSkillName, skill.Name); + Assert.Equal(AspireSkillDescription, skill.Description); + Assert.True(skill.IsDefault); + Assert.Equal(SkillSourceKind.AspireSkillsBundle, skill.SourceKind); + Assert.Equal(["evals"], skill.InstallExcludedRelativePaths); + Assert.Empty(skill.ApplicableLanguages); + } + finally + { + Directory.Delete(bundleDirectory, recursive: true); + } + } + [Fact] public async Task LoadAsync_ThrowsWhenHashDoesNotMatch() { @@ -83,12 +124,45 @@ public async Task LoadAsync_ThrowsWhenSkillDescriptionExceedsAgentHostLimit() } } + [Fact] + public async Task LoadAsync_ThrowsWhenSkillNamesDifferOnlyByCase() + { + var bundleDirectory = CreateTempDirectory(); + + try + { + await WriteSkillAsync(bundleDirectory, CommonAgentApplicators.AspireSkillName, CreateSkillFileContent()); + await WriteSkillAsync(bundleDirectory, "Aspire", CreateSkillFileContent("Aspire")); + + var manifest = new SkillBundleManifest + { + Version = AspireSkillsInstaller.Version, + Supports = CreateSupports(), + Skills = + [ + CreateManifestSkill(bundleDirectory, CommonAgentApplicators.AspireSkillName, AspireSkillDescription), + CreateManifestSkill(bundleDirectory, "Aspire", AspireSkillDescription) + ] + }; + + await WriteManifestAsync(bundleDirectory, manifest); + + var exception = await Assert.ThrowsAsync(() => AspireSkillsBundle.LoadAsync(new DirectoryInfo(bundleDirectory), CancellationToken.None)); + + Assert.Contains("duplicate skill", exception.Message, StringComparison.OrdinalIgnoreCase); + } + finally + { + Directory.Delete(bundleDirectory, recursive: true); + } + } + [Fact] public async Task LoadAsync_ThrowsWhenFilePathEscapesSkillRoot() { var bundleDirectory = CreateTempDirectory(); - Directory.CreateDirectory(Path.Combine(bundleDirectory, "skills", SkillDefinition.Aspire.Name)); - await File.WriteAllTextAsync(Path.Combine(bundleDirectory, "skills", SkillDefinition.Aspire.Name, "SKILL.md"), CreateSkillFileContent()); + Directory.CreateDirectory(Path.Combine(bundleDirectory, "skills", CommonAgentApplicators.AspireSkillName)); + await File.WriteAllTextAsync(Path.Combine(bundleDirectory, "skills", CommonAgentApplicators.AspireSkillName, "SKILL.md"), CreateSkillFileContent()); try { @@ -100,9 +174,8 @@ public async Task LoadAsync_ThrowsWhenFilePathEscapesSkillRoot() [ new SkillBundleSkill { - Name = SkillDefinition.Aspire.Name, - Description = SkillDefinition.Aspire.Description, - IsDefault = true, + Name = CommonAgentApplicators.AspireSkillName, + Description = AspireSkillDescription, Files = [ new SkillBundleFile @@ -131,10 +204,10 @@ public async Task LoadAsync_ThrowsWhenFilePathEscapesSkillRoot() public async Task GetSkillFilesAsync_TreatsMissingOptionalPathArraysAsEmpty() { var bundleDirectory = CreateTempDirectory(); - var skillDirectory = Path.Combine(bundleDirectory, "skills", SkillDefinition.Aspireify.Name); + var skillDirectory = Path.Combine(bundleDirectory, "skills", CommonAgentApplicators.AspireifySkillName); Directory.CreateDirectory(skillDirectory); var skillPath = Path.Combine(skillDirectory, "SKILL.md"); - var skillContent = CreateSkillFileContent(SkillDefinition.Aspireify.Name, SkillDefinition.Aspireify.Description, "# Aspireify"); + var skillContent = CreateSkillFileContent(CommonAgentApplicators.AspireifySkillName, AspireifySkillDescription, "# Aspireify"); await File.WriteAllTextAsync(skillPath, skillContent); try @@ -149,9 +222,8 @@ public async Task GetSkillFilesAsync_TreatsMissingOptionalPathArraysAsEmpty() }, "skills": [ { - "name": "{{SkillDefinition.Aspireify.Name}}", - "description": "{{SkillDefinition.Aspireify.Description}}", - "isDefault": true, + "name": "{{CommonAgentApplicators.AspireifySkillName}}", + "description": "{{AspireifySkillDescription}}", "files": [ { "relativePath": "SKILL.md", "sha256": "{{ComputeSha256(skillPath)}}" } ] @@ -162,7 +234,7 @@ public async Task GetSkillFilesAsync_TreatsMissingOptionalPathArraysAsEmpty() await File.WriteAllTextAsync(Path.Combine(bundleDirectory, "skill-manifest.json"), manifestJson); var bundle = await AspireSkillsBundle.LoadAsync(new DirectoryInfo(bundleDirectory), CancellationToken.None); - var files = await bundle.GetSkillFilesAsync(SkillDefinition.Aspireify, CancellationToken.None); + var files = await bundle.GetSkillFilesAsync(AspireifySkillDefinition, CancellationToken.None); var skillFile = Assert.Single(files); Assert.Equal("SKILL.md", skillFile.RelativePath); @@ -178,7 +250,7 @@ public async Task GetSkillFilesAsync_TreatsMissingOptionalPathArraysAsEmpty() public async Task LoadAsync_ThrowsWhenSupportsAreMissing() { var bundleDirectory = CreateTempDirectory(); - var skillDirectory = Path.Combine(bundleDirectory, "skills", SkillDefinition.Aspire.Name); + var skillDirectory = Path.Combine(bundleDirectory, "skills", CommonAgentApplicators.AspireSkillName); Directory.CreateDirectory(skillDirectory); var skillPath = Path.Combine(skillDirectory, "SKILL.md"); await File.WriteAllTextAsync(skillPath, CreateSkillFileContent()); @@ -192,9 +264,8 @@ public async Task LoadAsync_ThrowsWhenSupportsAreMissing() [ new SkillBundleSkill { - Name = SkillDefinition.Aspire.Name, - Description = SkillDefinition.Aspire.Description, - IsDefault = true, + Name = CommonAgentApplicators.AspireSkillName, + Description = AspireSkillDescription, Files = [ new SkillBundleFile @@ -271,13 +342,69 @@ await CreateBundleAsync( } } + [Fact] + public async Task LoadAsync_SkipCompatibilityCheck_AllowsBundleOutsideSupportsRange() + { + var bundleDirectory = CreateTempDirectory(); + + try + { + await CreateBundleAsync( + bundleDirectory, + new Dictionary { ["SKILL.md"] = CreateSkillFileContent() }, + supports: new SkillBundleSupports { AspireCli = ">=13.4.0 <13.5.0" }); + + var bundle = await AspireSkillsBundle.LoadAsync( + new DirectoryInfo(bundleDirectory), + currentCliVersion: "13.5.0-pr.17553.gca8e5ace", + currentSdkVersion: "13.5.0", + skipCompatibilityCheck: true, + CancellationToken.None); + + Assert.Equal(AspireSkillsInstaller.Version, bundle.Version); + } + finally + { + Directory.Delete(bundleDirectory, recursive: true); + } + } + + [Fact] + public async Task LoadAsync_SkipCompatibilityCheck_StillRejectsOtherInvariants() + { + var bundleDirectory = CreateTempDirectory(); + + try + { + await CreateBundleAsync( + bundleDirectory, + new Dictionary { ["SKILL.md"] = CreateSkillFileContent() }); + + // Truncate the bundled SKILL.md so the SHA-256 in the manifest no longer matches. + // The compatibility skip must not bypass content verification. + var skillPath = Path.Combine(bundleDirectory, "skills", CommonAgentApplicators.AspireSkillName, "SKILL.md"); + await File.WriteAllTextAsync(skillPath, "tampered"); + + await Assert.ThrowsAsync(() => AspireSkillsBundle.LoadAsync( + new DirectoryInfo(bundleDirectory), + currentCliVersion: "13.5.0", + currentSdkVersion: "13.5.0", + skipCompatibilityCheck: true, + CancellationToken.None)); + } + finally + { + Directory.Delete(bundleDirectory, recursive: true); + } + } + private static async Task CreateBundleAsync( string bundleDirectory, Dictionary files, string? hashOverride = null, SkillBundleSupports? supports = null) { - var skillDirectory = Path.Combine(bundleDirectory, "skills", SkillDefinition.Aspire.Name); + var skillDirectory = Path.Combine(bundleDirectory, "skills", CommonAgentApplicators.AspireSkillName); Directory.CreateDirectory(skillDirectory); foreach (var (relativePath, content) in files) @@ -295,9 +422,8 @@ private static async Task CreateBundleAsync( [ new SkillBundleSkill { - Name = SkillDefinition.Aspire.Name, - Description = SkillDefinition.Aspire.Description, - IsDefault = true, + Name = CommonAgentApplicators.AspireSkillName, + Description = AspireSkillDescription, InstallExcludedRelativePaths = ["evals"], Files = files .Select(file => new SkillBundleFile @@ -322,6 +448,30 @@ private static SkillBundleSupports CreateSupports() }; } + private static async Task WriteSkillAsync(string bundleDirectory, string skillName, string content) + { + var skillDirectory = Path.Combine(bundleDirectory, "skills", skillName); + Directory.CreateDirectory(skillDirectory); + await File.WriteAllTextAsync(Path.Combine(skillDirectory, "SKILL.md"), content); + } + + private static SkillBundleSkill CreateManifestSkill(string bundleDirectory, string skillName, string description) + { + return new SkillBundleSkill + { + Name = skillName, + Description = description, + Files = + [ + new SkillBundleFile + { + RelativePath = "SKILL.md", + Sha256 = ComputeSha256(Path.Combine(bundleDirectory, "skills", skillName, "SKILL.md")) + } + ] + }; + } + private static Task WriteManifestAsync(string bundleDirectory, SkillBundleManifest manifest) { var manifestJson = JsonSerializer.Serialize(manifest, AspireSkillsJsonSerializerContext.Default.SkillBundleManifest); diff --git a/tests/Aspire.Cli.Tests/Agents/AspireSkillsInstallerTests.cs b/tests/Aspire.Cli.Tests/Agents/AspireSkillsInstallerTests.cs index 3b418f7cc99..95b1f0da049 100644 --- a/tests/Aspire.Cli.Tests/Agents/AspireSkillsInstallerTests.cs +++ b/tests/Aspire.Cli.Tests/Agents/AspireSkillsInstallerTests.cs @@ -8,6 +8,7 @@ using System.Text.Json; using Aspire.Cli.Agents; using Aspire.Cli.Agents.AspireSkills; +using Aspire.Cli.Configuration; using Aspire.Cli.Npm; using Aspire.Cli.Tests.Telemetry; using Aspire.Cli.Tests.TestServices; @@ -19,6 +20,8 @@ namespace Aspire.Cli.Tests.Agents; public class AspireSkillsInstallerTests { + private const string AspireSkillDescription = "Aspire CLI commands and workflows for distributed apps"; + private const string GitHubReleaseAssetBuildType = "https://actions.github.io/buildtypes/workflow/v1"; [Fact] @@ -114,6 +117,70 @@ public async Task InstallAsync_WhenGitHubReleaseIsUnavailableAndEmbeddedBundleMa } } + [Fact] + public async Task InstallAsync_WhenRemoteFetchFeatureIsDisabled_SkipsGitHubAndUsesEmbedded() + { + var rootDirectory = CreateTempDirectory(); + + try + { + var executionContext = TestExecutionContextHelper.CreateExecutionContext(new DirectoryInfo(rootDirectory)); + var embeddedBundleProvider = await CreateEmbeddedBundleProviderAsync(); + var attestationVerifier = new TestGitHubArtifactAttestationVerifier(); + // Throw on any HTTP call so we can prove the GitHub path was never invoked. + var handler = new MockHttpMessageHandler(_ => throw new InvalidOperationException("HTTP must not be called when remote fetch is disabled.")); + var features = new TestFeatures().SetFeature(KnownFeatures.AspireSkillsRemoteFetchEnabled, false); + var installer = CreateInstaller( + executionContext, + httpMessageHandler: handler, + githubArtifactAttestationVerifier: attestationVerifier, + embeddedBundleProvider: embeddedBundleProvider, + features: features); + + var result = await installer.InstallAsync(CancellationToken.None); + + Assert.Equal(AspireSkillsInstallStatus.Installed, result.Status); + Assert.NotNull(result.Bundle); + Assert.True(embeddedBundleProvider.OpenArchiveCalled); + Assert.False(attestationVerifier.VerifyCalled); + } + finally + { + Directory.Delete(rootDirectory, recursive: true); + } + } + + [Fact] + public async Task InstallAsync_WhenRemoteFetchFeatureIsDisabledAndCacheExists_UsesCacheWithoutNetwork() + { + var rootDirectory = CreateTempDirectory(); + + try + { + var executionContext = TestExecutionContextHelper.CreateExecutionContext(new DirectoryInfo(rootDirectory)); + var cachedBundleDirectory = Path.Combine(executionContext.CacheDirectory.FullName, "aspire-skills", AspireSkillsInstaller.Version); + await CreateCachedBundleAsync(cachedBundleDirectory); + var attestationVerifier = new TestGitHubArtifactAttestationVerifier(); + var handler = new MockHttpMessageHandler(_ => throw new InvalidOperationException("HTTP must not be called when cache is used.")); + var features = new TestFeatures().SetFeature(KnownFeatures.AspireSkillsRemoteFetchEnabled, false); + var installer = CreateInstaller( + executionContext, + httpMessageHandler: handler, + githubArtifactAttestationVerifier: attestationVerifier, + features: features); + + var result = await installer.InstallAsync(CancellationToken.None); + + Assert.Equal(AspireSkillsInstallStatus.Installed, result.Status); + Assert.NotNull(result.Bundle); + Assert.False(attestationVerifier.VerifyCalled); + } + finally + { + Directory.Delete(rootDirectory, recursive: true); + } + } + [Fact] public void EmbeddedAspireSkillsBundleProvider_OpensSnapshotResource() { @@ -298,12 +365,82 @@ public async Task InstallAsync_WhenEmbeddedArchiveHashDoesNotMatch_ReturnsFailur } } + [Fact] + public async Task InstallAsync_WhenEmbeddedBundleSupportsRangeExcludesCurrentCli_StillUsesEmbeddedBundle() + { + var rootDirectory = CreateTempDirectory(); + + try + { + // Simulate a CLI prerelease whose version falls outside the embedded snapshot's + // declared `supports` range (e.g., a 13.5.x dogfood build paired with a snapshot + // stamped ">=13.4.0 <13.5.0"). The embedded path must still install the bundle — + // otherwise an offline user with a version-mismatched embedded snapshot would lose + // access to all bundled skills. + var staleSupports = new SkillBundleSupports + { + AspireCli = ">=0.0.1 <0.0.2", + AspireSdk = ">=0.0.1 <0.0.2" + }; + var executionContext = TestExecutionContextHelper.CreateExecutionContext(new DirectoryInfo(rootDirectory)); + var embeddedBundleProvider = await CreateEmbeddedBundleProviderAsync(supports: staleSupports); + var installer = CreateInstaller(executionContext, embeddedBundleProvider: embeddedBundleProvider); + + var result = await installer.InstallAsync(CancellationToken.None); + + Assert.Equal(AspireSkillsInstallStatus.Installed, result.Status); + Assert.NotNull(result.Bundle); + Assert.True(embeddedBundleProvider.OpenArchiveCalled); + } + finally + { + Directory.Delete(rootDirectory, recursive: true); + } + } + + [Fact] + public async Task InstallAsync_WhenCachedBundleSupportsRangeExcludesCurrentCli_StillUsesCache() + { + var rootDirectory = CreateTempDirectory(); + + try + { + var executionContext = TestExecutionContextHelper.CreateExecutionContext(new DirectoryInfo(rootDirectory)); + var cachedBundleDirectory = Path.Combine(executionContext.CacheDirectory.FullName, "aspire-skills", AspireSkillsInstaller.Version); + + // The cache is written by the installer itself (either from GitHub or from the + // embedded snapshot), so the bundle's `supports` range is not the right + // invalidation signal — bundle version is. A stale `supports` on a cached entry + // must not force a re-install on every invocation. + await CreateCachedBundleAsync( + cachedBundleDirectory, + supports: new SkillBundleSupports + { + AspireCli = ">=0.0.1 <0.0.2", + AspireSdk = ">=0.0.1 <0.0.2" + }); + var embeddedBundleProvider = await CreateEmbeddedBundleProviderAsync(); + var installer = CreateInstaller(executionContext, embeddedBundleProvider: embeddedBundleProvider); + + var result = await installer.InstallAsync(CancellationToken.None); + + Assert.Equal(AspireSkillsInstallStatus.Installed, result.Status); + Assert.NotNull(result.Bundle); + Assert.False(embeddedBundleProvider.OpenArchiveCalled); + } + finally + { + Directory.Delete(rootDirectory, recursive: true); + } + } + private static AspireSkillsInstaller CreateInstaller( CliExecutionContext executionContext, HttpMessageHandler? httpMessageHandler = null, TestGitHubArtifactAttestationVerifier? githubArtifactAttestationVerifier = null, IConfiguration? configuration = null, - IEmbeddedAspireSkillsBundleProvider? embeddedBundleProvider = null) + IEmbeddedAspireSkillsBundleProvider? embeddedBundleProvider = null, + IFeatures? features = null) { return new AspireSkillsInstaller( githubArtifactAttestationVerifier ?? new TestGitHubArtifactAttestationVerifier(), @@ -312,13 +449,17 @@ private static AspireSkillsInstaller CreateInstaller( new TestInteractionService(), executionContext, configuration ?? new ConfigurationBuilder().Build(), + // Default existing tests to the remote-fetch-enabled path so they continue to + // exercise the GitHub flow without per-test boilerplate. Tests that want to + // exercise the production default (flag off) pass an empty TestFeatures. + features ?? new TestFeatures().SetFeature(KnownFeatures.AspireSkillsRemoteFetchEnabled, true), TestTelemetryHelper.CreateInitializedTelemetry(), NullLogger.Instance); } - private static async Task CreateCachedBundleAsync(string bundleDirectory) + private static async Task CreateCachedBundleAsync(string bundleDirectory, SkillBundleSupports? supports = null) { - var skillDirectory = Path.Combine(bundleDirectory, "skills", SkillDefinition.Aspire.Name); + var skillDirectory = Path.Combine(bundleDirectory, "skills", CommonAgentApplicators.AspireSkillName); Directory.CreateDirectory(skillDirectory); var skillPath = Path.Combine(skillDirectory, "SKILL.md"); @@ -335,14 +476,13 @@ await File.WriteAllTextAsync(skillPath, var manifest = new SkillBundleManifest { Version = AspireSkillsInstaller.Version, - Supports = CreateSupports(), + Supports = supports ?? CreateSupports(), Skills = [ new SkillBundleSkill { - Name = SkillDefinition.Aspire.Name, - Description = SkillDefinition.Aspire.Description, - IsDefault = true, + Name = CommonAgentApplicators.AspireSkillName, + Description = AspireSkillDescription, Files = [ new SkillBundleFile @@ -368,14 +508,14 @@ private static SkillBundleSupports CreateSupports() }; } - private static async Task CreateBundleArchiveBytesAsync() + private static async Task CreateBundleArchiveBytesAsync(SkillBundleSupports? supports = null) { var rootDirectory = CreateTempDirectory(); try { var bundleDirectory = Path.Combine(rootDirectory, $"aspire-skills-v{AspireSkillsInstaller.Version}"); - await CreateCachedBundleAsync(bundleDirectory); + await CreateCachedBundleAsync(bundleDirectory, supports); await using var archiveStream = new MemoryStream(); await using (var gzipStream = new GZipStream(archiveStream, CompressionLevel.SmallestSize, leaveOpen: true)) @@ -402,9 +542,9 @@ private static string ComputeSha256(byte[] bytes) return Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant(); } - private static async Task CreateEmbeddedBundleProviderAsync() + private static async Task CreateEmbeddedBundleProviderAsync(SkillBundleSupports? supports = null) { - var archiveBytes = await CreateBundleArchiveBytesAsync(); + var archiveBytes = await CreateBundleArchiveBytesAsync(supports); return new TestEmbeddedAspireSkillsBundleProvider { Metadata = new EmbeddedAspireSkillsBundleMetadata diff --git a/tests/Aspire.Cli.Tests/Agents/CommonAgentApplicatorsTests.cs b/tests/Aspire.Cli.Tests/Agents/CommonAgentApplicatorsTests.cs index 36a46c10e5e..3c0fea948d3 100644 --- a/tests/Aspire.Cli.Tests/Agents/CommonAgentApplicatorsTests.cs +++ b/tests/Aspire.Cli.Tests/Agents/CommonAgentApplicatorsTests.cs @@ -46,45 +46,36 @@ public void SkillLocation_OnlyStandardIsDefault() } [Fact] - public void SkillDefinition_All_ContainsExpectedSkills() + public void SkillDefinition_CliDefined_ContainsExpectedSkills() { - Assert.Equal(5, SkillDefinition.All.Count); - Assert.Contains(SkillDefinition.All, s => s == SkillDefinition.Aspire); - Assert.Contains(SkillDefinition.All, s => s == SkillDefinition.AspireDeployment); - Assert.Contains(SkillDefinition.All, s => s == SkillDefinition.Aspireify); - Assert.Contains(SkillDefinition.All, s => s == SkillDefinition.PlaywrightCli); - Assert.Contains(SkillDefinition.All, s => s == SkillDefinition.DotnetInspect); + Assert.Equal(2, SkillDefinition.CliDefined.Count); + Assert.Contains(SkillDefinition.CliDefined, s => s == SkillDefinition.PlaywrightCli); + Assert.Contains(SkillDefinition.CliDefined, s => s == SkillDefinition.DotnetInspect); } [Fact] - public void SkillDefinition_DefaultSkills() + public void SkillDefinition_CliDefinedSkills_AreNotDefault() { - Assert.True(SkillDefinition.Aspire.IsDefault); - Assert.True(SkillDefinition.AspireDeployment.IsDefault); - Assert.True(SkillDefinition.Aspireify.IsDefault); - Assert.False(SkillDefinition.PlaywrightCli.IsDefault); - Assert.False(SkillDefinition.DotnetInspect.IsDefault); + Assert.All(SkillDefinition.CliDefined, static skill => Assert.False(skill.IsDefault)); } [Fact] public void SkillDefinition_DotnetInspect_IsRestrictedToCSharp() { Assert.Equal([KnownLanguageId.CSharp], SkillDefinition.DotnetInspect.ApplicableLanguages); - Assert.Empty(SkillDefinition.Aspire.ApplicableLanguages); - Assert.Empty(SkillDefinition.AspireDeployment.ApplicableLanguages); - Assert.Empty(SkillDefinition.Aspireify.ApplicableLanguages); Assert.Empty(SkillDefinition.PlaywrightCli.ApplicableLanguages); } [Fact] public void SkillDefinition_IsApplicableToLanguage_EmptyApplicableLanguages_AlwaysTrue() { - Assert.True(SkillDefinition.Aspire.IsApplicableToLanguage(null)); - Assert.True(SkillDefinition.Aspire.IsApplicableToLanguage(new LanguageId(KnownLanguageId.CSharp))); - Assert.True(SkillDefinition.Aspire.IsApplicableToLanguage(new LanguageId(KnownLanguageId.TypeScript))); - Assert.True(SkillDefinition.AspireDeployment.IsApplicableToLanguage(null)); - Assert.True(SkillDefinition.AspireDeployment.IsApplicableToLanguage(new LanguageId(KnownLanguageId.CSharp))); - Assert.True(SkillDefinition.AspireDeployment.IsApplicableToLanguage(new LanguageId(KnownLanguageId.TypeScript))); + var bundleSkill = SkillDefinition.CreateAspireSkillsBundle( + "aspire-monitoring", + "Observe Aspire apps with logs, traces, metrics, and resource state"); + + Assert.True(bundleSkill.IsApplicableToLanguage(null)); + Assert.True(bundleSkill.IsApplicableToLanguage(new LanguageId(KnownLanguageId.CSharp))); + Assert.True(bundleSkill.IsApplicableToLanguage(new LanguageId(KnownLanguageId.TypeScript))); } [Fact] @@ -106,10 +97,14 @@ public void SkillDefinition_PlaywrightCli_HasNoSkillContent() } [Fact] - public void SkillDefinition_AspireWorkflowSkills_AreExternallySourced() + public void SkillDefinition_BundleSkills_AreExternallySourced() { Assert.All( - [SkillDefinition.Aspire, SkillDefinition.Aspireify, SkillDefinition.AspireDeployment], + [ + SkillDefinition.CreateAspireSkillsBundle(CommonAgentApplicators.AspireSkillName, "Aspire CLI commands and workflows for distributed apps"), + SkillDefinition.CreateAspireSkillsBundle(CommonAgentApplicators.AspireifySkillName, "One-time setup: wire up AppHost with discovered projects"), + SkillDefinition.CreateAspireSkillsBundle(CommonAgentApplicators.AspireDeploymentSkillName, "Aspire deployment target selection, preflight, publish, and deploy workflows") + ], skill => { Assert.Null(skill.SkillContent); @@ -121,7 +116,7 @@ public void SkillDefinition_AspireWorkflowSkills_AreExternallySourced() [Fact] public async Task SkillDefinition_StaticInstallableSkillDescriptionsFitAgentHostLimits() { - var installableSkills = SkillDefinition.All + var installableSkills = SkillDefinition.CliDefined .Where(static skill => skill.SkillContent is not null); foreach (var skill in installableSkills) @@ -139,11 +134,16 @@ public async Task SkillDefinition_StaticInstallableSkillDescriptionsFitAgentHost } [Fact] - public void SkillDefinition_Aspire_ExcludesEvalsFromInstall() + public void SkillDefinition_BundleSkill_ExcludesManifestPathsFromInstall() { - Assert.Contains(SkillDefinition.Aspire.InstallExcludedRelativePaths, path => path == Path.Combine("evals")); - Assert.False(SkillDefinition.Aspire.ShouldInstallFile(Path.Combine("evals", "evals.json"))); - Assert.True(SkillDefinition.Aspire.ShouldInstallFile("SKILL.md")); + var bundleSkill = SkillDefinition.CreateAspireSkillsBundle( + CommonAgentApplicators.AspireSkillName, + "Aspire CLI commands and workflows for distributed apps", + installExcludedRelativePaths: [Path.Combine("evals")]); + + Assert.Contains(bundleSkill.InstallExcludedRelativePaths, path => path == Path.Combine("evals")); + Assert.False(bundleSkill.ShouldInstallFile(Path.Combine("evals", "evals.json"))); + Assert.True(bundleSkill.ShouldInstallFile("SKILL.md")); } [Fact] diff --git a/tests/Aspire.Cli.Tests/Commands/AgentInitCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/AgentInitCommandTests.cs index ace28bcd5c4..02dd72d738a 100644 --- a/tests/Aspire.Cli.Tests/Commands/AgentInitCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/AgentInitCommandTests.cs @@ -2,6 +2,8 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Globalization; +using System.Security.Cryptography; +using System.Text.Json; using Aspire.Cli.Agents; using Aspire.Cli.Agents.AspireSkills; using Aspire.Cli.Commands; @@ -27,7 +29,7 @@ public async Task AgentInitCommand_SummarizesNormalizedDisplayPath_WhenInstallin .Where(choice => choice switch { SkillLocation location => location == SkillLocation.Standard, - SkillDefinition skill => skill == SkillDefinition.Aspire, + SkillDefinition skill => skill.HasName(CommonAgentApplicators.AspireSkillName), _ => false }) .ToList(); @@ -46,7 +48,7 @@ public async Task AgentInitCommand_SummarizesNormalizedDisplayPath_WhenInstallin Assert.Equal(0, exitCode); var expectedSummary = string.Join(Environment.NewLine, AgentCommandStrings.InitCommand_InstalledSkillsSummary, - $" {string.Format(CultureInfo.CurrentCulture, AgentCommandStrings.InitCommand_InstalledSkillsSummarySkills, SkillDefinition.Aspire.Name)}", + $" {string.Format(CultureInfo.CurrentCulture, AgentCommandStrings.InitCommand_InstalledSkillsSummarySkills, CommonAgentApplicators.AspireSkillName)}", $" {string.Format(CultureInfo.CurrentCulture, AgentCommandStrings.InitCommand_InstalledSkillsSummaryLocations, ".agents/skills, ~/.agents/skills")}"); Assert.Contains( @@ -64,14 +66,6 @@ public async Task AgentInitCommand_SummarizesDefaultSkillsOnce() var homeDirectory = workspace.CreateDirectory("fake-home"); var interactionService = new TestInteractionService(); interactionService.SetupStringPromptResponse(workspace.WorkspaceRoot.FullName); - interactionService.PromptForSelectionsCallback = (_, choices, _, _) => choices.Cast() - .Where(choice => choice switch - { - SkillLocation location => location == SkillLocation.Standard, - SkillDefinition skill => skill.IsDefault, - _ => false - }) - .ToList(); var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => { options.InteractionServiceFactory = _ => interactionService; @@ -91,7 +85,7 @@ public async Task AgentInitCommand_SummarizesDefaultSkillsOnce() $" {string.Format( CultureInfo.CurrentCulture, AgentCommandStrings.InitCommand_InstalledSkillsSummarySkills, - string.Join(", ", SkillDefinition.All.Where(static skill => skill.IsDefault).Select(static skill => skill.Name)))}", + $"{CommonAgentApplicators.AspireSkillName}, {CommonAgentApplicators.AspireDeploymentSkillName}, {FakeAspireSkillsInstaller.AspireInitSkillName}, {FakeAspireSkillsInstaller.AspireMonitoringSkillName}, {FakeAspireSkillsInstaller.AspireOrchestrationSkillName}")}", $" {string.Format(CultureInfo.CurrentCulture, AgentCommandStrings.InitCommand_InstalledSkillsSummaryLocations, ".agents/skills, ~/.agents/skills")}"); var message = Assert.Single(interactionService.DisplayedMessages, displayedMessage => displayedMessage.Emoji.Equals(KnownEmojis.Robot)); Assert.Equal(expectedSummary, message.Message); @@ -113,7 +107,7 @@ public async Task AgentInitCommand_IncludesSpecificSkillDirectory_WhenInstallFai .Where(choice => choice switch { SkillLocation location => location == SkillLocation.Standard, - SkillDefinition skill => skill == SkillDefinition.Aspire, + SkillDefinition skill => skill.HasName(CommonAgentApplicators.AspireSkillName), _ => false }) .ToList(); @@ -130,7 +124,7 @@ public async Task AgentInitCommand_IncludesSpecificSkillDirectory_WhenInstallFai Assert.Equal(CliExitCodes.InvalidCommand, exitCode); - var expectedSkillDirectoryPath = Path.Combine(invalidRootFilePath, ".agents", "skills", SkillDefinition.Aspire.Name); + var expectedSkillDirectoryPath = Path.Combine(invalidRootFilePath, ".agents", "skills", CommonAgentApplicators.AspireSkillName); Assert.Contains( interactionService.DisplayedErrors, message => message.Contains(expectedSkillDirectoryPath, StringComparison.Ordinal)); @@ -152,19 +146,312 @@ public async Task AgentInitCommand_NonInteractive_WithAllLocationsAndSkills_Inst // Exit code is InvalidCommand because FakeNpmRunner cannot resolve Playwright CLI in tests. Assert.Equal(CliExitCodes.InvalidCommand, exitCode); - // Verify that the Aspire skills were installed to all locations - Assert.True(File.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, ".agents", "skills", "aspire", "SKILL.md"))); - Assert.True(File.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, ".agents", "skills", "aspireify", "SKILL.md"))); - Assert.True(File.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, ".agents", "skills", "aspire-deployment", "SKILL.md"))); - Assert.True(File.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, ".claude", "skills", "aspire", "SKILL.md"))); - Assert.True(File.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, ".claude", "skills", "aspireify", "SKILL.md"))); - Assert.True(File.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, ".claude", "skills", "aspire-deployment", "SKILL.md"))); - Assert.True(File.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, ".github", "skills", "aspire", "SKILL.md"))); - Assert.True(File.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, ".github", "skills", "aspireify", "SKILL.md"))); - Assert.True(File.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, ".github", "skills", "aspire-deployment", "SKILL.md"))); - Assert.True(File.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, ".opencode", "skill", "aspire", "SKILL.md"))); - Assert.True(File.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, ".opencode", "skill", "aspireify", "SKILL.md"))); - Assert.True(File.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, ".opencode", "skill", "aspire-deployment", "SKILL.md"))); + var expectedSkillNames = new[] + { + CommonAgentApplicators.AspireSkillName, + CommonAgentApplicators.AspireifySkillName, + CommonAgentApplicators.AspireDeploymentSkillName, + FakeAspireSkillsInstaller.AspireInitSkillName, + FakeAspireSkillsInstaller.AspireMonitoringSkillName, + FakeAspireSkillsInstaller.AspireOrchestrationSkillName + }; + var expectedSkillDirectories = new[] + { + Path.Combine(".agents", "skills"), + Path.Combine(".claude", "skills"), + Path.Combine(".github", "skills"), + Path.Combine(".opencode", "skill") + }; + + foreach (var relativeSkillDirectory in expectedSkillDirectories) + { + foreach (var skillName in expectedSkillNames) + { + AssertSkillFileExists(workspace.WorkspaceRoot, relativeSkillDirectory, skillName); + } + } + } + + [Fact] + public async Task AgentInitCommand_InteractiveSkillPrompt_IncludesAllBundleSkills() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var promptedSkillNames = new List(); + var interactionService = new TestInteractionService(); + interactionService.SetupStringPromptResponse(workspace.WorkspaceRoot.FullName); + interactionService.PromptForSelectionsCallback = (_, choices, _, _) => + { + var items = choices.Cast().ToList(); + if (items.FirstOrDefault() is SkillLocation) + { + return [SkillLocation.Standard]; + } + + promptedSkillNames.AddRange(items.OfType().Select(static skill => skill.Name)); + return []; + }; + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.InteractionServiceFactory = _ => interactionService; + }); + using var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("agent init"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(CliExitCodes.Success, exitCode); + Assert.Contains(CommonAgentApplicators.AspireSkillName, promptedSkillNames); + Assert.Contains(CommonAgentApplicators.AspireifySkillName, promptedSkillNames); + Assert.Contains(CommonAgentApplicators.AspireDeploymentSkillName, promptedSkillNames); + Assert.Contains(FakeAspireSkillsInstaller.AspireInitSkillName, promptedSkillNames); + Assert.Contains(FakeAspireSkillsInstaller.AspireMonitoringSkillName, promptedSkillNames); + Assert.Contains(FakeAspireSkillsInstaller.AspireOrchestrationSkillName, promptedSkillNames); + } + + [Fact] + public async Task AgentInitCommand_InteractiveSkillPrompt_EscapesBundleDescriptions() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var bundle = await CreateBundleAsync( + workspace.WorkspaceRoot, + (FakeAspireSkillsInstaller.AspireMonitoringSkillName, "Observe [danger] apps")); + string? formattedSkill = null; + var interactionService = new TestInteractionService(); + interactionService.SetupStringPromptResponse(workspace.WorkspaceRoot.FullName); + interactionService.PromptForSelectionsCallback = (_, choices, formatter, _) => + { + var items = choices.Cast().ToList(); + if (items.FirstOrDefault() is SkillLocation) + { + return [SkillLocation.Standard]; + } + + var skill = Assert.Single(items.OfType(), static skill => skill.HasName(FakeAspireSkillsInstaller.AspireMonitoringSkillName)); + formattedSkill = formatter(skill); + return []; + }; + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.InteractionServiceFactory = _ => interactionService; + options.AspireSkillsInstallerFactory = serviceProvider => new FakeAspireSkillsInstaller( + serviceProvider.GetRequiredService(), + AspireSkillsInstallResult.Installed(bundle)); + }); + using var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("agent init"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(CliExitCodes.Success, exitCode); + Assert.NotNull(formattedSkill); + Assert.Contains("Observe [[danger]] apps", formattedSkill); + } + + [Theory] + [InlineData("", "")] + [InlineData(" ", " ")] + [InlineData("Short description", "Short description")] + [InlineData("Short description.", "Short description.")] + [InlineData("First sentence. Second sentence.", "First sentence.")] + [InlineData("**WORKFLOW SKILL** - Top-level router for Aspire 13.4 distributed apps. Detects the AppHost.", "Top-level router for Aspire 13.4 distributed apps.")] + [InlineData("**ANALYSIS SKILL** — Observe Aspire apps. USE FOR: aspire logs.", "Observe Aspire apps.")] + [InlineData("**SETUP SKILL**: One-time setup of resources. INVOKES: aspire add.", "One-time setup of resources.")] + [InlineData("Visit github.com for docs. Then run the tool.", "Visit github.com for docs.")] + [InlineData("**TYPE** -", "")] + // Fix 1 regression: a leading separator that does NOT follow a "**TYPE**" prefix must be preserved. + // The earlier implementation unconditionally trimmed leading separators after the bold-prefix + // branch, which silently mutated bundle descriptions that happened to start with '-' or ':'. + [InlineData("-Quickly do X.", "-Quickly do X.")] + [InlineData(":memo notes", ":memo notes")] + public void SimplifyDescription_ProducesExpectedSummary(string input, string expected) + { + Assert.Equal(expected, AgentInitCommand.SimplifyDescription(input)); + } + + [Fact] + public async Task AgentInitCommand_InteractiveSkillPrompt_StripsVerboseBundleDescription() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var bundle = await CreateBundleAsync( + workspace.WorkspaceRoot, + (FakeAspireSkillsInstaller.AspireMonitoringSkillName, + "**ANALYSIS SKILL** - Observe Aspire apps. USE FOR: aspire logs, aspire traces. INVOKES: aspire CLI.")); + string? formattedSkill = null; + var interactionService = new TestInteractionService(); + interactionService.SetupStringPromptResponse(workspace.WorkspaceRoot.FullName); + interactionService.PromptForSelectionsCallback = (_, choices, formatter, _) => + { + var items = choices.Cast().ToList(); + if (items.FirstOrDefault() is SkillLocation) + { + return [SkillLocation.Standard]; + } + + var skill = Assert.Single(items.OfType(), static skill => skill.HasName(FakeAspireSkillsInstaller.AspireMonitoringSkillName)); + formattedSkill = formatter(skill); + return []; + }; + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.InteractionServiceFactory = _ => interactionService; + options.AspireSkillsInstallerFactory = serviceProvider => new FakeAspireSkillsInstaller( + serviceProvider.GetRequiredService(), + AspireSkillsInstallResult.Installed(bundle)); + }); + using var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("agent init"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(CliExitCodes.Success, exitCode); + Assert.NotNull(formattedSkill); + Assert.Contains("Observe Aspire apps.", formattedSkill); + Assert.DoesNotContain("ANALYSIS SKILL", formattedSkill); + Assert.DoesNotContain("USE FOR", formattedSkill); + Assert.DoesNotContain("INVOKES", formattedSkill); + } + + [Fact] + public async Task AgentInitCommand_InteractiveSkillPrompt_OrdersSkillsAlphabetically() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + // Intentionally pass bundle skills in non-alphabetical order to confirm the prompt sorts deterministically. + var bundle = await CreateBundleAsync( + workspace.WorkspaceRoot, + ("zeta-bundle-skill", "Zeta skill"), + ("alpha-bundle-skill", "Alpha skill"), + ("middle-bundle-skill", "Middle skill")); + var promptedSkillNames = new List(); + var interactionService = new TestInteractionService(); + interactionService.SetupStringPromptResponse(workspace.WorkspaceRoot.FullName); + interactionService.PromptForSelectionsCallback = (_, choices, _, _) => + { + var items = choices.Cast().ToList(); + if (items.FirstOrDefault() is SkillLocation) + { + return [SkillLocation.Standard]; + } + + promptedSkillNames.AddRange(items.OfType().Select(static skill => skill.Name)); + return []; + }; + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.InteractionServiceFactory = _ => interactionService; + options.AspireSkillsInstallerFactory = serviceProvider => new FakeAspireSkillsInstaller( + serviceProvider.GetRequiredService(), + AspireSkillsInstallResult.Installed(bundle)); + }); + using var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("agent init"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(CliExitCodes.Success, exitCode); + Assert.NotEmpty(promptedSkillNames); + var sorted = promptedSkillNames.OrderBy(static name => name, StringComparer.OrdinalIgnoreCase).ToList(); + Assert.Equal(sorted, promptedSkillNames); + } + + [Fact] + public async Task AgentInitCommand_NonInteractive_WithSpecificBundleSkill_InstallsSkillFiles() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + using var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse($"agent init --workspace-root {workspace.WorkspaceRoot.FullName} --skill-locations all --skills {FakeAspireSkillsInstaller.AspireMonitoringSkillName}"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(CliExitCodes.Success, exitCode); + AssertSkillFileExists(workspace.WorkspaceRoot, Path.Combine(".agents", "skills"), FakeAspireSkillsInstaller.AspireMonitoringSkillName); + AssertSkillFileExists(workspace.WorkspaceRoot, Path.Combine(".claude", "skills"), FakeAspireSkillsInstaller.AspireMonitoringSkillName); + AssertSkillFileExists(workspace.WorkspaceRoot, Path.Combine(".github", "skills"), FakeAspireSkillsInstaller.AspireMonitoringSkillName); + AssertSkillFileExists(workspace.WorkspaceRoot, Path.Combine(".opencode", "skill"), FakeAspireSkillsInstaller.AspireMonitoringSkillName); + } + + [Fact] + public async Task AgentInitCommand_NonInteractive_WithCliDefinedSkillDifferentCasing_DoesNotResolveBundle() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + const string installFailureMessage = "Aspire skills bundle is unavailable."; + var interactionService = new TestInteractionService(); + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.InteractionServiceFactory = _ => interactionService; + options.AspireSkillsInstallerFactory = serviceProvider => new FakeAspireSkillsInstaller( + serviceProvider.GetRequiredService(), + AspireSkillsInstallResult.Failed(installFailureMessage)); + }); + using var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse($"agent init --workspace-root {workspace.WorkspaceRoot.FullName} --skill-locations all --skills PLAYWRIGHT-CLI"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(CliExitCodes.Success, exitCode); + Assert.DoesNotContain( + interactionService.DisplayedMessages, + message => message.Emoji.Equals(KnownEmojis.Warning) && message.Message == installFailureMessage); + } + + [Fact] + public async Task AgentInitCommand_InteractiveSkillPrompt_CliDefinedSkillsWinBundleNameCollisions() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var bundle = await CreateBundleAsync( + workspace.WorkspaceRoot, + (CommonAgentApplicators.AspireSkillName, "Aspire CLI commands and workflows for distributed apps"), + (SkillDefinition.PlaywrightCli.Name, "Bundle-provided Playwright collision")); + var promptedSkills = new List(); + var interactionService = new TestInteractionService(); + interactionService.SetupStringPromptResponse(workspace.WorkspaceRoot.FullName); + interactionService.PromptForSelectionsCallback = (_, choices, _, _) => + { + var items = choices.Cast().ToList(); + if (items.FirstOrDefault() is SkillLocation) + { + return [SkillLocation.Standard]; + } + + promptedSkills.AddRange(items.OfType()); + return []; + }; + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.InteractionServiceFactory = _ => interactionService; + options.AspireSkillsInstallerFactory = serviceProvider => new FakeAspireSkillsInstaller( + serviceProvider.GetRequiredService(), + AspireSkillsInstallResult.Installed(bundle)); + }); + using var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("agent init"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(CliExitCodes.Success, exitCode); + var playwrightSkill = Assert.Single(promptedSkills, static skill => skill.HasName(SkillDefinition.PlaywrightCli.Name, StringComparison.OrdinalIgnoreCase)); + Assert.Same(SkillDefinition.PlaywrightCli, playwrightSkill); } [Fact] @@ -216,16 +503,81 @@ public async Task AgentInitCommand_NonInteractive_WithoutSkills_UsesDefaultSkill var exitCode = await result.InvokeAsync().DefaultTimeout(); - // Default Aspire skills are installed. Playwright is not default so it is not selected. + // Default Aspire skills are installed (all bundle skills except the one-time setup skill). + // Aspireify is filtered out by ExcludeOneTimeSetupSkillsFromDefaults; Playwright is + // a CLI-defined skill that is not default. Assert.Equal(CliExitCodes.Success, exitCode); - // Verify the default Aspire skills were installed - var aspireSkillPath = Path.Combine(workspace.WorkspaceRoot.FullName, ".agents", "skills", "aspire", "SKILL.md"); - Assert.True(File.Exists(aspireSkillPath), $"Expected skill file at {aspireSkillPath}"); - var aspireifySkillPath = Path.Combine(workspace.WorkspaceRoot.FullName, ".agents", "skills", "aspireify", "SKILL.md"); - Assert.True(File.Exists(aspireifySkillPath), $"Expected skill file at {aspireifySkillPath}"); - var deploymentSkillPath = Path.Combine(workspace.WorkspaceRoot.FullName, ".agents", "skills", "aspire-deployment", "SKILL.md"); - Assert.True(File.Exists(deploymentSkillPath), $"Expected skill file at {deploymentSkillPath}"); + AssertSkillFileExists(workspace.WorkspaceRoot, Path.Combine(".agents", "skills"), CommonAgentApplicators.AspireSkillName); + AssertSkillFileExists(workspace.WorkspaceRoot, Path.Combine(".agents", "skills"), CommonAgentApplicators.AspireDeploymentSkillName); + AssertSkillFileExists(workspace.WorkspaceRoot, Path.Combine(".agents", "skills"), FakeAspireSkillsInstaller.AspireInitSkillName); + AssertSkillFileExists(workspace.WorkspaceRoot, Path.Combine(".agents", "skills"), FakeAspireSkillsInstaller.AspireMonitoringSkillName); + AssertSkillFileExists(workspace.WorkspaceRoot, Path.Combine(".agents", "skills"), FakeAspireSkillsInstaller.AspireOrchestrationSkillName); + var aspireifySkillPath = Path.Combine(workspace.WorkspaceRoot.FullName, ".agents", "skills", CommonAgentApplicators.AspireifySkillName); + Assert.False(Directory.Exists(aspireifySkillPath), $"Expected no aspireify skill directory but found {aspireifySkillPath}"); + } + + [Fact] + public async Task AgentInitCommand_NonInteractive_AllBundleSkills_AreInstallableByName() + { + // Regression guard for the original issue: the bundle ships skills (aspire-init, + // aspire-monitoring, aspire-orchestration) that were not surfaced by the CLI because the + // install prompt was driven by a hardcoded list. After the refactor, the catalog comes + // from the bundle manifest. + // + // The assertion is data-driven against the bundle's own manifest so this test stays + // accurate as the fixture (or, one day, the real bundle) evolves — adding or removing + // a skill in FakeAspireSkillsInstaller doesn't require updating the test body. + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + using var provider = services.BuildServiceProvider(); + + // Prime the bundle and read the list of skills it actually surfaces. The fake + // installer's InstallAsync is idempotent, so the subsequent CLI invocation will reuse + // this same bundle directory. + var installer = provider.GetRequiredService(); + var installResult = await installer.InstallAsync(TestContext.Current.CancellationToken).DefaultTimeout(); + Assert.NotNull(installResult.Bundle); + var bundleSkillNames = installResult.Bundle.GetSkillDefinitions().Select(static s => s.Name).ToList(); + Assert.NotEmpty(bundleSkillNames); + + // Explicit names instead of `all` keeps the assertion focused on bundle skills and + // avoids dragging in Playwright/dotnet-inspect, which would attempt real network calls. + var skillsArg = string.Join(",", bundleSkillNames); + var command = provider.GetRequiredService(); + var result = command.Parse($"agent init --workspace-root {workspace.WorkspaceRoot.FullName} --skill-locations all --skills {skillsArg}"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(CliExitCodes.Success, exitCode); + foreach (var skillName in bundleSkillNames) + { + AssertSkillFileExists(workspace.WorkspaceRoot, Path.Combine(".agents", "skills"), skillName); + } + } + + [Fact] + public async Task AgentInitCommand_NonInteractive_WithExplicitBundleSkillName_InstallsBundleSkill() + { + // Regression guard: bundle-only skill names (e.g. aspire-orchestration) must be selectable + // via --skills by name now that the catalog comes from the manifest rather than the + // hardcoded CLI list. + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + using var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse($"agent init --workspace-root {workspace.WorkspaceRoot.FullName} --skill-locations all --skills {FakeAspireSkillsInstaller.AspireOrchestrationSkillName}"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(CliExitCodes.Success, exitCode); + + AssertSkillFileExists(workspace.WorkspaceRoot, Path.Combine(".agents", "skills"), FakeAspireSkillsInstaller.AspireOrchestrationSkillName); + var aspireSkillPath = Path.Combine(workspace.WorkspaceRoot.FullName, ".agents", "skills", CommonAgentApplicators.AspireSkillName); + Assert.False(Directory.Exists(aspireSkillPath), $"Expected only the selected skill but found {aspireSkillPath}"); } [Fact] @@ -259,17 +611,17 @@ public async Task AgentInitCommand_NonInteractive_WithoutWorkspaceRoot_UsesWorki Assert.Equal(CliExitCodes.Success, exitCode); - // Verify that the default Aspire skills were installed under the working directory - var aspireSkillPath = Path.Combine(workspace.WorkspaceRoot.FullName, ".agents", "skills", "aspire", "SKILL.md"); - Assert.True(File.Exists(aspireSkillPath), $"Expected skill file at {aspireSkillPath}"); - var aspireifySkillPath = Path.Combine(workspace.WorkspaceRoot.FullName, ".agents", "skills", "aspireify", "SKILL.md"); - Assert.True(File.Exists(aspireifySkillPath), $"Expected skill file at {aspireifySkillPath}"); - var deploymentSkillPath = Path.Combine(workspace.WorkspaceRoot.FullName, ".agents", "skills", "aspire-deployment", "SKILL.md"); - Assert.True(File.Exists(deploymentSkillPath), $"Expected skill file at {deploymentSkillPath}"); + AssertSkillFileExists(workspace.WorkspaceRoot, Path.Combine(".agents", "skills"), CommonAgentApplicators.AspireSkillName); + AssertSkillFileExists(workspace.WorkspaceRoot, Path.Combine(".agents", "skills"), CommonAgentApplicators.AspireDeploymentSkillName); + AssertSkillFileExists(workspace.WorkspaceRoot, Path.Combine(".agents", "skills"), FakeAspireSkillsInstaller.AspireInitSkillName); + AssertSkillFileExists(workspace.WorkspaceRoot, Path.Combine(".agents", "skills"), FakeAspireSkillsInstaller.AspireMonitoringSkillName); + AssertSkillFileExists(workspace.WorkspaceRoot, Path.Combine(".agents", "skills"), FakeAspireSkillsInstaller.AspireOrchestrationSkillName); + var aspireifySkillPath = Path.Combine(workspace.WorkspaceRoot.FullName, ".agents", "skills", CommonAgentApplicators.AspireifySkillName); + Assert.False(Directory.Exists(aspireifySkillPath), $"Expected no aspireify skill directory but found {aspireifySkillPath}"); } [Fact] - public async Task AgentInitCommand_NonInteractive_WithUnavailableAspireSkillsBundle_Fails() + public async Task AgentInitCommand_NonInteractive_WithUnavailableAspireSkillsBundle_SucceedsWithoutWarningOrSelectedAspireSkills() { using var workspace = TemporaryWorkspace.Create(outputHelper); const string installFailureMessage = "Aspire skills bundle is unavailable."; @@ -289,13 +641,16 @@ public async Task AgentInitCommand_NonInteractive_WithUnavailableAspireSkillsBun var exitCode = await result.InvokeAsync().DefaultTimeout(); - Assert.Equal(CliExitCodes.InvalidCommand, exitCode); - Assert.Contains(installFailureMessage, interactionService.DisplayedErrors); - Assert.Empty(interactionService.DisplayedSuccess); + Assert.Equal(CliExitCodes.Success, exitCode); + Assert.DoesNotContain(installFailureMessage, interactionService.DisplayedErrors); + Assert.DoesNotContain( + interactionService.DisplayedMessages, + message => message.Emoji.Equals(KnownEmojis.Warning) && message.Message == installFailureMessage); + Assert.Contains(McpCommandStrings.InitCommand_ConfigurationComplete, interactionService.DisplayedSuccess); } [Fact] - public async Task PromptAndChainAsync_WithUnavailableAspireSkillsBundle_SucceedsWithoutSelectedAspireSkills() + public async Task PromptAndChainAsync_WithUnavailableAspireSkillsBundle_SucceedsWithoutWarningOrSelectedAspireSkills() { using var workspace = TemporaryWorkspace.Create(outputHelper); const string installFailureMessage = "Aspire skills bundle is unavailable."; @@ -321,12 +676,71 @@ public async Task PromptAndChainAsync_WithUnavailableAspireSkillsBundle_Succeeds Assert.Equal(CliExitCodes.Success, result.ExitCode); Assert.DoesNotContain(result.SelectedSkills, static skill => skill.SourceKind is SkillSourceKind.AspireSkillsBundle); - Assert.Contains( + Assert.DoesNotContain( interactionService.DisplayedMessages, message => message.Emoji.Equals(KnownEmojis.Warning) && message.Message == installFailureMessage); Assert.Contains(McpCommandStrings.InitCommand_ConfigurationComplete, interactionService.DisplayedSuccess); } + [Fact] + public async Task PromptAndChainAsync_WithoutPredicateOverride_PreSelectsBundleDefaultsIncludingAspireify() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var interactionService = new TestInteractionService(); + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.InteractionServiceFactory = _ => interactionService; + }); + using var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + + // Passing no predicate pre-selects every bundle-sourced skill, which is the semantic + // `aspire init` relies on so the one-time wiring skill chains into the flow. + var result = await command.PromptAndChainAsync( + interactionService, + CliExitCodes.Success, + workspace.WorkspaceRoot, + PromptBinding.CreateDefault(true), + CancellationToken.None).DefaultTimeout(); + + Assert.Equal(CliExitCodes.Success, result.ExitCode); + Assert.Contains(result.SelectedSkills, static skill => skill.HasName(CommonAgentApplicators.AspireifySkillName)); + AssertSkillFileExists(workspace.WorkspaceRoot, Path.Combine(".agents", "skills"), CommonAgentApplicators.AspireifySkillName); + } + + [Fact] + public async Task PromptAndChainAsync_WithExcludeAspireifyPredicate_DoesNotPreSelectAspireify() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var interactionService = new TestInteractionService(); + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.InteractionServiceFactory = _ => interactionService; + }); + using var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + + // Callers that just created a new AppHost (aspire new) or are running standalone agent + // init pass a predicate that strips aspireify from the default selection. The skill + // remains in the prompt — it's just not pre-checked. + var result = await command.PromptAndChainAsync( + interactionService, + CliExitCodes.Success, + workspace.WorkspaceRoot, + PromptBinding.CreateDefault(true), + CancellationToken.None, + AgentInitCommand.ExcludeOneTimeSetupSkillsFromDefaults).DefaultTimeout(); + + Assert.Equal(CliExitCodes.Success, result.ExitCode); + Assert.DoesNotContain(result.SelectedSkills, static skill => skill.HasName(CommonAgentApplicators.AspireifySkillName)); + var aspireifySkillPath = Path.Combine(workspace.WorkspaceRoot.FullName, ".agents", "skills", CommonAgentApplicators.AspireifySkillName); + Assert.False(Directory.Exists(aspireifySkillPath), $"Expected no aspireify skill directory but found {aspireifySkillPath}"); + } + [Fact] public async Task AgentInitCommand_NonInteractive_WithNoneSkills_SucceedsWithNoSkillsInstalled() { @@ -343,7 +757,7 @@ public async Task AgentInitCommand_NonInteractive_WithNoneSkills_SucceedsWithNoS Assert.Equal(CliExitCodes.Success, exitCode); // No skills selected, so no skill files should be created - var aspireSkillPath = Path.Combine(workspace.WorkspaceRoot.FullName, ".agents", "skills", "aspire"); + var aspireSkillPath = Path.Combine(workspace.WorkspaceRoot.FullName, ".agents", "skills", CommonAgentApplicators.AspireSkillName); Assert.False(Directory.Exists(aspireSkillPath), $"Expected no aspire skill directory but found {aspireSkillPath}"); } @@ -364,6 +778,69 @@ public async Task AgentInitCommand_NonInteractive_ConfigureMcpDefaultsToFalse() Assert.Equal(CliExitCodes.Success, exitCode); } + private static void AssertSkillFileExists(DirectoryInfo workspaceRoot, string relativeSkillDirectory, string skillName) + { + var skillPath = Path.Combine(workspaceRoot.FullName, relativeSkillDirectory, skillName, "SKILL.md"); + Assert.True(File.Exists(skillPath), $"Expected skill file at {skillPath}"); + } + + private static async Task CreateBundleAsync(DirectoryInfo workspaceRoot, params (string Name, string Description)[] skills) + { + var bundleDirectory = new DirectoryInfo(Path.Combine(workspaceRoot.FullName, $".test-aspire-skills-bundle-{Guid.NewGuid():N}")); + Directory.CreateDirectory(bundleDirectory.FullName); + + var manifestSkills = new List(); + foreach (var (name, description) in skills) + { + var skillDirectory = Path.Combine(bundleDirectory.FullName, "skills", name); + Directory.CreateDirectory(skillDirectory); + var skillPath = Path.Combine(skillDirectory, "SKILL.md"); + await File.WriteAllTextAsync(skillPath, $$""" + --- + name: {{name}} + description: "{{description}}" + --- + + # {{name}} + """); + + manifestSkills.Add(new SkillBundleSkill + { + Name = name, + Description = description, + Files = + [ + new SkillBundleFile + { + RelativePath = "SKILL.md", + Sha256 = ComputeSha256(skillPath) + } + ] + }); + } + + var manifest = new SkillBundleManifest + { + Version = AspireSkillsInstaller.Version, + Supports = new SkillBundleSupports + { + AspireCli = ">=0.0.0 <999.0.0", + AspireSdk = ">=0.0.0 <999.0.0" + }, + Skills = [.. manifestSkills] + }; + + var manifestJson = JsonSerializer.Serialize(manifest, AspireSkillsJsonSerializerContext.Default.SkillBundleManifest); + await File.WriteAllTextAsync(Path.Combine(bundleDirectory.FullName, "skill-manifest.json"), manifestJson); + return await AspireSkillsBundle.LoadAsync(bundleDirectory, CancellationToken.None); + } + + private static string ComputeSha256(string path) + { + using var stream = File.OpenRead(path); + return Convert.ToHexString(SHA256.HashData(stream)).ToLowerInvariant(); + } + private static CliExecutionContext CreateExecutionContext(DirectoryInfo workingDirectory, DirectoryInfo homeDirectory) { return TestExecutionContextHelper.CreateExecutionContext( diff --git a/tests/Aspire.Cli.Tests/Commands/InitCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/InitCommandTests.cs index f4838101818..b8408f1ac47 100644 --- a/tests/Aspire.Cli.Tests/Commands/InitCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/InitCommandTests.cs @@ -496,7 +496,11 @@ public async Task InitCommand_WhenAspireifySkillSelected_PrintsToolSpecificFollo return [SkillLocation.Standard, SkillLocation.ClaudeCode, SkillLocation.OpenCode]; } - return [SkillDefinition.Aspireify]; + return items + .OfType() + .Where(static skill => skill.HasName(CommonAgentApplicators.AspireifySkillName)) + .Cast() + .ToList(); }; var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => @@ -540,7 +544,11 @@ public async Task InitCommand_WhenAspireifySkillNotSelected_DoesNotPrintFollowUp return [SkillLocation.Standard]; } - return [SkillDefinition.Aspire]; + return items + .OfType() + .Where(static skill => skill.HasName(CommonAgentApplicators.AspireSkillName)) + .Cast() + .ToList(); }; var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => diff --git a/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs index 8baa0175b71..633bdd9ee82 100644 --- a/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs @@ -3,6 +3,7 @@ using System.Globalization; using System.Xml.Linq; +using Aspire.Cli.Agents; using Aspire.Cli.Utils; using Aspire.Cli.Certificates; using Aspire.Cli.Commands; @@ -2891,7 +2892,7 @@ public async Task NewCommandNonInteractive_SuppressAgentInitTrue_SkipsAgentInit( Assert.Equal(CliExitCodes.Success, exitCode); // Agent init should not have run — no skill files should exist - var skillPath = Path.Combine(workspace.WorkspaceRoot.FullName, "output", ".agents", "skills", "aspire", "SKILL.md"); + var skillPath = Path.Combine(workspace.WorkspaceRoot.FullName, "output", ".agents", "skills", CommonAgentApplicators.AspireSkillName, "SKILL.md"); Assert.False(File.Exists(skillPath)); } @@ -2917,8 +2918,10 @@ public async Task NewCommandNonInteractive_SuppressAgentInitFalse_RunsAgentInit( Assert.Equal(CliExitCodes.Success, exitCode); // Agent init should have run — default skill files should exist - var skillPath = Path.Combine(workspace.WorkspaceRoot.FullName, "output", ".agents", "skills", "aspire", "SKILL.md"); + var skillPath = Path.Combine(workspace.WorkspaceRoot.FullName, "output", ".agents", "skills", CommonAgentApplicators.AspireSkillName, "SKILL.md"); Assert.True(File.Exists(skillPath)); + var aspireifySkillPath = Path.Combine(workspace.WorkspaceRoot.FullName, "output", ".agents", "skills", CommonAgentApplicators.AspireifySkillName, "SKILL.md"); + Assert.False(File.Exists(aspireifySkillPath)); } [Fact] @@ -2943,8 +2946,10 @@ public async Task NewCommandNonInteractive_NoSuppressAgentInitOption_DefaultsToR Assert.Equal(CliExitCodes.Success, exitCode); // Default is to run agent init - var skillPath = Path.Combine(workspace.WorkspaceRoot.FullName, "output", ".agents", "skills", "aspire", "SKILL.md"); + var skillPath = Path.Combine(workspace.WorkspaceRoot.FullName, "output", ".agents", "skills", CommonAgentApplicators.AspireSkillName, "SKILL.md"); Assert.True(File.Exists(skillPath)); + var aspireifySkillPath = Path.Combine(workspace.WorkspaceRoot.FullName, "output", ".agents", "skills", CommonAgentApplicators.AspireifySkillName, "SKILL.md"); + Assert.False(File.Exists(aspireifySkillPath)); } [Fact] diff --git a/tests/Aspire.Cli.Tests/Commands/PublishCommandPromptingIntegrationTests.cs b/tests/Aspire.Cli.Tests/Commands/PublishCommandPromptingIntegrationTests.cs index 3e554a7f97b..0e2ad82a64a 100644 --- a/tests/Aspire.Cli.Tests/Commands/PublishCommandPromptingIntegrationTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/PublishCommandPromptingIntegrationTests.cs @@ -910,7 +910,7 @@ public Task PromptForSelectionAsync(string promptText, IEnumerable choi return Task.FromResult(choices.First()); } - public Task> PromptForSelectionsAsync(string promptText, IEnumerable choices, Func choiceFormatter, IEnumerable? preSelected = null, bool optional = false, PromptBinding? binding = null, bool echoSelected = true, CancellationToken cancellationToken = default) where T : notnull + public Task> PromptForSelectionsAsync(string promptText, IEnumerable choices, Func choiceFormatter, IEnumerable? preSelected = null, bool optional = false, PromptBinding? binding = null, bool echoSelected = true, IEnumerable? bindingChoices = null, CancellationToken cancellationToken = default) where T : notnull { if (_shouldCancel || cancellationToken.IsCancellationRequested) { diff --git a/tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs index 1ca9bb6be7c..17d26e209e7 100644 --- a/tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs @@ -2639,17 +2639,17 @@ public CancellationTrackingInteractionService(IInteractionService innerService) public Task ShowStatusAsync(string statusText, Func> action, KnownEmoji? emoji = null, bool allowMarkup = false) => _innerService.ShowStatusAsync(statusText, action, emoji, allowMarkup); public Task ShowDynamicStatusAsync(string initialStatusText, Func, Task> action, KnownEmoji? emoji = null) => _innerService.ShowDynamicStatusAsync(initialStatusText, action, emoji); public void ShowStatus(string statusText, Action action, KnownEmoji? emoji = null, bool allowMarkup = false) => _innerService.ShowStatus(statusText, action, emoji, allowMarkup); - public Task PromptForStringAsync(string promptText, Func? validator = null, bool isSecret = false, bool required = false, PromptBinding? binding = null, CancellationToken cancellationToken = default) + public Task PromptForStringAsync(string promptText, Func? validator = null, bool isSecret = false, bool required = false, PromptBinding? binding = null, CancellationToken cancellationToken = default) => _innerService.PromptForStringAsync(promptText, validator, isSecret, required, binding, cancellationToken); public Task PromptForFilePathAsync(string promptText, Func? validator = null, bool directory = false, bool required = false, PromptBinding? binding = null, CancellationToken cancellationToken = default) => _innerService.PromptForFilePathAsync(promptText, validator, directory, required, binding, cancellationToken); - public Task PromptConfirmAsync(string promptText, PromptBinding? binding = null, CancellationToken cancellationToken = default) + public Task PromptConfirmAsync(string promptText, PromptBinding? binding = null, CancellationToken cancellationToken = default) => _innerService.PromptConfirmAsync(promptText, binding, cancellationToken); - public Task PromptForSelectionAsync(string promptText, IEnumerable choices, Func choiceFormatter, PromptBinding? binding = null, bool echoSelected = true, CancellationToken cancellationToken = default) where T : notnull + public Task PromptForSelectionAsync(string promptText, IEnumerable choices, Func choiceFormatter, PromptBinding? binding = null, bool echoSelected = true, CancellationToken cancellationToken = default) where T : notnull => _innerService.PromptForSelectionAsync(promptText, choices, choiceFormatter, binding, echoSelected, cancellationToken); - public Task> PromptForSelectionsAsync(string promptText, IEnumerable choices, Func choiceFormatter, IEnumerable? preSelected = null, bool optional = false, PromptBinding? binding = null, bool echoSelected = true, CancellationToken cancellationToken = default) where T : notnull - => _innerService.PromptForSelectionsAsync(promptText, choices, choiceFormatter, preSelected, optional, binding, echoSelected, cancellationToken); - public int DisplayIncompatibleVersionError(AppHostIncompatibleException ex, string appHostHostingVersion) + public Task> PromptForSelectionsAsync(string promptText, IEnumerable choices, Func choiceFormatter, IEnumerable? preSelected = null, bool optional = false, PromptBinding? binding = null, bool echoSelected = true, IEnumerable? bindingChoices = null, CancellationToken cancellationToken = default) where T : notnull + => _innerService.PromptForSelectionsAsync(promptText, choices, choiceFormatter, preSelected, optional, binding, echoSelected, bindingChoices, cancellationToken); + public int DisplayIncompatibleVersionError(AppHostIncompatibleException ex, string appHostHostingVersion) => _innerService.DisplayIncompatibleVersionError(ex, appHostHostingVersion); public void DisplayError(string errorMessage, bool allowMarkup = false) => _innerService.DisplayError(errorMessage, allowMarkup); public void DisplayMessage(KnownEmoji emoji, string message, bool allowMarkup = false, ConsoleOutput? consoleOverride = null) => _innerService.DisplayMessage(emoji, message, allowMarkup, consoleOverride); diff --git a/tests/Aspire.Cli.Tests/Interaction/ConsoleInteractionServiceTests.cs b/tests/Aspire.Cli.Tests/Interaction/ConsoleInteractionServiceTests.cs index 391dfe65ae2..ff4ec94bb11 100644 --- a/tests/Aspire.Cli.Tests/Interaction/ConsoleInteractionServiceTests.cs +++ b/tests/Aspire.Cli.Tests/Interaction/ConsoleInteractionServiceTests.cs @@ -1456,6 +1456,67 @@ public async Task PromptForSelectionsAsync_NonInteractive_CliProvidedInvalidValu Assert.Contains("gamma", outputString); } + [Fact] + public async Task PromptForSelectionsAsync_NonInteractive_CliProvidedInvalidValue_OmitsItemsOutsideBindingChoices() + { + // The visible multi-select prompt may include UX-only entries (e.g. a "Configure MCP server" + // applicator) that share the prompt with the real catalog but must not be addressable from + // the --option value. Callers narrow non-interactive validation via the bindingChoices subset; + // entries outside that subset must not leak into the "Available values" rejection message. + var output = new StringBuilder(); + var console = CreateInteractiveConsoleWithInput(output, ""); + var interactionService = CreateInteractionService(console, hostEnvironment: TestHelpers.CreateNonInteractiveHostEnvironment()); + var visibleChoices = new[] { "alpha", "beta", "ux-only-entry" }; + var bindingChoices = new[] { "alpha", "beta" }; + + var option = new System.CommandLine.Option("--items"); + var command = new System.CommandLine.RootCommand { option }; + var parseResult = command.Parse("--items invalid"); + var binding = PromptBinding.Create(parseResult, option); + + await Assert.ThrowsAsync(() => + interactionService.PromptForSelectionsAsync("Select:", visibleChoices, x => x, binding: binding, bindingChoices: bindingChoices, cancellationToken: CancellationToken.None)); + + var outputString = output.ToString(); + Assert.Contains("alpha", outputString); + Assert.Contains("beta", outputString); + Assert.DoesNotContain("ux-only-entry", outputString); + } + + [Fact] + public async Task PromptForSelectionsAsync_NonInteractive_CliProvidedInvalidValue_StripsSpectreMarkupFromChoiceLabels() + { + // Choice formatters sometimes return Spectre.Console markup (e.g. "[bold]Label[/]") so the + // interactive multi-select can render styled text. The non-interactive rejection message is + // plain text, so those tokens must be stripped rather than printed verbatim — otherwise a + // user who mistypes --option sees `[bold]Label[/]` in the "Available values" list. + var output = new StringBuilder(); + var console = CreateInteractiveConsoleWithInput(output, ""); + var interactionService = CreateInteractionService(console, hostEnvironment: TestHelpers.CreateNonInteractiveHostEnvironment()); + var choices = new[] { "alpha", "beta" }; + + var option = new System.CommandLine.Option("--items"); + var command = new System.CommandLine.RootCommand { option }; + var parseResult = command.Parse("--items invalid"); + var binding = PromptBinding.Create(parseResult, option); + + await Assert.ThrowsAsync(() => + interactionService.PromptForSelectionsAsync( + "Select:", + choices, + x => $"[bold]{x}[/] [dim](styled)[/]", + binding: binding, + cancellationToken: CancellationToken.None)); + + var outputString = output.ToString(); + Assert.Contains("alpha", outputString); + Assert.Contains("beta", outputString); + Assert.Contains("(styled)", outputString); + Assert.DoesNotContain("[bold]", outputString); + Assert.DoesNotContain("[/]", outputString); + Assert.DoesNotContain("[dim]", outputString); + } + [Fact] public async Task PromptForSelectionAsync_NonInteractive_WithDefaultValue_ReturnsMatch() { diff --git a/tests/Aspire.Cli.Tests/Projects/ExtensionGuestLauncherTests.cs b/tests/Aspire.Cli.Tests/Projects/ExtensionGuestLauncherTests.cs index ce2f8fa3853..323cb019c52 100644 --- a/tests/Aspire.Cli.Tests/Projects/ExtensionGuestLauncherTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/ExtensionGuestLauncherTests.cs @@ -163,7 +163,7 @@ public Task LaunchAppHostAsync(string projectFile, List arguments, List< public Task PromptForStringAsync(string promptText, Func? validator = null, bool isSecret = false, bool required = false, PromptBinding? binding = null, CancellationToken cancellationToken = default) => throw new NotImplementedException(); public Task PromptForFilePathAsync(string promptText, Func? validator = null, bool directory = false, bool required = false, PromptBinding? binding = null, CancellationToken cancellationToken = default) => throw new NotImplementedException(); public Task PromptForSelectionAsync(string promptText, IEnumerable choices, Func choiceFormatter, PromptBinding? binding = null, bool echoSelected = true, CancellationToken cancellationToken = default) where T : notnull => throw new NotImplementedException(); - public Task> PromptForSelectionsAsync(string promptText, IEnumerable choices, Func choiceFormatter, IEnumerable? preSelected = null, bool optional = false, PromptBinding? binding = null, bool echoSelected = true, CancellationToken cancellationToken = default) where T : notnull => throw new NotImplementedException(); + public Task> PromptForSelectionsAsync(string promptText, IEnumerable choices, Func choiceFormatter, IEnumerable? preSelected = null, bool optional = false, PromptBinding? binding = null, bool echoSelected = true, IEnumerable? bindingChoices = null, CancellationToken cancellationToken = default) where T : notnull => throw new NotImplementedException(); public int DisplayIncompatibleVersionError(AppHostIncompatibleException ex, string appHostHostingVersion) => throw new NotImplementedException(); public void DisplayError(string errorMessage, bool allowMarkup = false) => throw new NotImplementedException(); public void DisplayMessage(KnownEmoji emoji, string message, bool allowMarkup = false, ConsoleOutput? consoleOverride = null) => throw new NotImplementedException(); diff --git a/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs b/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs index 854334b1a2f..6c8c9b46fdb 100644 --- a/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs +++ b/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs @@ -374,7 +374,7 @@ private sealed class TestInteractionService : IInteractionService public Task PromptForSelectionAsync(string prompt, IEnumerable choices, Func displaySelector, PromptBinding? binding = null, bool echoSelected = true, CancellationToken cancellationToken = default) where T : notnull => throw new NotImplementedException(); - public Task> PromptForSelectionsAsync(string promptText, IEnumerable choices, Func choiceFormatter, IEnumerable? preSelected = null, bool optional = false, PromptBinding? binding = null, bool echoSelected = true, CancellationToken cancellationToken = default) where T : notnull + public Task> PromptForSelectionsAsync(string promptText, IEnumerable choices, Func choiceFormatter, IEnumerable? preSelected = null, bool optional = false, PromptBinding? binding = null, bool echoSelected = true, IEnumerable? bindingChoices = null, CancellationToken cancellationToken = default) where T : notnull => throw new NotImplementedException(); public Task PromptForStringAsync(string promptText, Func? validator = null, bool isSecret = false, bool required = false, PromptBinding? binding = null, CancellationToken cancellationToken = default) diff --git a/tests/Aspire.Cli.Tests/TestServices/FakePlaywrightServices.cs b/tests/Aspire.Cli.Tests/TestServices/FakePlaywrightServices.cs index f87c5d29ce2..19dff3db7df 100644 --- a/tests/Aspire.Cli.Tests/TestServices/FakePlaywrightServices.cs +++ b/tests/Aspire.Cli.Tests/TestServices/FakePlaywrightServices.cs @@ -49,6 +49,10 @@ public Task VerifyProvenanceAsync(string packageNa /// internal sealed class FakeAspireSkillsInstaller : IAspireSkillsInstaller { + internal const string AspireInitSkillName = "aspire-init"; + internal const string AspireMonitoringSkillName = "aspire-monitoring"; + internal const string AspireOrchestrationSkillName = "aspire-orchestration"; + private readonly DirectoryInfo _bundleDirectory; private readonly AspireSkillsInstallResult? _result; @@ -113,7 +117,34 @@ private async Task EnsureBundleAsync(CancellationToken cancellationToken) # Aspire Deployment """, - [(CommonAgentApplicators.AspireDeploymentSkillName, Path.Combine("references", "preflight.md"))] = "# Preflight" + [(CommonAgentApplicators.AspireDeploymentSkillName, Path.Combine("references", "preflight.md"))] = "# Preflight", + [(AspireInitSkillName, "SKILL.md")] = + """ + --- + name: aspire-init + description: "First-run flow for adding Aspire to a repo" + --- + + # Aspire Init + """, + [(AspireMonitoringSkillName, "SKILL.md")] = + """ + --- + name: aspire-monitoring + description: "Observe Aspire apps with logs, traces, metrics, and resource state" + --- + + # Aspire Monitoring + """, + [(AspireOrchestrationSkillName, "SKILL.md")] = + """ + --- + name: aspire-orchestration + description: "Manage Aspire AppHost lifecycle and resource commands" + --- + + # Aspire Orchestration + """ }; foreach (var ((skillName, relativePath), content) in files) @@ -133,9 +164,12 @@ private async Task EnsureBundleAsync(CancellationToken cancellationToken) }, Skills = [ - CreateSkill(CommonAgentApplicators.AspireSkillName, isDefault: true, ["evals"], files), - CreateSkill(CommonAgentApplicators.AspireifySkillName, isDefault: true, [], files), - CreateSkill(CommonAgentApplicators.AspireDeploymentSkillName, isDefault: true, [], files) + CreateSkill(CommonAgentApplicators.AspireSkillName, ["evals"], files), + CreateSkill(CommonAgentApplicators.AspireifySkillName, ["evals"], files), + CreateSkill(CommonAgentApplicators.AspireDeploymentSkillName, ["evals"], files), + CreateSkill(AspireInitSkillName, ["evals"], files), + CreateSkill(AspireMonitoringSkillName, ["evals"], files), + CreateSkill(AspireOrchestrationSkillName, ["evals"], files) ] }; @@ -143,13 +177,12 @@ private async Task EnsureBundleAsync(CancellationToken cancellationToken) await File.WriteAllTextAsync(Path.Combine(_bundleDirectory.FullName, "skill-manifest.json"), manifestJson, cancellationToken); } - private SkillBundleSkill CreateSkill(string skillName, bool isDefault, string[] installExcludedRelativePaths, Dictionary<(string SkillName, string RelativePath), string> files) + private SkillBundleSkill CreateSkill(string skillName, string[] installExcludedRelativePaths, Dictionary<(string SkillName, string RelativePath), string> files) { return new SkillBundleSkill { Name = skillName, Description = $"{skillName} skill", - IsDefault = isDefault, InstallExcludedRelativePaths = installExcludedRelativePaths, Files = files .Where(entry => string.Equals(entry.Key.SkillName, skillName, StringComparison.Ordinal)) diff --git a/tests/Aspire.Cli.Tests/TestServices/TestExtensionInteractionService.cs b/tests/Aspire.Cli.Tests/TestServices/TestExtensionInteractionService.cs index 8fab1ba4e5e..9cc3950292b 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestExtensionInteractionService.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestExtensionInteractionService.cs @@ -75,7 +75,7 @@ public Task PromptForSelectionAsync(string promptText, IEnumerable choi return Task.FromResult(choicesArray.First()); } - public Task> PromptForSelectionsAsync(string promptText, IEnumerable choices, Func choiceFormatter, IEnumerable? preSelected = null, bool optional = false, PromptBinding? binding = null, bool echoSelected = true, CancellationToken cancellationToken = default) where T : notnull + public Task> PromptForSelectionsAsync(string promptText, IEnumerable choices, Func choiceFormatter, IEnumerable? preSelected = null, bool optional = false, PromptBinding? binding = null, bool echoSelected = true, IEnumerable? bindingChoices = null, CancellationToken cancellationToken = default) where T : notnull { if (!choices.Any()) { diff --git a/tests/Aspire.Cli.Tests/TestServices/TestInteractionService.cs b/tests/Aspire.Cli.Tests/TestServices/TestInteractionService.cs index f5a6b1f3d0f..c4212033722 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestInteractionService.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestInteractionService.cs @@ -180,7 +180,7 @@ public Task PromptForSelectionAsync(string promptText, IEnumerable choi return Task.FromResult(choices.First()); } - public Task> PromptForSelectionsAsync(string promptText, IEnumerable choices, Func choiceFormatter, IEnumerable? preSelected = null, bool optional = false, PromptBinding? binding = null, bool echoSelected = true, CancellationToken cancellationToken = default) where T : notnull + public Task> PromptForSelectionsAsync(string promptText, IEnumerable choices, Func choiceFormatter, IEnumerable? preSelected = null, bool optional = false, PromptBinding? binding = null, bool echoSelected = true, IEnumerable? bindingChoices = null, CancellationToken cancellationToken = default) where T : notnull { if (_shouldCancel || cancellationToken.IsCancellationRequested) {