| File: Agents\AspireSkills\SkillSource.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.ComponentModel; using System.Globalization; using System.Text.Json.Serialization; using Aspire.Cli.Git; using Aspire.Cli.Utils; using Microsoft.Extensions.Configuration; namespace Aspire.Cli.Agents.AspireSkills; internal sealed class SkillSource { internal const string SectionName = "skillSources"; internal const string DefaultName = "default"; internal const string DefaultV1Catalog = "https://raw.githubusercontent.com/microsoft/aspire-skills/main/opencode/v1/"; internal const string DefaultV2Catalog = "https://raw.githubusercontent.com/microsoft/aspire-skills/main/opencode/v2/"; internal static SkillSource BuiltIn { get; } = new() { Repository = AspireSkillsRemoteConfiguration.Repository, Marketplace = AspireSkillsRemoteConfiguration.MarketplaceName, Plugins = ["aspire"], OpenCodeV1Catalog = DefaultV1Catalog, OpenCodeV2Catalog = DefaultV2Catalog }; [JsonPropertyName("enabled")] [Description("Whether to register this source. Defaults to true. Set default.enabled to false to omit the built-in Aspire source.")] public bool? Enabled { get; init; } [JsonPropertyName("repository")] [Description("GitHub owner/repository or an HTTPS/SSH Git repository URL containing the agent marketplace.")] public string? Repository { get; init; } [JsonPropertyName("marketplace")] [Description("Agent marketplace identifier. Defaults to the source name, or aspire-skills for the default source.")] public string? Marketplace { get; init; } [JsonPropertyName("plugins")] [Description("Plugin names within the marketplace. Defaults to [\"aspire\"].")] public string[]? Plugins { get; init; } [JsonPropertyName("ref")] [Description("Optional marketplace Git ref. Existing agent-side pins are preserved.")] public string? Ref { get; init; } [JsonPropertyName("openCodeV1Catalog")] [Description("HTTPS skills catalog URL for OpenCode V1. Omit for sources without an OpenCode catalog.")] public string? OpenCodeV1Catalog { get; init; } [JsonPropertyName("openCodeV2Catalog")] [Description("HTTPS skills catalog URL for OpenCode V2. Omit for sources without an OpenCode catalog.")] public string? OpenCodeV2Catalog { get; init; } internal IEnumerable<string> GetPluginNames() => (Plugins ?? ["aspire"]).Select(plugin => $"{plugin}@{Marketplace}"); internal string GetRepositoryUrl() => IsGitHubRepository(Repository!) ? $"https://github.com/{Repository}" : Repository!; internal static IReadOnlyList<SkillSource> Resolve(DirectoryInfo configurationDirectory, FileInfo globalSettingsFile) { // Chained new/init runs before startup configuration can see newly generated or // materialized settings. Read the selected app's current file, not the invoking directory. using var effective = new ConfigurationManager(); effective.AddEnvironmentVariables(); ConfigurationHelper.RegisterSettingsFiles(effective, configurationDirectory, globalSettingsFile, persistNormalization: false); return Resolve(effective); } internal static IReadOnlyList<SkillSource> Resolve(IConfiguration configuration) { var section = configuration.GetSection(SectionName); if (section.Value is not null) { throw AspireSkillsRemoteConfiguration.ExistingConfiguration(SectionName); } var names = new[] { DefaultName }.Concat(section.GetChildren().Select(child => child.Key)) .Distinct(StringComparer.OrdinalIgnoreCase); var sources = new List<SkillSource>(); var marketplaces = new HashSet<string>(StringComparer.OrdinalIgnoreCase); foreach (var name in names) { var settings = section.GetSection(name); if (settings.Value is not null || !IsIdentifier(name)) { throw AspireSkillsRemoteConfiguration.ExistingConfiguration($"{SectionName}.{name}"); } if (settings.GetBool("enabled") is false) { continue; } var isDefault = name.Equals(DefaultName, StringComparison.OrdinalIgnoreCase); var pluginsSection = settings.GetSection("plugins"); var pluginEntries = pluginsSection.GetChildren().ToArray(); var plugins = settings.GetChildren().Any(child => child.Key.Equals("plugins", StringComparison.OrdinalIgnoreCase)) ? pluginEntries.Select(child => child.Value ?? string.Empty).ToArray() : ["aspire"]; if (pluginsSection.Value is not null || plugins.Length == 0 || plugins.Any(plugin => !IsIdentifier(plugin)) || pluginEntries.Where((child, index) => child.Key != index.ToString(CultureInfo.InvariantCulture) || child.GetChildren().Any()).Any()) { throw AspireSkillsRemoteConfiguration.ExistingConfiguration($"{SectionName}.{name}.plugins"); } var source = new SkillSource { Repository = settings["repository"] ?? (isDefault ? BuiltIn.Repository : null), Marketplace = settings["marketplace"] ?? (isDefault ? AspireSkillsRemoteConfiguration.MarketplaceName : name), Plugins = plugins, Ref = settings["ref"], OpenCodeV1Catalog = settings["openCodeV1Catalog"] ?? (isDefault ? BuiltIn.OpenCodeV1Catalog : null), OpenCodeV2Catalog = settings["openCodeV2Catalog"] ?? (isDefault ? BuiltIn.OpenCodeV2Catalog : null) }; if (!IsIdentifier(source.Marketplace) || !marketplaces.Add(source.Marketplace)) { throw AspireSkillsRemoteConfiguration.ExistingConfiguration($"{SectionName}.{name}.marketplace"); } if (string.IsNullOrWhiteSpace(source.Repository)) { throw AspireSkillsRemoteConfiguration.ExistingConfiguration($"{SectionName}.{name}.repository"); } if (!GitHelpers.IsValidRepository(source.GetRepositoryUrl()) || !GitHelpers.IsValidRef(source.Ref)) { throw AspireSkillsRemoteConfiguration.ExistingConfiguration($"{SectionName}.{name}.repository/ref"); } foreach (var catalog in new[] { source.OpenCodeV1Catalog, source.OpenCodeV2Catalog }) { if (catalog is not null && (!Uri.TryCreate(catalog, UriKind.Absolute, out var uri) || uri.Scheme != Uri.UriSchemeHttps || uri.UserInfo.Length != 0 || uri.Fragment.Length != 0)) { throw AspireSkillsRemoteConfiguration.ExistingConfiguration($"{SectionName}.{name}.openCodeCatalog"); } } sources.Add(source); } return sources; } internal static bool IsGitHubRepository(string repository) => repository.Split('/') is [var owner, var repo] && IsIdentifier(owner) && IsIdentifier(repo); private static bool IsIdentifier(string value) => value.Length > 0 && value.All(c => char.IsAsciiLetterOrDigit(c) || c is '-' or '_' or '.'); }