File: Agents\AspireSkills\AspireSkillsRemoteConfiguration.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.Nodes;
using Aspire.Cli.Resources;
 
namespace Aspire.Cli.Agents.AspireSkills;
 
/// <summary>
/// Aspire plugin and OpenCode catalog registration without acquiring their content.
/// </summary>
internal static class AspireSkillsRemoteConfiguration
{
    internal const string MarketplaceName = "aspire-skills";
    internal const string PluginName = "aspire@aspire-skills";
    internal const string Repository = "microsoft/aspire-skills";
    internal const string RepositoryUrl = "https://github.com/" + Repository;
 
    private const string OpenCodeV1Catalog = "https://raw.githubusercontent.com/microsoft/aspire-skills/main/opencode/v1/";
    private const string OpenCodeV2Catalog = "https://raw.githubusercontent.com/microsoft/aspire-skills/main/opencode/v2/";
 
    public static bool HasPluginRegistration(IEnumerable<string> settings, CancellationToken cancellationToken)
    {
        var enabled = false;
        foreach (var path in settings)
        {
            if (AgentConfiguration.ReadObject(path, cancellationToken) is { } config &&
                OptionalObject(config, "enabledPlugins") is { } plugins &&
                Boolean(plugins, PluginName) is { } value)
            {
                enabled = value;
            }
        }
 
        return enabled;
    }
 
    public static void ApplyOpenCodeCatalog(JsonObject root, IEnumerable<JsonObject> configs)
    {
        var target = ReadOpenCodeSkills(root);
        string? preferred = null;
        foreach (var config in configs)
        {
            var sources = ReadOpenCodeSkills(config);
            if (sources?.Select(String).FirstOrDefault(IsOpenCodeCatalog) is { } existing &&
                (preferred is null || preferred.Contains("/main/opencode/", StringComparison.Ordinal)))
            {
                preferred = existing;
            }
        }
 
        // V2 accepts V1 skills objects and native arrays independently of MCP.
        // Catalog URLs identify content and pins, not the surrounding JSON syntax.
        // https://github.com/anomalyco/opencode/blob/52a10f573f1da5103c85990da9341e3645a39365/services/www/src/docs/content/migrate-v1.mdx#skills
        preferred ??= root["skills"] is JsonArray ? OpenCodeV2Catalog : OpenCodeV1Catalog;
        if (target is null)
        {
            // With no skills setting, use the object form supported by both clients.
            target = new JsonArray();
            Object(root, "skills")["urls"] = target;
        }
        if (!target.Any(value => IsOpenCodeCatalog(String(value))))
        {
            target.Add((JsonNode?)preferred);
        }
    }
 
    public static bool HasOpenCodeCatalog(JsonObject root)
        => ReadOpenCodeSkills(root)?.Any(value => IsOpenCodeCatalog(String(value))) is true;
 
    private static JsonArray? ReadOpenCodeSkills(JsonObject root)
    {
        if (!root.TryGetPropertyValue("skills", out var skills))
        {
            return null;
        }
 
        if (skills is JsonObject v1)
        {
            OptionalStrings(v1, "paths");
            return OptionalStrings(v1, "urls");
        }
        ArgumentNullException.ThrowIfNull(skills);
        return Strings(skills.AsArray());
    }
 
    private static bool IsOpenCodeCatalog(string? source)
    {
        if (!Uri.TryCreate(source, UriKind.Absolute, out var uri) || uri.Scheme != Uri.UriSchemeHttps ||
            !uri.Host.Equals("raw.githubusercontent.com", StringComparison.OrdinalIgnoreCase) ||
            !uri.AbsolutePath.StartsWith("/microsoft/aspire-skills/", StringComparison.OrdinalIgnoreCase))
        {
            return false;
        }
 
        var path = uri.AbsolutePath.TrimEnd('/');
        return path.EndsWith("/opencode/v1", StringComparison.Ordinal) ||
            path.EndsWith("/opencode/v2", StringComparison.Ordinal);
    }
 
