using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text; using System.Text.RegularExpressions; using UnityEngine; namespace io.github.hatayama.uLoopMCP { internal static class SkillInstallLayout { internal const string SkillsDirName = "skills"; internal const string ManagedSkillsDirName = "unity-cli-loop"; internal const string SkillFileName = "SKILL.md"; private const string CliPackageDirName = "Cli~"; private const string CliSkillDefinitionsDirName = "skill-definitions"; private const string CliOnlySkillDefinitionsDirName = "cli-only"; private const string MarkdownFileExtension = ".md"; private static readonly HashSet ExcludedFileNames = new() { ".meta", ".DS_Store", ".gitkeep" }; private static readonly Regex UloopCommandPattern = new( "(^|[^A-Za-z0-9_-])uloop(?=\\s)", RegexOptions.Compiled); private sealed class SkillSourceDefinition { public readonly string Name; public readonly string ToolName; public readonly string SkillDirectoryPath; public readonly Dictionary SkillFiles; public SkillSourceDefinition( string name, string toolName, string skillDirectoryPath, Dictionary skillFiles) { Name = name; ToolName = toolName; SkillDirectoryPath = skillDirectoryPath; SkillFiles = skillFiles; } } internal readonly struct SkillSourceInfo { public readonly string Name; public readonly string ToolName; public readonly Dictionary SkillFiles; public SkillSourceInfo( string name, string toolName, Dictionary skillFiles) { Name = name; ToolName = toolName; SkillFiles = skillFiles; } } internal static string GetSkillsRoot(string targetRoot) { return Path.Combine(targetRoot, SkillsDirName); } internal static string GetManagedSkillsRoot(string targetRoot) { return Path.Combine(GetSkillsRoot(targetRoot), ManagedSkillsDirName); } internal static bool HasOptedInSkillsDirectory(string targetRoot) { return Directory.Exists(GetSkillsRoot(targetRoot)); } internal static IEnumerable EnumerateInstalledSkillDirectories(string targetRoot) { foreach (string skillDir in EnumerateManagedSkillDirectories(targetRoot)) { yield return skillDir; } foreach (string skillDir in EnumerateLegacyManagedSkillDirectories(targetRoot)) { yield return skillDir; } } internal static bool HasInstalledSkills(string targetRoot) { string projectRoot = UnityMcpPathResolver.GetProjectRoot(); return HasInstalledSkills(projectRoot, targetRoot); } internal static bool HasInstalledSkills(string targetRoot, bool groupSkillsUnderUnityCliLoop) { string projectRoot = UnityMcpPathResolver.GetProjectRoot(); return HasInstalledSkills(projectRoot, targetRoot, groupSkillsUnderUnityCliLoop); } internal static bool HasInstalledSkills(string projectRoot, string targetRoot) { Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty"); Debug.Assert(!string.IsNullOrEmpty(targetRoot), "targetRoot must not be null or empty"); return HasInstalledSkills(projectRoot, targetRoot, groupSkillsUnderUnityCliLoop: false) || HasInstalledSkills(projectRoot, targetRoot, groupSkillsUnderUnityCliLoop: true); } internal static bool HasInstalledSkills( string projectRoot, string targetRoot, bool groupSkillsUnderUnityCliLoop) { Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty"); Debug.Assert(!string.IsNullOrEmpty(targetRoot), "targetRoot must not be null or empty"); if (!groupSkillsUnderUnityCliLoop && EnumerateLegacyManagedSkillDirectories(targetRoot).Any()) { return true; } Dictionary expectedSkills = GetSkillSources(projectRoot); if (expectedSkills.Count == 0) { return false; } return expectedSkills.Keys.Any(skillName => Directory.Exists(GetInstalledSkillDirectoryPath(targetRoot, skillName, groupSkillsUnderUnityCliLoop))); } internal static SkillInstallState GetInstalledState( string projectRoot, string targetRoot, bool groupSkillsUnderUnityCliLoop) { Dictionary expectedSkills = GetSkillSources(projectRoot); bool hasLayoutSkills = HasInstalledSkills(projectRoot, targetRoot, groupSkillsUnderUnityCliLoop); if (expectedSkills.Count == 0) { return hasLayoutSkills ? SkillInstallState.Installed : SkillInstallState.Missing; } bool hasInstalledExpectedSkill = false; bool hasMissingExpectedSkill = false; foreach (SkillSourceDefinition expectedSkill in expectedSkills.Values) { string installedSkillDirectory = GetInstalledSkillDirectoryPath( targetRoot, expectedSkill.Name, groupSkillsUnderUnityCliLoop); if (!Directory.Exists(installedSkillDirectory)) { hasMissingExpectedSkill = true; continue; } hasInstalledExpectedSkill = true; if (IsSkillDirectoryOutdated(expectedSkill.SkillFiles, installedSkillDirectory)) { return SkillInstallState.Outdated; } } if (!hasInstalledExpectedSkill) { return hasLayoutSkills ? SkillInstallState.Outdated : SkillInstallState.Missing; } return hasMissingExpectedSkill ? SkillInstallState.Outdated : SkillInstallState.Installed; } internal static bool SkillMatchesTool(string skillDir, string toolName) { string skillMdPath = Path.Combine(skillDir, SkillFileName); if (File.Exists(skillMdPath)) { string content = File.ReadAllText(skillMdPath); string parsed = ParseToolNameFromFrontmatter(content); if (!string.IsNullOrEmpty(parsed)) { return parsed == toolName; } } string dirName = Path.GetFileName(skillDir); return dirName == $"{CliConstants.SKILL_DIR_PREFIX}{toolName}"; } internal static List GetSkillSourceInfos(string projectRoot) { return GetSkillSources(projectRoot) .Values .Select(source => new SkillSourceInfo(source.Name, source.ToolName, source.SkillFiles)) .ToList(); } internal static string GetInstalledSkillDirectoryPathForLayout( string targetRoot, string skillName, bool groupSkillsUnderUnityCliLoop) { return GetInstalledSkillDirectoryPath(targetRoot, skillName, groupSkillsUnderUnityCliLoop); } internal static IEnumerable EnumerateInstalledSkillDirectoryNamesForLayout( string targetRoot, bool groupSkillsUnderUnityCliLoop) { IEnumerable installedSkillDirectories = groupSkillsUnderUnityCliLoop ? EnumerateManagedSkillDirectories(targetRoot) : EnumerateLegacyManagedSkillDirectories(targetRoot); return installedSkillDirectories .Select(Path.GetFileName) .Where(name => !string.IsNullOrEmpty(name)); } private static IEnumerable EnumerateManagedSkillDirectories(string targetRoot) { string managedSkillsRoot = GetManagedSkillsRoot(targetRoot); if (!Directory.Exists(managedSkillsRoot)) { return Enumerable.Empty(); } return Directory.EnumerateDirectories(managedSkillsRoot); } private static IEnumerable EnumerateLegacyManagedSkillDirectories(string targetRoot) { string skillsRoot = GetSkillsRoot(targetRoot); if (!Directory.Exists(skillsRoot)) { return Enumerable.Empty(); } return Directory.EnumerateDirectories(skillsRoot) .Where(skillDir => Path.GetFileName(skillDir) != ManagedSkillsDirName) .Where(IsLegacyManagedSkillDirectory); } private static bool IsLegacyManagedSkillDirectory(string skillDir) { string dirName = Path.GetFileName(skillDir); if (dirName.StartsWith(CliConstants.SKILL_DIR_PREFIX, StringComparison.Ordinal)) { return true; } string skillMdPath = Path.Combine(skillDir, SkillFileName); if (!File.Exists(skillMdPath)) { return false; } string content = File.ReadAllText(skillMdPath); if (!string.IsNullOrEmpty(ParseToolNameFromFrontmatter(content))) { return true; } return false; } private static string GetInstalledSkillDirectoryPath( string targetRoot, string skillName, bool groupSkillsUnderUnityCliLoop) { Debug.Assert(IsSafeSkillPathComponent(skillName), "skillName must be a single safe path component"); string skillsRoot = groupSkillsUnderUnityCliLoop ? GetManagedSkillsRoot(targetRoot) : GetSkillsRoot(targetRoot); return Path.Combine(skillsRoot, skillName); } private static bool IsSkillDirectoryOutdated( Dictionary sourceFiles, string installedSkillDirectory) { Dictionary installedFiles = CollectInstalledSkillFiles(installedSkillDirectory); if (sourceFiles.Count != installedFiles.Count) { return true; } foreach (KeyValuePair sourceFile in sourceFiles) { if (!installedFiles.TryGetValue(sourceFile.Key, out byte[] installedContent)) { return true; } if (!sourceFile.Value.SequenceEqual(installedContent)) { return true; } } return false; } private static Dictionary CollectInstalledSkillFiles(string skillDirectory) { Dictionary files = new(StringComparer.Ordinal); foreach (string filePath in Directory.EnumerateFiles(skillDirectory, "*", SearchOption.AllDirectories)) { string fileName = Path.GetFileName(filePath); if (IsExcludedFile(fileName)) { continue; } string relativePath = Path.GetRelativePath(skillDirectory, filePath); files[relativePath] = File.ReadAllBytes(filePath); } return files; } private static Dictionary CollectSourceSkillFiles( string skillDirectory, string skillFilePath) { Debug.Assert(!string.IsNullOrEmpty(skillDirectory), "skillDirectory must not be null or empty"); Debug.Assert(!string.IsNullOrEmpty(skillFilePath), "skillFilePath must not be null or empty"); Dictionary sourceFiles; if (string.Equals(Path.GetFileName(skillDirectory), "Skill", StringComparison.Ordinal)) { sourceFiles = CollectInstalledSkillFiles(skillDirectory); return FormatSkillFilesForCliInvocation(sourceFiles); } sourceFiles = new Dictionary(StringComparer.Ordinal) { [SkillFileName] = File.ReadAllBytes(skillFilePath) }; return FormatSkillFilesForCliInvocation(sourceFiles); } private static Dictionary FormatSkillFilesForCliInvocation( Dictionary skillFiles) { Debug.Assert(skillFiles != null, "skillFiles must not be null"); if (ToolSettings.GetSkillCliInvocation() == CliConstants.SKILL_CLI_INVOCATION_GLOBAL) { return skillFiles; } Debug.Assert(skillFiles.ContainsKey(SkillFileName), "skillFiles must contain SKILL.md"); foreach (string relativePath in skillFiles.Keys.ToArray()) { if (!IsMarkdownSkillFile(relativePath)) { continue; } string skillContent = Encoding.UTF8.GetString(skillFiles[relativePath]); string formattedContent = FormatSkillMarkdownForNpx( skillContent, McpConstants.PackageInfo.version); skillFiles[relativePath] = Encoding.UTF8.GetBytes(formattedContent); } return skillFiles; } private static bool IsMarkdownSkillFile(string relativePath) { Debug.Assert(!string.IsNullOrEmpty(relativePath), "relativePath must not be null or empty"); return string.Equals( Path.GetExtension(relativePath), MarkdownFileExtension, StringComparison.OrdinalIgnoreCase); } private static string FormatSkillMarkdownForNpx(string content, string packageVersion) { Debug.Assert(content != null, "content must not be null"); Debug.Assert(!string.IsNullOrEmpty(packageVersion), "packageVersion must not be null or empty"); string npxCommand = $"{CliConstants.NPX_EXECUTABLE_NAME} {CliConstants.NPX_YES_FLAG} {CliConstants.NPM_PACKAGE_NAME}@{packageVersion}"; Match frontmatterMatch = Regex.Match( content, "^(---\\r?\\n[\\s\\S]*?\\r?\\n---)(\\r?\\n?)([\\s\\S]*)$"); if (!frontmatterMatch.Success) { return UloopCommandPattern.Replace(content, match => $"{match.Groups[1].Value}{npxCommand}"); } string frontmatter = $"{frontmatterMatch.Groups[1].Value}{frontmatterMatch.Groups[2].Value}"; string body = frontmatterMatch.Groups[3].Value; string formattedBody = UloopCommandPattern.Replace( body, match => $"{match.Groups[1].Value}{npxCommand}"); return $"{frontmatter}{formattedBody}"; } private static Dictionary GetSkillSources(string projectRoot) { Dictionary sources = new(StringComparer.Ordinal); foreach (string searchRoot in EnumerateSkillSourceRoots(projectRoot)) { if (!Directory.Exists(searchRoot)) { continue; } IEnumerable skillFilePaths = IsCliOnlySkillSourceRoot(searchRoot) ? Directory.EnumerateFiles(searchRoot, SkillFileName, SearchOption.AllDirectories) : EnumerateEditorFolders(searchRoot, 3).SelectMany(editorFolder => Directory.EnumerateFiles(editorFolder, SkillFileName, SearchOption.AllDirectories)); foreach (string skillFilePath in skillFilePaths) { string skillDirectory = Path.GetDirectoryName(skillFilePath); if (skillDirectory == null) { continue; } string skillContent = File.ReadAllText(skillFilePath); if (IsInternalSkill(skillContent)) { continue; } string skillName = ParseNameFromFrontmatter(skillContent); if (string.IsNullOrEmpty(skillName) || !IsSafeSkillPathComponent(skillName) || sources.ContainsKey(skillName)) { continue; } sources[skillName] = new SkillSourceDefinition( skillName, ParseToolNameFromFrontmatter(skillContent), skillDirectory, CollectSourceSkillFiles(skillDirectory, skillFilePath)); } } return sources; } 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 = UnityMcpPathResolver.GetProjectRoot(); if (!string.Equals( Path.GetFullPath(projectRoot), Path.GetFullPath(currentProjectRoot), StringComparison.Ordinal)) { return null; } return Path.Combine( McpConstants.PackageResolvedPath, CliPackageDirName, McpConstants.SRC_DIR, SkillsDirName, CliSkillDefinitionsDirName, CliOnlySkillDefinitionsDirName); } private static bool IsCliOnlySkillSourceRoot(string searchRoot) { return string.Equals( Path.GetFullPath(searchRoot), Path.GetFullPath(GetCliOnlySkillSourceRoot(UnityMcpPathResolver.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 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 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 IsExcludedFile(string fileName) { if (ExcludedFileNames.Contains(fileName)) { return true; } foreach (string excludedPattern in ExcludedFileNames) { if (fileName.EndsWith(excludedPattern, StringComparison.Ordinal)) { return true; } } return false; } private static bool IsSafeSkillPathComponent(string skillName) { if (string.IsNullOrEmpty(skillName)) { return false; } if (skillName == "." || skillName == "..") { return false; } if (skillName.Contains('/') || skillName.Contains('\\')) { return false; } if (Path.IsPathRooted(skillName)) { return false; } if (skillName.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0) { return false; } return string.Equals(Path.GetFileName(skillName), skillName, StringComparison.Ordinal); } } }