File: Agents\AspireSkills\LocalAspireSkills.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.Globalization;
using System.Text.Json;
using Aspire.Cli.Resources;
 
namespace Aspire.Cli.Agents.AspireSkills;
 
internal static class LocalAspireSkills
{
    // These names identify skills copied by older CLIs, not the current plugin catalog.
    // Keep them independent of bundles so leftover files remain discoverable after bundle removal.
    private static readonly string[] s_legacySkillNames =
    [
        "aspire",
        "aspire-deployment",
        "aspire-init",
        "aspire-monitoring",
        "aspire-orchestration",
        "aspire-project-v2-migration",
        "aspireify"
    ];
 
    internal static (IReadOnlyList<string> Files, IReadOnlyList<string> Errors, bool HasFileErrors) Find(
        DirectoryInfo workingDirectory,
        DirectoryInfo workspaceRoot,
        IEnumerable<IAgentClient> agents,
        CancellationToken cancellationToken)
    {
        cancellationToken.ThrowIfCancellationRequested();
 
        // Clients load project skills along the active ancestor chain, not just at its root.
        // Clamping to the workspace excludes sibling projects and unrelated current directories.
        var projectDirectories = AgentPath.ProjectDirectories(workingDirectory, workspaceRoot).ToArray();
        var files = new Dictionary<string, string>(StringComparers.FileSystemPath);
        var errors = new HashSet<string>(StringComparer.Ordinal);
        var inspected = new HashSet<string>(StringComparers.FileSystemPath);
        var hasFileErrors = false;
        foreach (var agent in agents)
        {
            foreach (var scope in new[] { AgentConfigurationScope.Project, AgentConfigurationScope.Global })
            {
                cancellationToken.ThrowIfCancellationRequested();
                string[] directories;
                try
                {
                    directories = scope is AgentConfigurationScope.Project
                        ? projectDirectories.SelectMany(directory => agent.GetLocalSkillDirectories(scope, directory)).ToArray()
                        : agent.GetLocalSkillDirectories(scope, workspaceRoot).ToArray();
                }
                catch (Exception ex) when (ex is JsonException or InvalidOperationException or ArgumentNullException)
                {
                    // Invalid skill settings block plugin setup, not independently selected MCP.
                    errors.Add(string.Format(CultureInfo.CurrentCulture, AgentCommandStrings.LocalSkills_ReadFailed, agent.DisplayName, ex.Message));
                    continue;
                }
                catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException)
                {
                    hasFileErrors = true;
                    errors.Add(string.Format(CultureInfo.CurrentCulture, AgentCommandStrings.LocalSkills_ReadFailed, agent.DisplayName, ex.Message));
                    continue;
                }
 
                foreach (var directory in directories)
                {
                    foreach (var name in s_legacySkillNames)
                    {
                        cancellationToken.ThrowIfCancellationRequested();
                        var path = Path.Combine(directory, name, "SKILL.md");
                        if (!inspected.Add(path))
                        {
                            continue;
                        }
 
                        try
                        {
                            var fullPath = Path.GetFullPath(path);
                            // Older CLI versions copied Markdown verbatim, without an ownership
                            // stamp or installed version. Names identify possible collisions only;
                            // even byte-identical direct installs cannot be classified as obsolete.
                            using var stream = File.OpenRead(fullPath);
                            files.TryAdd(fullPath, path);
                        }
                        catch (Exception ex) when (ex is FileNotFoundException or DirectoryNotFoundException)
                        {
                            // Missing entry points are expected; unreadable ones remain errors.
                        }
                        catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException)
                        {
                            hasFileErrors = true;
                            errors.Add(string.Format(CultureInfo.CurrentCulture, AgentCommandStrings.LocalSkills_ReadFailed, path, ex.Message));
                        }
                    }
                }
            }
        }
 
        return (files.Values.Order(StringComparers.FileSystemPath).ToArray(), errors.Order(StringComparer.Ordinal).ToArray(), hasFileErrors);
    }
}