File: Agents\SkillDefinition.cs
Web Access
Project: src\src\Aspire.Cli\Aspire.Cli.csproj (aspire)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Diagnostics;
using Aspire.Cli.Projects;
using Aspire.Cli.Resources;
 
namespace Aspire.Cli.Agents;
 
/// <summary>
/// Represents a skill that can be installed into a skill location.
/// </summary>
[DebuggerDisplay("Name = {Name}, Description = {Description}, IsDefault = {IsDefault}")]
internal sealed class SkillDefinition
{
    /// <summary>
    /// The Playwright CLI skill for browser automation.
    /// </summary>
    public static readonly SkillDefinition PlaywrightCli = new(
        "playwright-cli",
        AgentCommandStrings.SkillDescription_PlaywrightCli,
        skillContent: null,
        sourceKind: SkillSourceKind.ExternalInstaller, // Playwright is installed via PlaywrightCliInstaller, not a static file
        installExcludedRelativePaths: [],
        isDefault: false);
 
    /// <summary>
    /// The dotnet-inspect skill for querying .NET API surfaces.
    /// Only offered when the workspace contains a .NET AppHost.
    /// </summary>
    public static readonly SkillDefinition DotnetInspect = new(
        CommonAgentApplicators.DotnetInspectSkillName,
        AgentCommandStrings.SkillDescription_DotnetInspect,
        CommonAgentApplicators.DotnetInspectSkillFileContent,
        sourceKind: SkillSourceKind.Static,
        installExcludedRelativePaths: [],
        isDefault: false,
        applicableLanguages: [KnownLanguageId.CSharp]);
 
    /// <summary>
    /// 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 <c>aspire new</c>
    /// and standalone <c>aspire agent init</c> can still narrow that set with a predicate
    /// (see <c>AgentInitCommand.ExcludeOneTimeSetupSkillsFromDefaults</c>).
    /// </summary>
    internal static SkillDefinition CreateAspireSkillsBundle(
        string name,
        string description,
        IReadOnlyList<string>? installExcludedRelativePaths = null,
        IReadOnlyList<string>? applicableLanguages = null)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(name);
        ArgumentException.ThrowIfNullOrWhiteSpace(description);
 
        return new(
            name,
            description,
            skillContent: null,
            sourceKind: SkillSourceKind.AspireSkillsBundle,
            installExcludedRelativePaths: installExcludedRelativePaths ?? [],
            isDefault: true,
            applicableLanguages);
    }
 
    private SkillDefinition(string name, string description, string? skillContent, SkillSourceKind sourceKind, IReadOnlyList<string> installExcludedRelativePaths, bool isDefault, IReadOnlyList<string>? applicableLanguages = null)
    {
        Name = name;
        Description = description;
        SkillContent = skillContent;
        SourceKind = sourceKind;
        InstallExcludedRelativePaths = installExcludedRelativePaths;
        IsDefault = isDefault;
        ApplicableLanguages = applicableLanguages ?? [];
    }
 
    /// <summary>
    /// Gets the skill name (used as the folder name under skill locations).
    /// </summary>
    public string Name { get; }
 
    /// <summary>
    /// Gets the description shown in the selection prompt.
    /// </summary>
    public string Description { get; }
 
    /// <summary>
    /// Gets the content for the top-level SKILL.md file when the skill is defined as a single-file bundle.
    /// </summary>
    public string? SkillContent { get; }
 
    /// <summary>
    /// Gets where the installable files for this skill come from.
    /// </summary>
    public SkillSourceKind SourceKind { get; }
 
    /// <summary>
    /// Gets whether this skill has files that <c>aspire agent init</c> installs directly.
    /// </summary>
    public bool HasInstallableFiles => SkillContent is not null || SourceKind is SkillSourceKind.AspireSkillsBundle;
 
    /// <summary>
    /// Gets relative paths that should be excluded when the skill is installed into a workspace.
    /// </summary>
    public IReadOnlyList<string> InstallExcludedRelativePaths { get; }
 
    /// <summary>
    /// Gets whether a bundled file should be installed into a workspace.
    /// </summary>
    public bool ShouldInstallFile(string relativePath)
    {
        foreach (var excludedPath in InstallExcludedRelativePaths)
        {
            if (PathMatchesOrIsUnder(relativePath, excludedPath))
            {
                return false;
            }
        }
 
        return true;
    }
 
    /// <summary>
    /// Gets whether this skill should be selected by default.
    /// </summary>
    public bool IsDefault { get; }
 
    /// <summary>
    /// Gets the set of language identifiers (from <see cref="KnownLanguageId"/>) this skill applies to.
    /// An empty list means the skill is language-agnostic and always offered.
    /// When non-empty, the skill is only offered when the detected language matches one of the entries.
    /// </summary>
    public IReadOnlyList<string> ApplicableLanguages { get; }
 
    /// <summary>
    /// Returns whether this skill is applicable for the given detected language.
    /// A skill with no <see cref="ApplicableLanguages"/> restrictions is always applicable.
    /// A skill with restrictions is only applicable when the detected language matches one of the entries.
    /// When no language is detected (<paramref name="detectedLanguage"/> is <c>null</c>), language-restricted skills are excluded.
    /// </summary>
    public bool IsApplicableToLanguage(LanguageId? detectedLanguage)
    {
        if (ApplicableLanguages.Count == 0)
        {
            return true;
        }
 
        if (detectedLanguage is null)
        {
            return false;
        }
 
        return ApplicableLanguages.Any(l => string.Equals(l, detectedLanguage.Value.Value, StringComparison.OrdinalIgnoreCase));
    }
 
    /// <summary>
    /// Returns whether this skill has the specified name.
    /// </summary>
    public bool HasName(string name, StringComparison comparison = StringComparison.Ordinal) => string.Equals(Name, name, comparison);
 
    private static bool PathMatchesOrIsUnder(string relativePath, string excludedPath)
    {
        if (string.Equals(relativePath, excludedPath, StringComparison.Ordinal))
        {
            return true;
        }
 
        if (!relativePath.StartsWith(excludedPath, StringComparison.Ordinal))
        {
            return false;
        }
 
        return relativePath.Length > excludedPath.Length && relativePath[excludedPath.Length] == Path.DirectorySeparatorChar;
    }
 
    /// <summary>
    /// Gets CLI-defined skills that are not sourced from the Aspire skills bundle.
    /// </summary>
    public static IReadOnlyList<SkillDefinition> CliDefined { get; } = [PlaywrightCli, DotnetInspect];
 
    /// <inheritdoc />
    public override string ToString() => Name;
}
 
/// <summary>
/// Identifies where skill files are sourced from.
/// </summary>
internal enum SkillSourceKind
{
    /// <summary>
    /// The skill is represented by static content compiled into the CLI.
    /// </summary>
    Static,
 
    /// <summary>
    /// The skill is installed from the external Aspire skills bundle.
    /// </summary>
    AspireSkillsBundle,
 
    /// <summary>
    /// The skill is managed by a dedicated external installer.
    /// </summary>
    ExternalInstaller
}