    public static async Task ApplyPluginAsync(
        JsonObject root, IEnumerable<string> settingsPaths, string targetPath,
        AgentConfiguration edit, CancellationToken cancellationToken)
    {
        var settings = await ReadSettingsAsync(settingsPaths, targetPath, edit, cancellationToken);
        JsonObject? selectedSource = null;
        foreach (var config in settings.Append(root))
        {
            var plugins = OptionalObject(config, "enabledPlugins");
            if (plugins is not null && Boolean(plugins, PluginName) is false)
            {
                throw ExistingConfiguration($"enabledPlugins.{PluginName}");
            }
 
            var marketplaces = OptionalObject(config, "extraKnownMarketplaces");
            if (marketplaces is not null && marketplaces.TryGetPropertyValue(MarketplaceName, out var marketplaceNode))
            {
                ArgumentNullException.ThrowIfNull(marketplaceNode);
                var source = OptionalObject(marketplaceNode.AsObject(), "source");
                ArgumentNullException.ThrowIfNull(source);
 
                if (!IsOfficialSource(source))
                {
                    throw ExistingConfiguration($"extraKnownMarketplaces.{MarketplaceName}.source");
                }
 
                // Keep inherited release/commit pins unless the destination declares its own source.
                if (ReferenceEquals(config, root) || source.ContainsKey("ref") || source.ContainsKey("sha") || selectedSource is null)
                {
                    selectedSource = source;
                }
            }
        }
 
        selectedSource ??= new JsonObject
        {
            ["source"] = "github",
            ["repo"] = Repository
        };
        // Leave marketplace policy enforcement to the client rather than matching its rules.
        foreach (var config in settings.Append(root))
        {
            foreach (var policy in new[] { "strictKnownMarketplaces", "blockedMarketplaces" })
            {
                if (config.ContainsKey(policy))
                {
                    throw ExistingConfiguration(policy);
                }
            }
        }
 
        var targetMarketplaces = Object(root, "extraKnownMarketplaces");
        if (!targetMarketplaces.ContainsKey(MarketplaceName))
        {
            targetMarketplaces[MarketplaceName] = new JsonObject
            {
                ["source"] = selectedSource.DeepClone()
            };
        }
 
        // No autoUpdate opt-in and no installedPlugins/cache metadata: registration is not
        // acquisition. Existing entries, including explicit false, were checked above.
        var targetPlugins = Object(root, "enabledPlugins");
        if (!targetPlugins.ContainsKey(PluginName))
        {
            targetPlugins[PluginName] = true;
        }
    }
 
    public static async Task<IReadOnlyList<JsonObject>> ReadSettingsAsync(
        IEnumerable<string> paths, string targetPath, AgentConfiguration edit, CancellationToken cancellationToken)
    {
        var settings = new List<JsonObject>();
        foreach (var path in paths.Distinct(StringComparers.FileSystemPath))
        {
            if (!string.Equals(path, targetPath, StringComparisons.FileSystemPath) &&
                await edit.ReadObjectForEditAsync(path, cancellationToken) is { } root)
            {
                settings.Add(root);
            }
        }
 
        return settings;
    }
 
    public static JsonObject? OptionalObject(JsonObject parent, string key)
    {
        if (!parent.TryGetPropertyValue(key, out var node))
        {
            return null;
        }
 
        ArgumentNullException.ThrowIfNull(node, key);
        return node.AsObject();
    }
 
    public static JsonObject Object(JsonObject parent, string key)
    {
        var value = OptionalObject(parent, key);
        if (value is null)
        {
            value = new JsonObject();
            parent[key] = value;
        }
 
        return value;
    }
 
    public static JsonArray? OptionalStrings(JsonObject parent, string key)
    {
        if (!parent.TryGetPropertyValue(key, out var node))
        {
            return null;
        }
 
        ArgumentNullException.ThrowIfNull(node, key);
        return Strings(node.AsArray());
    }
 
    public static JsonArray Strings(JsonArray array)
    {
        foreach (var value in array)
        {
            ArgumentNullException.ThrowIfNull(value);
            _ = value.GetValue<string>();
        }
 
        return array;
    }
 
    public static string? String(JsonNode? node)
        => node is JsonValue value && value.TryGetValue<string>(out var text) ? text : null;
 
    public static bool? Boolean(JsonObject parent, string key)
    {
        if (!parent.TryGetPropertyValue(key, out var node))
        {
            return null;
        }
 
        ArgumentNullException.ThrowIfNull(node, key);
        return node.GetValue<bool>();
    }
 
    internal static InvalidOperationException ExistingConfiguration(string key)
        => new(string.Format(CultureInfo.CurrentCulture, AgentCommandStrings.InitCommand_ExistingConfigurationConflict, key));
 
    private static bool IsOfficialSource(JsonObject source)
    {
        return String(source["source"]) switch
        {
            "github" => string.Equals(String(source["repo"]), Repository, StringComparison.OrdinalIgnoreCase),
            "git" => String(source["url"]) is { } url &&
                (string.Equals(url.TrimEnd('/'), $"https://github.com/{Repository}", StringComparison.OrdinalIgnoreCase) ||
                 string.Equals(url.TrimEnd('/'), $"https://github.com/{Repository}.git", StringComparison.OrdinalIgnoreCase)),
            _ => false
        };
    }
}