using System.Text.RegularExpressions; using Agent.Core.Skills; using YamlDotNet.Serialization; using YamlDotNet.Serialization.NamingConventions; namespace Agent.App.Configuration; public static class SkillLoader { private static readonly Regex NamePattern = new("^[a-z0-9]([a-z0-9-]*[a-z0-9])?$", RegexOptions.Compiled); public static IReadOnlyList Load(string directory) { if (!Directory.Exists(directory)) return []; var skills = new List(); var names = new HashSet(StringComparer.Ordinal); foreach (string child in Directory.EnumerateDirectories(directory, "*", SearchOption.TopDirectoryOnly)) { var info = new DirectoryInfo(child); if ((info.Attributes & FileAttributes.ReparsePoint) != 0) continue; string skillMd = Path.Combine(info.FullName, "SKILL.md"); if (!File.Exists(skillMd)) continue; string dirName = info.Name; string content = File.ReadAllText(skillMd); var (frontmatter, body) = SplitFrontmatter(content); if (frontmatter is null) throw new SkillLoadException($"Skill '{dirName}': SKILL.md is missing YAML frontmatter."); SkillFrontmatter fm = ParseFrontmatter(frontmatter); ValidateSkill(dirName, fm, names); skills.Add(new AgentSkill( RootPath: info.FullName, Name: fm.Name, Description: fm.Description, Instructions: body.Trim(), License: fm.License, Compatibility: fm.Compatibility, Metadata: fm.Metadata, AllowedTools: fm.AllowedTools)); } return skills; } private static void ValidateSkill(string dirName, SkillFrontmatter fm, HashSet names) { if (string.IsNullOrWhiteSpace(fm.Name)) throw new SkillLoadException($"Skill '{dirName}': name is required."); if (fm.Name.Length < 1 || fm.Name.Length > 64) throw new SkillLoadException($"Skill '{dirName}': name must be 1-64 characters."); if (!NamePattern.IsMatch(fm.Name)) throw new SkillLoadException($"Skill '{dirName}': name must be lowercase alphanumeric with hyphens."); if (!fm.Name.Equals(dirName, StringComparison.Ordinal)) throw new SkillLoadException($"Skill '{dirName}': name '{fm.Name}' does not match directory name."); if (string.IsNullOrWhiteSpace(fm.Description)) throw new SkillLoadException($"Skill '{dirName}': description is required."); if (fm.Description.Length > 1024) throw new SkillLoadException($"Skill '{dirName}': description must be 1-1024 characters."); if (!names.Add(fm.Name)) throw new SkillLoadException($"Duplicate skill name '{fm.Name}'."); } private static (string? Frontmatter, string Body) SplitFrontmatter(string content) { if (!content.StartsWith("---", StringComparison.Ordinal)) return (null, content); int end = content.IndexOf("\n---", 3, StringComparison.Ordinal); if (end < 0) return (null, content); string frontmatter = content[3..end].Trim(); int bodyStart = end + 4; if (bodyStart < content.Length && content[bodyStart] == '\r') bodyStart++; if (bodyStart < content.Length && content[bodyStart] == '\n') bodyStart++; string body = bodyStart < content.Length ? content[bodyStart..] : string.Empty; return (frontmatter, body); } private static SkillFrontmatter ParseFrontmatter(string yaml) { try { var deserializer = new DeserializerBuilder() .WithNamingConvention(UnderscoredNamingConvention.Instance) .Build(); return deserializer.Deserialize(yaml); } catch (Exception ex) { throw new SkillLoadException($"Malformed YAML frontmatter: {ex.Message}"); } } private sealed class SkillFrontmatter { public string Name { get; set; } = ""; public string Description { get; set; } = ""; public string? License { get; set; } public string? Compatibility { get; set; } public Dictionary? Metadata { get; set; } public string? AllowedTools { get; set; } } } public sealed class SkillLoadException(string message) : Exception(message);