From 180ee168c83ed7e5abf25307b9c30f47766a204a Mon Sep 17 00:00:00 2001 From: hatayama Date: Wed, 8 Jul 2026 19:01:43 +0900 Subject: [PATCH 1/2] Extract skill source frontmatter reader Move SKILL.md metadata parsing and tool-name matching out of SkillInstallLayout so the layout class keeps less source parsing responsibility without changing its entry points. --- .../SkillSetup/SkillInstallLayout.cs | 151 ++---------------- .../SkillSourceFrontmatterReader.cs | 141 ++++++++++++++++ .../SkillSourceFrontmatterReader.cs.meta | 11 ++ 3 files changed, 165 insertions(+), 138 deletions(-) create mode 100644 Packages/src/Editor/Infrastructure/SkillSetup/SkillSourceFrontmatterReader.cs create mode 100644 Packages/src/Editor/Infrastructure/SkillSetup/SkillSourceFrontmatterReader.cs.meta diff --git a/Packages/src/Editor/Infrastructure/SkillSetup/SkillInstallLayout.cs b/Packages/src/Editor/Infrastructure/SkillSetup/SkillInstallLayout.cs index 17c38d0821..9d77ad78d5 100644 --- a/Packages/src/Editor/Infrastructure/SkillSetup/SkillInstallLayout.cs +++ b/Packages/src/Editor/Infrastructure/SkillSetup/SkillInstallLayout.cs @@ -202,7 +202,7 @@ internal static bool SkillMatchesTool(string skillDir, string toolName) if (File.Exists(skillMdPath)) { string content = File.ReadAllText(skillMdPath); - if (SkillContentMatchesTool(content, skillDir, toolName)) + if (SkillSourceFrontmatterReader.SkillContentMatchesTool(content, skillDir, toolName)) { return true; } @@ -233,12 +233,12 @@ internal static HashSet GetInternalSkillToolNames(string projectRoot) } string skillContent = File.ReadAllText(skillFilePath); - if (!IsInternalSkill(skillContent)) + if (!SkillSourceFrontmatterReader.IsInternalSkill(skillContent)) { continue; } - string toolName = GetToolNameFromSkillContent(skillContent); + string toolName = SkillSourceFrontmatterReader.GetToolNameFromSkillContent(skillContent); if (!string.IsNullOrEmpty(toolName)) { toolNames.Add(toolName); @@ -264,7 +264,9 @@ internal static IReadOnlyDictionary GetToolDescriptionsByToolNam Dictionary descriptions = new(StringComparer.Ordinal); foreach (SkillSourceDefinition source in GetSkillSources(projectRoot).Values) { - string toolName = ResolveToolNameForSkillSource(source.Name, source.ToolName); + string toolName = SkillSourceFrontmatterReader.ResolveToolNameForSkillSource( + source.Name, + source.ToolName); if (string.IsNullOrEmpty(toolName) || string.IsNullOrWhiteSpace(source.Description)) { continue; @@ -287,12 +289,12 @@ internal static SkillSourceInfo GetSkillSourceInfoFromDirectory(string skillDire Debug.Assert(File.Exists(skillFilePath), "skill source must contain SKILL.md"); string skillContent = File.ReadAllText(skillFilePath); - string skillName = ParseNameFromFrontmatter(skillContent); + string skillName = SkillSourceFrontmatterReader.ParseNameFromFrontmatter(skillContent); Debug.Assert(IsSafeSkillPathComponent(skillName), "skillName must be a single safe path component"); return new SkillSourceInfo( skillName, - ParseToolNameFromFrontmatter(skillContent), + SkillSourceFrontmatterReader.ParseToolNameFromFrontmatter(skillContent), CollectSourceSkillFiles(skillDirectory, skillFilePath)); } @@ -371,7 +373,7 @@ private static bool IsLegacyManagedSkillDirectory(string skillDir) } string content = File.ReadAllText(skillMdPath); - if (!string.IsNullOrEmpty(ParseToolNameFromFrontmatter(content))) + if (!string.IsNullOrEmpty(SkillSourceFrontmatterReader.ParseToolNameFromFrontmatter(content))) { return true; } @@ -634,12 +636,12 @@ private static Dictionary GetSkillSources(string } string skillContent = File.ReadAllText(skillFilePath); - if (IsInternalSkill(skillContent)) + if (SkillSourceFrontmatterReader.IsInternalSkill(skillContent)) { continue; } - string skillName = ParseNameFromFrontmatter(skillContent); + string skillName = SkillSourceFrontmatterReader.ParseNameFromFrontmatter(skillContent); if (string.IsNullOrEmpty(skillName) || !IsSafeSkillPathComponent(skillName) || sources.ContainsKey(skillName)) @@ -649,8 +651,8 @@ private static Dictionary GetSkillSources(string sources[skillName] = new SkillSourceDefinition( skillName, - ParseToolNameFromFrontmatter(skillContent), - ParseDescriptionFromFrontmatter(skillContent), + SkillSourceFrontmatterReader.ParseToolNameFromFrontmatter(skillContent), + SkillSourceFrontmatterReader.ParseDescriptionFromFrontmatter(skillContent), skillDirectory, CollectSourceSkillFiles(skillDirectory, skillFilePath)); } @@ -910,133 +912,6 @@ private static IEnumerable EnumerateEditorFoldersRecursive( } } - private static string ParseToolNameFromFrontmatter(string content) - { - Match frontmatterMatch = Regex.Match(content, @"^---\r?\n([\s\S]*?)\r?\n---"); - if (!frontmatterMatch.Success) - { - return null; - } - - string frontmatter = frontmatterMatch.Groups[1].Value; - Match toolNameMatch = Regex.Match(frontmatter, @"^toolName:\s*(.+)$", RegexOptions.Multiline); - if (!toolNameMatch.Success) - { - return null; - } - - return toolNameMatch.Groups[1].Value.Trim(); - } - - private static string ParseNameFromFrontmatter(string content) - { - Match frontmatterMatch = Regex.Match(content, @"^---\r?\n([\s\S]*?)\r?\n---"); - if (!frontmatterMatch.Success) - { - return null; - } - - string frontmatter = frontmatterMatch.Groups[1].Value; - Match nameMatch = Regex.Match(frontmatter, @"^name:\s*(.+)$", RegexOptions.Multiline); - if (!nameMatch.Success) - { - return null; - } - - return nameMatch.Groups[1].Value.Trim().Trim('"'); - } - - private static string ParseDescriptionFromFrontmatter(string content) - { - Match frontmatterMatch = Regex.Match(content, @"^---\r?\n([\s\S]*?)\r?\n---"); - if (!frontmatterMatch.Success) - { - return null; - } - - string frontmatter = frontmatterMatch.Groups[1].Value; - Match descriptionMatch = Regex.Match(frontmatter, @"^description:\s*(.+)$", RegexOptions.Multiline); - if (!descriptionMatch.Success) - { - return null; - } - - return descriptionMatch.Groups[1].Value.Trim().Trim('"'); - } - - private static string ResolveToolNameForSkillSource(string skillName, string toolName) - { - if (!string.IsNullOrEmpty(toolName)) - { - return toolName; - } - - if (string.IsNullOrEmpty(skillName) - || !skillName.StartsWith(CliConstants.SKILL_DIR_PREFIX, StringComparison.Ordinal)) - { - return null; - } - - return skillName.Substring(CliConstants.SKILL_DIR_PREFIX.Length); - } - - private static bool SkillContentMatchesTool(string content, string skillDirectory, string toolName) - { - string parsedToolName = ParseToolNameFromFrontmatter(content); - if (!string.IsNullOrEmpty(parsedToolName)) - { - return parsedToolName == toolName; - } - - string parsedSkillName = ParseNameFromFrontmatter(content); - if (!string.IsNullOrEmpty(parsedSkillName)) - { - return parsedSkillName == $"{CliConstants.SKILL_DIR_PREFIX}{toolName}"; - } - - string dirName = Path.GetFileName(skillDirectory); - return dirName == $"{CliConstants.SKILL_DIR_PREFIX}{toolName}"; - } - - private static string GetToolNameFromSkillContent(string content) - { - string parsedToolName = ParseToolNameFromFrontmatter(content); - if (!string.IsNullOrEmpty(parsedToolName)) - { - return parsedToolName; - } - - string parsedSkillName = ParseNameFromFrontmatter(content); - if (string.IsNullOrEmpty(parsedSkillName) - || !parsedSkillName.StartsWith(CliConstants.SKILL_DIR_PREFIX, StringComparison.Ordinal)) - { - return null; - } - - return parsedSkillName.Substring(CliConstants.SKILL_DIR_PREFIX.Length); - } - - private static bool IsInternalSkill(string content) - { - Match frontmatterMatch = Regex.Match(content, @"^---\r?\n([\s\S]*?)\r?\n---"); - if (!frontmatterMatch.Success) - { - return false; - } - - string frontmatter = frontmatterMatch.Groups[1].Value; - Match internalMatch = Regex.Match(frontmatter, @"^internal:\s*(.+)$", RegexOptions.Multiline); - if (!internalMatch.Success) - { - return false; - } - - return string.Equals( - internalMatch.Groups[1].Value.Trim(), - "true", - StringComparison.OrdinalIgnoreCase); - } - private static bool IsSafeSkillPathComponent(string skillName) { if (string.IsNullOrEmpty(skillName)) diff --git a/Packages/src/Editor/Infrastructure/SkillSetup/SkillSourceFrontmatterReader.cs b/Packages/src/Editor/Infrastructure/SkillSetup/SkillSourceFrontmatterReader.cs new file mode 100644 index 0000000000..f2885e03e1 --- /dev/null +++ b/Packages/src/Editor/Infrastructure/SkillSetup/SkillSourceFrontmatterReader.cs @@ -0,0 +1,141 @@ +using System; +using System.IO; +using System.Text.RegularExpressions; + +using io.github.hatayama.UnityCliLoop.Domain; + +namespace io.github.hatayama.UnityCliLoop.Infrastructure +{ + /// + /// Reads Unity CLI Loop skill metadata from SKILL.md frontmatter. + /// + internal static class SkillSourceFrontmatterReader + { + internal static string ParseToolNameFromFrontmatter(string content) + { + Match frontmatterMatch = Regex.Match(content, @"^---\r?\n([\s\S]*?)\r?\n---"); + if (!frontmatterMatch.Success) + { + return null; + } + + string frontmatter = frontmatterMatch.Groups[1].Value; + Match toolNameMatch = Regex.Match(frontmatter, @"^toolName:\s*(.+)$", RegexOptions.Multiline); + if (!toolNameMatch.Success) + { + return null; + } + + return toolNameMatch.Groups[1].Value.Trim(); + } + + internal static string ParseNameFromFrontmatter(string content) + { + Match frontmatterMatch = Regex.Match(content, @"^---\r?\n([\s\S]*?)\r?\n---"); + if (!frontmatterMatch.Success) + { + return null; + } + + string frontmatter = frontmatterMatch.Groups[1].Value; + Match nameMatch = Regex.Match(frontmatter, @"^name:\s*(.+)$", RegexOptions.Multiline); + if (!nameMatch.Success) + { + return null; + } + + return nameMatch.Groups[1].Value.Trim().Trim('"'); + } + + internal static string ParseDescriptionFromFrontmatter(string content) + { + Match frontmatterMatch = Regex.Match(content, @"^---\r?\n([\s\S]*?)\r?\n---"); + if (!frontmatterMatch.Success) + { + return null; + } + + string frontmatter = frontmatterMatch.Groups[1].Value; + Match descriptionMatch = Regex.Match(frontmatter, @"^description:\s*(.+)$", RegexOptions.Multiline); + if (!descriptionMatch.Success) + { + return null; + } + + return descriptionMatch.Groups[1].Value.Trim().Trim('"'); + } + + internal static string ResolveToolNameForSkillSource(string skillName, string toolName) + { + if (!string.IsNullOrEmpty(toolName)) + { + return toolName; + } + + if (string.IsNullOrEmpty(skillName) + || !skillName.StartsWith(CliConstants.SKILL_DIR_PREFIX, StringComparison.Ordinal)) + { + return null; + } + + return skillName.Substring(CliConstants.SKILL_DIR_PREFIX.Length); + } + + internal static bool SkillContentMatchesTool(string content, string skillDirectory, string toolName) + { + string parsedToolName = ParseToolNameFromFrontmatter(content); + if (!string.IsNullOrEmpty(parsedToolName)) + { + return parsedToolName == toolName; + } + + string parsedSkillName = ParseNameFromFrontmatter(content); + if (!string.IsNullOrEmpty(parsedSkillName)) + { + return parsedSkillName == $"{CliConstants.SKILL_DIR_PREFIX}{toolName}"; + } + + string dirName = Path.GetFileName(skillDirectory); + return dirName == $"{CliConstants.SKILL_DIR_PREFIX}{toolName}"; + } + + internal static string GetToolNameFromSkillContent(string content) + { + string parsedToolName = ParseToolNameFromFrontmatter(content); + if (!string.IsNullOrEmpty(parsedToolName)) + { + return parsedToolName; + } + + string parsedSkillName = ParseNameFromFrontmatter(content); + if (string.IsNullOrEmpty(parsedSkillName) + || !parsedSkillName.StartsWith(CliConstants.SKILL_DIR_PREFIX, StringComparison.Ordinal)) + { + return null; + } + + return parsedSkillName.Substring(CliConstants.SKILL_DIR_PREFIX.Length); + } + + internal static bool IsInternalSkill(string content) + { + Match frontmatterMatch = Regex.Match(content, @"^---\r?\n([\s\S]*?)\r?\n---"); + if (!frontmatterMatch.Success) + { + return false; + } + + string frontmatter = frontmatterMatch.Groups[1].Value; + Match internalMatch = Regex.Match(frontmatter, @"^internal:\s*(.+)$", RegexOptions.Multiline); + if (!internalMatch.Success) + { + return false; + } + + return string.Equals( + internalMatch.Groups[1].Value.Trim(), + "true", + StringComparison.OrdinalIgnoreCase); + } + } +} diff --git a/Packages/src/Editor/Infrastructure/SkillSetup/SkillSourceFrontmatterReader.cs.meta b/Packages/src/Editor/Infrastructure/SkillSetup/SkillSourceFrontmatterReader.cs.meta new file mode 100644 index 0000000000..048a0a0f35 --- /dev/null +++ b/Packages/src/Editor/Infrastructure/SkillSetup/SkillSourceFrontmatterReader.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: dff1a8028aff445e9a633b08e1d7af5e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: From be672a9dbfa18f63cf13914b3b28f4a7c6d9bb48 Mon Sep 17 00:00:00 2001 From: hatayama Date: Wed, 8 Jul 2026 19:07:28 +0900 Subject: [PATCH 2/2] Extract skill source root discovery Move skill source root enumeration and SKILL.md source discovery out of SkillInstallLayout while keeping the existing SkillInstallLayout entry points as delegating wrappers. --- .../SkillSetup/SkillInstallLayout.cs | 394 +---------------- .../SkillSetup/SkillSourceRootEnumerator.cs | 408 ++++++++++++++++++ .../SkillSourceRootEnumerator.cs.meta | 11 + 3 files changed, 429 insertions(+), 384 deletions(-) create mode 100644 Packages/src/Editor/Infrastructure/SkillSetup/SkillSourceRootEnumerator.cs create mode 100644 Packages/src/Editor/Infrastructure/SkillSetup/SkillSourceRootEnumerator.cs.meta diff --git a/Packages/src/Editor/Infrastructure/SkillSetup/SkillInstallLayout.cs b/Packages/src/Editor/Infrastructure/SkillSetup/SkillInstallLayout.cs index 9d77ad78d5..f4483bc538 100644 --- a/Packages/src/Editor/Infrastructure/SkillSetup/SkillInstallLayout.cs +++ b/Packages/src/Editor/Infrastructure/SkillSetup/SkillInstallLayout.cs @@ -2,7 +2,6 @@ using System.Collections.Generic; using System.IO; using System.Linq; -using System.Text.RegularExpressions; using UnityEngine; @@ -26,8 +25,6 @@ internal static class SkillInstallLayout private const int Utf16CodeUnitByteCount = 2; private const ushort CarriageReturnCodeUnit = 0x000D; private const ushort LineFeedCodeUnit = 0x000A; - private const string EditorDirName = "Editor"; - private const string CliOnlyToolsDirName = "CliOnlyTools~"; private static readonly HashSet TextSkillFileExtensions = new(StringComparer.OrdinalIgnoreCase) { ".json", @@ -39,32 +36,6 @@ internal static class SkillInstallLayout ".yml" }; - /// - /// Provides Skill Source Definition behavior for Unity CLI Loop. - /// - private sealed class SkillSourceDefinition - { - public readonly string Name; - public readonly string ToolName; - public readonly string Description; - public readonly string SkillDirectoryPath; - public readonly Dictionary SkillFiles; - - public SkillSourceDefinition( - string name, - string toolName, - string description, - string skillDirectoryPath, - Dictionary skillFiles) - { - Name = name; - ToolName = toolName; - Description = description; - SkillDirectoryPath = skillDirectoryPath; - SkillFiles = skillFiles; - } - } - internal readonly struct SkillSourceInfo { public readonly string Name; @@ -144,14 +115,14 @@ internal static bool HasInstalledSkillsForLayout( return true; } - Dictionary expectedSkills = GetSkillSources(projectRoot); + List expectedSkills = SkillSourceRootEnumerator.GetSkillSourceInfos(projectRoot); if (expectedSkills.Count == 0) { return false; } - return expectedSkills.Keys.Any(skillName => - Directory.Exists(GetInstalledSkillDirectoryPath(targetRoot, skillName, groupSkillsUnderUnityCliLoop))); + return expectedSkills.Any(skill => + Directory.Exists(GetInstalledSkillDirectoryPath(targetRoot, skill.Name, groupSkillsUnderUnityCliLoop))); } internal static SkillInstallState GetInstalledState( @@ -159,7 +130,7 @@ internal static SkillInstallState GetInstalledState( string targetRoot, bool groupSkillsUnderUnityCliLoop) { - Dictionary expectedSkills = GetSkillSources(projectRoot); + List expectedSkills = SkillSourceRootEnumerator.GetSkillSourceInfos(projectRoot); bool hasLayoutSkills = HasInstalledSkillsForLayout(projectRoot, targetRoot, groupSkillsUnderUnityCliLoop); if (expectedSkills.Count == 0) { @@ -169,7 +140,7 @@ internal static SkillInstallState GetInstalledState( bool hasInstalledExpectedSkill = false; bool hasMissingExpectedSkill = false; - foreach (SkillSourceDefinition expectedSkill in expectedSkills.Values) + foreach (SkillSourceInfo expectedSkill in expectedSkills) { string installedSkillDirectory = GetInstalledSkillDirectoryPath( targetRoot, @@ -216,69 +187,19 @@ internal static HashSet GetInternalSkillToolNames(string projectRoot) { Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty"); - HashSet toolNames = new(StringComparer.Ordinal); - foreach (string searchRoot in EnumerateSkillSourceRoots(projectRoot)) - { - if (!Directory.Exists(searchRoot)) - { - continue; - } - - foreach (string skillFilePath in EnumerateSourceSkillFiles(searchRoot)) - { - string skillDirectory = Path.GetDirectoryName(skillFilePath); - if (skillDirectory == null) - { - continue; - } - - string skillContent = File.ReadAllText(skillFilePath); - if (!SkillSourceFrontmatterReader.IsInternalSkill(skillContent)) - { - continue; - } - - string toolName = SkillSourceFrontmatterReader.GetToolNameFromSkillContent(skillContent); - if (!string.IsNullOrEmpty(toolName)) - { - toolNames.Add(toolName); - } - } - } - - return toolNames; + return SkillSourceRootEnumerator.GetInternalSkillToolNames(projectRoot); } internal static List GetSkillSourceInfos(string projectRoot) { - return GetSkillSources(projectRoot) - .Values - .Select(source => new SkillSourceInfo(source.Name, source.ToolName, source.SkillFiles)) - .ToList(); + return SkillSourceRootEnumerator.GetSkillSourceInfos(projectRoot); } internal static IReadOnlyDictionary GetToolDescriptionsByToolName(string projectRoot) { Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty"); - Dictionary descriptions = new(StringComparer.Ordinal); - foreach (SkillSourceDefinition source in GetSkillSources(projectRoot).Values) - { - string toolName = SkillSourceFrontmatterReader.ResolveToolNameForSkillSource( - source.Name, - source.ToolName); - if (string.IsNullOrEmpty(toolName) || string.IsNullOrWhiteSpace(source.Description)) - { - continue; - } - - if (!descriptions.ContainsKey(toolName)) - { - descriptions[toolName] = source.Description; - } - } - - return descriptions; + return SkillSourceRootEnumerator.GetToolDescriptionsByToolName(projectRoot); } internal static SkillSourceInfo GetSkillSourceInfoFromDirectory(string skillDirectory) @@ -438,7 +359,7 @@ private static Dictionary CollectInstalledSkillFiles(string skil return files; } - private static Dictionary CollectSourceSkillFiles( + internal static Dictionary CollectSourceSkillFiles( string skillDirectory, string skillFilePath) { @@ -617,302 +538,7 @@ private static void WriteUtf16CodeUnit(List output, ushort codeUnit, bool output.Add((byte)(codeUnit & 0xFF)); } - private static Dictionary GetSkillSources(string projectRoot) - { - Dictionary sources = new(StringComparer.Ordinal); - foreach (string searchRoot in EnumerateSkillSourceRoots(projectRoot)) - { - if (!Directory.Exists(searchRoot)) - { - continue; - } - - foreach (string skillFilePath in EnumerateSourceSkillFiles(searchRoot)) - { - string skillDirectory = Path.GetDirectoryName(skillFilePath); - if (skillDirectory == null) - { - continue; - } - - string skillContent = File.ReadAllText(skillFilePath); - if (SkillSourceFrontmatterReader.IsInternalSkill(skillContent)) - { - continue; - } - - string skillName = SkillSourceFrontmatterReader.ParseNameFromFrontmatter(skillContent); - if (string.IsNullOrEmpty(skillName) - || !IsSafeSkillPathComponent(skillName) - || sources.ContainsKey(skillName)) - { - continue; - } - - sources[skillName] = new SkillSourceDefinition( - skillName, - SkillSourceFrontmatterReader.ParseToolNameFromFrontmatter(skillContent), - SkillSourceFrontmatterReader.ParseDescriptionFromFrontmatter(skillContent), - skillDirectory, - CollectSourceSkillFiles(skillDirectory, skillFilePath)); - } - } - - return sources; - } - - private static IEnumerable EnumerateSourceSkillFiles(string searchRoot) - { - if (IsCliOnlySkillSourceRoot(searchRoot)) - { - return Directory.EnumerateFiles(searchRoot, SkillFileName, SearchOption.AllDirectories); - } - - return EnumerateEditorFolders(searchRoot, 3).SelectMany(editorFolder => - Directory.EnumerateFiles(editorFolder, SkillFileName, SearchOption.AllDirectories)); - } - - private static IEnumerable EnumerateSkillSourceRoots(string projectRoot) - { - HashSet seenRoots = new(StringComparer.Ordinal); - - AddSkillSourceRoot(seenRoots, GetCliOnlySkillSourceRoot(projectRoot)); - AddSkillSourceRoot(seenRoots, Path.Combine(projectRoot, "Assets")); - foreach (string packageRoot in EnumerateDirectProjectPackageRoots(projectRoot)) - { - AddSkillSourceRoot(seenRoots, packageRoot); - } - - foreach (string packageRoot in EnumerateManifestLocalPackageRoots(projectRoot)) - { - AddSkillSourceRoot(seenRoots, packageRoot); - } - - foreach (string packageRoot in EnumerateDependencyPackageCacheRoots(projectRoot)) - { - AddSkillSourceRoot(seenRoots, packageRoot); - } - - foreach (string root in seenRoots) - { - yield return root; - } - } - - private static void AddSkillSourceRoot(HashSet roots, string root) - { - if (string.IsNullOrEmpty(root)) - { - return; - } - - roots.Add(Path.GetFullPath(root)); - } - - private static string GetCliOnlySkillSourceRoot(string projectRoot) - { - string currentProjectRoot = UnityCliLoopPathResolver.GetProjectRoot(); - if (!string.Equals( - Path.GetFullPath(projectRoot), - Path.GetFullPath(currentProjectRoot), - StringComparison.Ordinal)) - { - return null; - } - - return Path.Combine( - UnityCliLoopConstants.PackageResolvedPath, - EditorDirName, - CliOnlyToolsDirName); - } - - private static bool IsCliOnlySkillSourceRoot(string searchRoot) - { - return string.Equals( - Path.GetFullPath(searchRoot), - Path.GetFullPath(GetCliOnlySkillSourceRoot(UnityCliLoopPathResolver.GetProjectRoot())), - StringComparison.Ordinal); - } - - private static IEnumerable EnumerateDirectProjectPackageRoots(string projectRoot) - { - string packagesRoot = Path.Combine(projectRoot, "Packages"); - if (!Directory.Exists(packagesRoot)) - { - yield break; - } - - foreach (string packageDirectory in Directory.EnumerateDirectories(packagesRoot)) - { - yield return ResolveSkillSearchRootCandidate(packageDirectory); - } - } - - private static IEnumerable EnumerateManifestLocalPackageRoots(string projectRoot) - { - foreach (KeyValuePair dependency in EnumerateManifestDependencies(projectRoot)) - { - string localPath = ResolveLocalDependencyPath(dependency.Value, projectRoot); - if (string.IsNullOrEmpty(localPath)) - { - continue; - } - - yield return ResolveSkillSearchRootCandidate(localPath); - } - } - - private static IEnumerable EnumerateDependencyPackageCacheRoots(string projectRoot) - { - HashSet dependencyNames = new( - EnumerateManifestDependencies(projectRoot).Select(dependency => dependency.Key), - StringComparer.OrdinalIgnoreCase); - if (dependencyNames.Count == 0) - { - yield break; - } - - string packageCacheRoot = Path.Combine(projectRoot, "Library", "PackageCache"); - if (!Directory.Exists(packageCacheRoot)) - { - yield break; - } - - foreach (string packageDirectory in Directory.EnumerateDirectories(packageCacheRoot)) - { - string packageName = Path.GetFileName(packageDirectory); - if (string.IsNullOrEmpty(packageName)) - { - continue; - } - - int separatorIndex = packageName.IndexOf('@'); - string dependencyName = separatorIndex >= 0 ? packageName.Substring(0, separatorIndex) : packageName; - if (!dependencyNames.Contains(dependencyName)) - { - continue; - } - - yield return ResolveSkillSearchRootCandidate(packageDirectory); - } - } - - private static IEnumerable> EnumerateManifestDependencies(string projectRoot) - { - string manifestPath = Path.Combine(projectRoot, "Packages", "manifest.json"); - if (!File.Exists(manifestPath)) - { - yield break; - } - - string manifestContent = File.ReadAllText(manifestPath); - Match dependenciesMatch = Regex.Match( - manifestContent, - "\"dependencies\"\\s*:\\s*\\{(?[\\s\\S]*?)\\}", - RegexOptions.Multiline); - if (!dependenciesMatch.Success) - { - yield break; - } - - MatchCollection dependencyMatches = Regex.Matches( - dependenciesMatch.Groups["body"].Value, - "\"(?[^\"]+)\"\\s*:\\s*\"(?[^\"]*)\""); - foreach (Match dependencyMatch in dependencyMatches) - { - string dependencyName = dependencyMatch.Groups["name"].Value; - string dependencyValue = dependencyMatch.Groups["value"].Value; - if (string.IsNullOrEmpty(dependencyName) || string.IsNullOrEmpty(dependencyValue)) - { - continue; - } - - yield return new KeyValuePair(dependencyName, dependencyValue); - } - } - - private static string ResolveLocalDependencyPath(string dependencyValue, string projectRoot) - { - const string FilePrefix = "file:"; - const string PathPrefix = "path:"; - - if (dependencyValue.StartsWith(FilePrefix, StringComparison.Ordinal)) - { - return ResolveDependencyPath(dependencyValue.Substring(FilePrefix.Length), projectRoot); - } - - if (dependencyValue.StartsWith(PathPrefix, StringComparison.Ordinal)) - { - return ResolveDependencyPath(dependencyValue.Substring(PathPrefix.Length), projectRoot); - } - - return null; - } - - private static string ResolveDependencyPath(string rawPath, string projectRoot) - { - if (string.IsNullOrWhiteSpace(rawPath)) - { - return null; - } - - string normalizedPath = rawPath.Trim(); - if (normalizedPath.StartsWith("//", StringComparison.Ordinal)) - { - normalizedPath = normalizedPath.Substring(2); - } - - if (Path.IsPathRooted(normalizedPath)) - { - return normalizedPath; - } - - return Path.GetFullPath(Path.Combine(projectRoot, normalizedPath)); - } - - private static string ResolveSkillSearchRootCandidate(string candidate) - { - string nestedRoot = Path.Combine(candidate, "Packages", "src"); - if (Directory.Exists(nestedRoot)) - { - return nestedRoot; - } - - return candidate; - } - - private static IEnumerable EnumerateEditorFolders(string basePath, int maxDepth) - { - return EnumerateEditorFoldersRecursive(basePath, depth: 0, maxDepth); - } - - private static IEnumerable EnumerateEditorFoldersRecursive( - string currentPath, - int depth, - int maxDepth) - { - if (depth > maxDepth || !Directory.Exists(currentPath)) - { - yield break; - } - - foreach (string directory in Directory.EnumerateDirectories(currentPath)) - { - string directoryName = Path.GetFileName(directory); - if (string.Equals(directoryName, "Editor", StringComparison.Ordinal)) - { - yield return directory; - continue; - } - - foreach (string editorDirectory in EnumerateEditorFoldersRecursive(directory, depth + 1, maxDepth)) - { - yield return editorDirectory; - } - } - } - - private static bool IsSafeSkillPathComponent(string skillName) + internal static bool IsSafeSkillPathComponent(string skillName) { if (string.IsNullOrEmpty(skillName)) { diff --git a/Packages/src/Editor/Infrastructure/SkillSetup/SkillSourceRootEnumerator.cs b/Packages/src/Editor/Infrastructure/SkillSetup/SkillSourceRootEnumerator.cs new file mode 100644 index 0000000000..4c73d96b89 --- /dev/null +++ b/Packages/src/Editor/Infrastructure/SkillSetup/SkillSourceRootEnumerator.cs @@ -0,0 +1,408 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; + +using UnityEngine; + +using io.github.hatayama.UnityCliLoop.ToolContracts; + +namespace io.github.hatayama.UnityCliLoop.Infrastructure +{ + /// + /// Discovers project skill source roots and turns SKILL.md files into skill source metadata. + /// + internal static class SkillSourceRootEnumerator + { + private const string EditorDirName = "Editor"; + private const string CliOnlyToolsDirName = "CliOnlyTools~"; + + private sealed class SkillSourceDefinition + { + public readonly string Name; + public readonly string ToolName; + public readonly string Description; + public readonly string SkillDirectoryPath; + public readonly Dictionary SkillFiles; + + public SkillSourceDefinition( + string name, + string toolName, + string description, + string skillDirectoryPath, + Dictionary skillFiles) + { + Name = name; + ToolName = toolName; + Description = description; + SkillDirectoryPath = skillDirectoryPath; + SkillFiles = skillFiles; + } + } + + internal static HashSet GetInternalSkillToolNames(string projectRoot) + { + Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty"); + + HashSet toolNames = new(StringComparer.Ordinal); + foreach (string searchRoot in EnumerateSkillSourceRoots(projectRoot)) + { + if (!Directory.Exists(searchRoot)) + { + continue; + } + + foreach (string skillFilePath in EnumerateSourceSkillFiles(searchRoot)) + { + string skillDirectory = Path.GetDirectoryName(skillFilePath); + if (skillDirectory == null) + { + continue; + } + + string skillContent = File.ReadAllText(skillFilePath); + if (!SkillSourceFrontmatterReader.IsInternalSkill(skillContent)) + { + continue; + } + + string toolName = SkillSourceFrontmatterReader.GetToolNameFromSkillContent(skillContent); + if (!string.IsNullOrEmpty(toolName)) + { + toolNames.Add(toolName); + } + } + } + + return toolNames; + } + + internal static List GetSkillSourceInfos(string projectRoot) + { + return GetSkillSources(projectRoot) + .Values + .Select(source => new SkillInstallLayout.SkillSourceInfo(source.Name, source.ToolName, source.SkillFiles)) + .ToList(); + } + + internal static IReadOnlyDictionary GetToolDescriptionsByToolName(string projectRoot) + { + Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty"); + + Dictionary descriptions = new(StringComparer.Ordinal); + foreach (SkillSourceDefinition source in GetSkillSources(projectRoot).Values) + { + string toolName = SkillSourceFrontmatterReader.ResolveToolNameForSkillSource( + source.Name, + source.ToolName); + if (string.IsNullOrEmpty(toolName) || string.IsNullOrWhiteSpace(source.Description)) + { + continue; + } + + if (!descriptions.ContainsKey(toolName)) + { + descriptions[toolName] = source.Description; + } + } + + return descriptions; + } + + private static Dictionary GetSkillSources(string projectRoot) + { + Dictionary sources = new(StringComparer.Ordinal); + foreach (string searchRoot in EnumerateSkillSourceRoots(projectRoot)) + { + if (!Directory.Exists(searchRoot)) + { + continue; + } + + foreach (string skillFilePath in EnumerateSourceSkillFiles(searchRoot)) + { + string skillDirectory = Path.GetDirectoryName(skillFilePath); + if (skillDirectory == null) + { + continue; + } + + string skillContent = File.ReadAllText(skillFilePath); + if (SkillSourceFrontmatterReader.IsInternalSkill(skillContent)) + { + continue; + } + + string skillName = SkillSourceFrontmatterReader.ParseNameFromFrontmatter(skillContent); + if (string.IsNullOrEmpty(skillName) + || !SkillInstallLayout.IsSafeSkillPathComponent(skillName) + || sources.ContainsKey(skillName)) + { + continue; + } + + sources[skillName] = new SkillSourceDefinition( + skillName, + SkillSourceFrontmatterReader.ParseToolNameFromFrontmatter(skillContent), + SkillSourceFrontmatterReader.ParseDescriptionFromFrontmatter(skillContent), + skillDirectory, + SkillInstallLayout.CollectSourceSkillFiles(skillDirectory, skillFilePath)); + } + } + + return sources; + } + + private static IEnumerable EnumerateSourceSkillFiles(string searchRoot) + { + if (IsCliOnlySkillSourceRoot(searchRoot)) + { + return Directory.EnumerateFiles(searchRoot, SkillInstallLayout.SkillFileName, SearchOption.AllDirectories); + } + + return EnumerateEditorFolders(searchRoot, 3).SelectMany(editorFolder => + Directory.EnumerateFiles(editorFolder, SkillInstallLayout.SkillFileName, SearchOption.AllDirectories)); + } + + private static IEnumerable EnumerateSkillSourceRoots(string projectRoot) + { + HashSet seenRoots = new(StringComparer.Ordinal); + + AddSkillSourceRoot(seenRoots, GetCliOnlySkillSourceRoot(projectRoot)); + AddSkillSourceRoot(seenRoots, Path.Combine(projectRoot, "Assets")); + foreach (string packageRoot in EnumerateDirectProjectPackageRoots(projectRoot)) + { + AddSkillSourceRoot(seenRoots, packageRoot); + } + + foreach (string packageRoot in EnumerateManifestLocalPackageRoots(projectRoot)) + { + AddSkillSourceRoot(seenRoots, packageRoot); + } + + foreach (string packageRoot in EnumerateDependencyPackageCacheRoots(projectRoot)) + { + AddSkillSourceRoot(seenRoots, packageRoot); + } + + foreach (string root in seenRoots) + { + yield return root; + } + } + + private static void AddSkillSourceRoot(HashSet roots, string root) + { + if (string.IsNullOrEmpty(root)) + { + return; + } + + roots.Add(Path.GetFullPath(root)); + } + + private static string GetCliOnlySkillSourceRoot(string projectRoot) + { + string currentProjectRoot = UnityCliLoopPathResolver.GetProjectRoot(); + if (!string.Equals( + Path.GetFullPath(projectRoot), + Path.GetFullPath(currentProjectRoot), + StringComparison.Ordinal)) + { + return null; + } + + return Path.Combine( + UnityCliLoopConstants.PackageResolvedPath, + EditorDirName, + CliOnlyToolsDirName); + } + + private static bool IsCliOnlySkillSourceRoot(string searchRoot) + { + return string.Equals( + Path.GetFullPath(searchRoot), + Path.GetFullPath(GetCliOnlySkillSourceRoot(UnityCliLoopPathResolver.GetProjectRoot())), + StringComparison.Ordinal); + } + + private static IEnumerable EnumerateDirectProjectPackageRoots(string projectRoot) + { + string packagesRoot = Path.Combine(projectRoot, "Packages"); + if (!Directory.Exists(packagesRoot)) + { + yield break; + } + + foreach (string packageDirectory in Directory.EnumerateDirectories(packagesRoot)) + { + yield return ResolveSkillSearchRootCandidate(packageDirectory); + } + } + + private static IEnumerable EnumerateManifestLocalPackageRoots(string projectRoot) + { + foreach (KeyValuePair dependency in EnumerateManifestDependencies(projectRoot)) + { + string localPath = ResolveLocalDependencyPath(dependency.Value, projectRoot); + if (string.IsNullOrEmpty(localPath)) + { + continue; + } + + yield return ResolveSkillSearchRootCandidate(localPath); + } + } + + private static IEnumerable EnumerateDependencyPackageCacheRoots(string projectRoot) + { + HashSet dependencyNames = new( + EnumerateManifestDependencies(projectRoot).Select(dependency => dependency.Key), + StringComparer.OrdinalIgnoreCase); + if (dependencyNames.Count == 0) + { + yield break; + } + + string packageCacheRoot = Path.Combine(projectRoot, "Library", "PackageCache"); + if (!Directory.Exists(packageCacheRoot)) + { + yield break; + } + + foreach (string packageDirectory in Directory.EnumerateDirectories(packageCacheRoot)) + { + string packageName = Path.GetFileName(packageDirectory); + if (string.IsNullOrEmpty(packageName)) + { + continue; + } + + int separatorIndex = packageName.IndexOf('@'); + string dependencyName = separatorIndex >= 0 ? packageName.Substring(0, separatorIndex) : packageName; + if (!dependencyNames.Contains(dependencyName)) + { + continue; + } + + yield return ResolveSkillSearchRootCandidate(packageDirectory); + } + } + + private static IEnumerable> EnumerateManifestDependencies(string projectRoot) + { + string manifestPath = Path.Combine(projectRoot, "Packages", "manifest.json"); + if (!File.Exists(manifestPath)) + { + yield break; + } + + string manifestContent = File.ReadAllText(manifestPath); + Match dependenciesMatch = Regex.Match( + manifestContent, + "\"dependencies\"\\s*:\\s*\\{(?[\\s\\S]*?)\\}", + RegexOptions.Multiline); + if (!dependenciesMatch.Success) + { + yield break; + } + + MatchCollection dependencyMatches = Regex.Matches( + dependenciesMatch.Groups["body"].Value, + "\"(?[^\"]+)\"\\s*:\\s*\"(?[^\"]*)\""); + foreach (Match dependencyMatch in dependencyMatches) + { + string dependencyName = dependencyMatch.Groups["name"].Value; + string dependencyValue = dependencyMatch.Groups["value"].Value; + if (string.IsNullOrEmpty(dependencyName) || string.IsNullOrEmpty(dependencyValue)) + { + continue; + } + + yield return new KeyValuePair(dependencyName, dependencyValue); + } + } + + private static string ResolveLocalDependencyPath(string dependencyValue, string projectRoot) + { + const string FilePrefix = "file:"; + const string PathPrefix = "path:"; + + if (dependencyValue.StartsWith(FilePrefix, StringComparison.Ordinal)) + { + return ResolveDependencyPath(dependencyValue.Substring(FilePrefix.Length), projectRoot); + } + + if (dependencyValue.StartsWith(PathPrefix, StringComparison.Ordinal)) + { + return ResolveDependencyPath(dependencyValue.Substring(PathPrefix.Length), projectRoot); + } + + return null; + } + + private static string ResolveDependencyPath(string rawPath, string projectRoot) + { + if (string.IsNullOrWhiteSpace(rawPath)) + { + return null; + } + + string normalizedPath = rawPath.Trim(); + if (normalizedPath.StartsWith("//", StringComparison.Ordinal)) + { + normalizedPath = normalizedPath.Substring(2); + } + + if (Path.IsPathRooted(normalizedPath)) + { + return normalizedPath; + } + + return Path.GetFullPath(Path.Combine(projectRoot, normalizedPath)); + } + + private static string ResolveSkillSearchRootCandidate(string candidate) + { + string nestedRoot = Path.Combine(candidate, "Packages", "src"); + if (Directory.Exists(nestedRoot)) + { + return nestedRoot; + } + + return candidate; + } + + private static IEnumerable EnumerateEditorFolders(string basePath, int maxDepth) + { + return EnumerateEditorFoldersRecursive(basePath, depth: 0, maxDepth); + } + + private static IEnumerable EnumerateEditorFoldersRecursive( + string currentPath, + int depth, + int maxDepth) + { + if (depth > maxDepth || !Directory.Exists(currentPath)) + { + yield break; + } + + foreach (string directory in Directory.EnumerateDirectories(currentPath)) + { + string directoryName = Path.GetFileName(directory); + if (string.Equals(directoryName, EditorDirName, StringComparison.Ordinal)) + { + yield return directory; + continue; + } + + foreach (string editorDirectory in EnumerateEditorFoldersRecursive(directory, depth + 1, maxDepth)) + { + yield return editorDirectory; + } + } + } + } +} diff --git a/Packages/src/Editor/Infrastructure/SkillSetup/SkillSourceRootEnumerator.cs.meta b/Packages/src/Editor/Infrastructure/SkillSetup/SkillSourceRootEnumerator.cs.meta new file mode 100644 index 0000000000..2248478485 --- /dev/null +++ b/Packages/src/Editor/Infrastructure/SkillSetup/SkillSourceRootEnumerator.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6d03979eff3884a1c946386f990cf080 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: