Files
ft/Client/Packages/io.github.hatayama.uloopmcp/Editor/Config/SkillInstallLayout.cs

807 lines
29 KiB
C#

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<string> 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<string, byte[]> SkillFiles;
public SkillSourceDefinition(
string name,
string toolName,
string skillDirectoryPath,
Dictionary<string, byte[]> skillFiles)
{
Name = name;
ToolName = toolName;
SkillDirectoryPath = skillDirectoryPath;
SkillFiles = skillFiles;
}
}
internal readonly struct SkillSourceInfo
{
public readonly string Name;
public readonly string ToolName;
public readonly Dictionary<string, byte[]> SkillFiles;
public SkillSourceInfo(
string name,
string toolName,
Dictionary<string, byte[]> 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<string> 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<string, SkillSourceDefinition> 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<string, SkillSourceDefinition> 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<SkillSourceInfo> 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<string> EnumerateInstalledSkillDirectoryNamesForLayout(
string targetRoot,
bool groupSkillsUnderUnityCliLoop)
{
IEnumerable<string> installedSkillDirectories = groupSkillsUnderUnityCliLoop
? EnumerateManagedSkillDirectories(targetRoot)
: EnumerateLegacyManagedSkillDirectories(targetRoot);
return installedSkillDirectories
.Select(Path.GetFileName)
.Where(name => !string.IsNullOrEmpty(name));
}
private static IEnumerable<string> EnumerateManagedSkillDirectories(string targetRoot)
{
string managedSkillsRoot = GetManagedSkillsRoot(targetRoot);
if (!Directory.Exists(managedSkillsRoot))
{
return Enumerable.Empty<string>();
}
return Directory.EnumerateDirectories(managedSkillsRoot);
}
private static IEnumerable<string> EnumerateLegacyManagedSkillDirectories(string targetRoot)
{
string skillsRoot = GetSkillsRoot(targetRoot);
if (!Directory.Exists(skillsRoot))
{
return Enumerable.Empty<string>();
}
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<string, byte[]> sourceFiles,
string installedSkillDirectory)
{
Dictionary<string, byte[]> installedFiles = CollectInstalledSkillFiles(installedSkillDirectory);
if (sourceFiles.Count != installedFiles.Count)
{
return true;
}
foreach (KeyValuePair<string, byte[]> 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<string, byte[]> CollectInstalledSkillFiles(string skillDirectory)
{
Dictionary<string, byte[]> 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<string, byte[]> 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<string, byte[]> sourceFiles;
if (string.Equals(Path.GetFileName(skillDirectory), "Skill", StringComparison.Ordinal))
{
sourceFiles = CollectInstalledSkillFiles(skillDirectory);
return FormatSkillFilesForCliInvocation(sourceFiles);
}
sourceFiles = new Dictionary<string, byte[]>(StringComparer.Ordinal)
{
[SkillFileName] = File.ReadAllBytes(skillFilePath)
};
return FormatSkillFilesForCliInvocation(sourceFiles);
}
private static Dictionary<string, byte[]> FormatSkillFilesForCliInvocation(
Dictionary<string, byte[]> 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<string, SkillSourceDefinition> GetSkillSources(string projectRoot)
{
Dictionary<string, SkillSourceDefinition> sources = new(StringComparer.Ordinal);
foreach (string searchRoot in EnumerateSkillSourceRoots(projectRoot))
{
if (!Directory.Exists(searchRoot))
{
continue;
}
IEnumerable<string> 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<string> EnumerateSkillSourceRoots(string projectRoot)
{
HashSet<string> 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<string> 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<string> 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<string> EnumerateManifestLocalPackageRoots(string projectRoot)
{
foreach (KeyValuePair<string, string> dependency in EnumerateManifestDependencies(projectRoot))
{
string localPath = ResolveLocalDependencyPath(dependency.Value, projectRoot);
if (string.IsNullOrEmpty(localPath))
{
continue;
}
yield return ResolveSkillSearchRootCandidate(localPath);
}
}
private static IEnumerable<string> EnumerateDependencyPackageCacheRoots(string projectRoot)
{
HashSet<string> 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<KeyValuePair<string, string>> 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*\\{(?<body>[\\s\\S]*?)\\}",
RegexOptions.Multiline);
if (!dependenciesMatch.Success)
{
yield break;
}
MatchCollection dependencyMatches = Regex.Matches(
dependenciesMatch.Groups["body"].Value,
"\"(?<name>[^\"]+)\"\\s*:\\s*\"(?<value>[^\"]*)\"");
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<string, string>(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<string> EnumerateEditorFolders(string basePath, int maxDepth)
{
return EnumerateEditorFoldersRecursive(basePath, depth: 0, maxDepth);
}
private static IEnumerable<string> 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);
}
}
}