From e6a036b56cb184f7f808c1d59838058462655602 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E6=B4=8B=E5=87=A1?= <378905096@qq.com> Date: Sat, 28 Feb 2026 16:32:15 +0800 Subject: [PATCH 1/6] Add Unity MCP skill sync installer Add an EditorWindow (McpForUnitySkillInstaller) that syncs the unity-mcp skill from a GitHub repository without cloning. The tool reads the repo tree via the GitHub API, computes git blob SHA-1s to build an incremental sync plan (added/updated/deleted), downloads raw files for changed items, validates hashes, and writes a last-synced commit to EditorPrefs. UI supports branch and CLI (codex/claude) selection, configurable install path, logging, and safety checks (aborts on truncated trees). Also add the corresponding .meta file. --- .../Editor/Setup/McpForUnitySkillInstaller.cs | 836 ++++++++++++++++++ .../Setup/McpForUnitySkillInstaller.cs.meta | 2 + 2 files changed, 838 insertions(+) create mode 100644 MCPForUnity/Editor/Setup/McpForUnitySkillInstaller.cs create mode 100644 MCPForUnity/Editor/Setup/McpForUnitySkillInstaller.cs.meta diff --git a/MCPForUnity/Editor/Setup/McpForUnitySkillInstaller.cs b/MCPForUnity/Editor/Setup/McpForUnitySkillInstaller.cs new file mode 100644 index 000000000..a3910ed3e --- /dev/null +++ b/MCPForUnity/Editor/Setup/McpForUnitySkillInstaller.cs @@ -0,0 +1,836 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Security.Cryptography; +using System.Text; +using System.Threading.Tasks; +using UnityEditor; +using UnityEngine; + +namespace Editor +{ + public class McpForUnitySkillInstaller : EditorWindow + { + private const string RepoUrlKey = "UnityMcpSkillSync.RepoUrl"; + private const string BranchKey = "UnityMcpSkillSync.Branch"; + private const string CliKey = "UnityMcpSkillSync.Cli"; + private const string InstallDirKey = "UnityMcpSkillSync.InstallDir"; + private const string LastSyncedCommitKey = "UnityMcpSkillSync.LastSyncedCommit"; + private const string FixedSkillSubdir = "unity-mcp-skill"; + private const string CodexCli = "codex"; + private const string ClaudeCli = "claude"; + private static readonly string[] BranchOptions = { "beta", "main" }; + private static readonly string[] CliOptions = { CodexCli, ClaudeCli }; + + private string _repoUrl; + private string _targetBranch; + private string _cliType; + private string _installDir; + private Vector2 _scroll; + private volatile bool _isRunning; + private readonly ConcurrentQueue _mainThreadActions = new(); + private readonly ConcurrentQueue _pendingLogs = new(); + private readonly StringBuilder _logBuilder = new(4096); + + [MenuItem("Window/MCP For Unity/Install(Sync) MCP Skill")] + public static void OpenWindow() + { + GetWindow("Unity MCP Skill Install(Sync)"); + } + + private void OnEnable() + { + var userHome = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + _repoUrl = EditorPrefs.GetString(RepoUrlKey, "https://github.com/CoplayDev/unity-mcp"); + _targetBranch = EditorPrefs.GetString(BranchKey, "beta"); + if (!BranchOptions.Contains(_targetBranch)) + { + _targetBranch = "beta"; + } + _cliType = EditorPrefs.GetString(CliKey, CodexCli); + if (!CliOptions.Contains(_cliType)) + { + _cliType = CodexCli; + } + _installDir = EditorPrefs.GetString(InstallDirKey, GetDefaultInstallDir(userHome, _cliType)); + EditorApplication.update += OnEditorUpdate; + } + + private void OnDisable() + { + EditorApplication.update -= OnEditorUpdate; + EditorPrefs.SetString(RepoUrlKey, _repoUrl); + EditorPrefs.SetString(BranchKey, _targetBranch); + EditorPrefs.SetString(CliKey, _cliType); + EditorPrefs.SetString(InstallDirKey, _installDir); + } + + private void OnGUI() + { + FlushPendingLogs(); + EditorGUILayout.HelpBox("Sync Unity MCP Skill to the latest on the selected branch and output the changed file list.", MessageType.Info); + EditorGUILayout.Space(4f); + + EditorGUILayout.LabelField("Config", EditorStyles.boldLabel); + _repoUrl = EditorGUILayout.TextField("Repo URL", _repoUrl); + var branchIndex = Array.IndexOf(BranchOptions, _targetBranch); + if (branchIndex < 0) + { + branchIndex = 0; + } + + var selectedBranchIndex = EditorGUILayout.Popup("Branch", branchIndex, BranchOptions); + _targetBranch = BranchOptions[selectedBranchIndex]; + + var cliIndex = Array.IndexOf(CliOptions, _cliType); + if (cliIndex < 0) + { + cliIndex = 0; + } + + var selectedCliIndex = EditorGUILayout.Popup("CLI", cliIndex, CliOptions); + if (selectedCliIndex != cliIndex) + { + var previousCli = _cliType; + _cliType = CliOptions[selectedCliIndex]; + TryApplyCliDefaultInstallPath(previousCli, _cliType); + } + + _installDir = EditorGUILayout.TextField("Install Dir", _installDir); + + EditorGUILayout.Space(8f); + EditorGUILayout.BeginHorizontal(); + using (new EditorGUI.DisabledScope(_isRunning)) + { + if (GUILayout.Button($"Sync Latest ({_targetBranch})", GUILayout.Height(32f))) + { + AppendLineImmediate("Sync task queued..."); + AppendLineImmediate("Will use GitHub API to read the remote directory tree and perform incremental sync (no repository clone)."); + RunSyncLatest(); + } + } + + if (GUILayout.Button("Clear Log", GUILayout.Width(100f), GUILayout.Height(32f))) + { + _logBuilder.Clear(); + while (_pendingLogs.TryDequeue(out _)) + { + } + } + EditorGUILayout.EndHorizontal(); + + EditorGUILayout.Space(8f); + EditorGUILayout.LabelField("Output", EditorStyles.boldLabel); + _scroll = EditorGUILayout.BeginScrollView(_scroll); + EditorGUILayout.TextArea(_logBuilder.ToString(), GUILayout.ExpandHeight(true)); + EditorGUILayout.EndScrollView(); + } + + private void OnEditorUpdate() + { + ExecuteMainThreadActions(); + var changed = FlushPendingLogs(); + if (_isRunning || changed) + { + Repaint(); + } + } + + private void RunSyncLatest() + { + var lastSyncedCommitKey = GetLastSyncedCommitKey(); + var lastSyncedCommit = EditorPrefs.GetString(lastSyncedCommitKey, string.Empty); + ExecuteWithGuard(() => + { + AppendLine("=== Sync Start ==="); + if (!TryParseGitHubRepository(_repoUrl, out var repoInfo)) + { + throw new InvalidOperationException($"Repo URL is not a recognized GitHub repository URL: {_repoUrl}"); + } + + AppendLine($"Target repository: {repoInfo.Owner}/{repoInfo.Repo}@{_targetBranch}"); + var snapshot = FetchRemoteSnapshot(repoInfo, _targetBranch, FixedSkillSubdir); + var installPath = GetInstallPath(); + + if (!Directory.Exists(installPath)) + { + Directory.CreateDirectory(installPath); + } + + var localFiles = ListFiles(installPath); + var plan = BuildPlan(snapshot.Files, localFiles); + var commitChanged = !string.Equals(lastSyncedCommit, snapshot.CommitSha, StringComparison.Ordinal); + + AppendLine($"Remote Commit: {ShortCommit(lastSyncedCommit)} -> {ShortCommit(snapshot.CommitSha)}"); + AppendLine(commitChanged + ? $"Commit: detected newer commit on {_targetBranch}." + : $"Commit: no new commit on {_targetBranch} since last sync."); + AppendLine($"Plan => Added:{plan.Added.Count} Updated:{plan.Updated.Count} Deleted:{plan.Deleted.Count}"); + AppendSummary(plan, commitChanged); + LogPlanDetails(plan); + + ApplyPlan(repoInfo, snapshot.CommitSha, snapshot.SubdirPath, installPath, plan); + AppendLine("Files mirrored to install directory."); + + ValidateFileHashes(installPath, snapshot.Files); + EnqueueMainThreadAction(() => EditorPrefs.SetString(lastSyncedCommitKey, snapshot.CommitSha)); + AppendLine($"Synced to commit: {snapshot.CommitSha}"); + AppendLine("=== Sync Done ==="); + }); + } + + private void ExecuteWithGuard(Action action) + { + if (_isRunning) + { + return; + } + + _isRunning = true; + Task.Run(() => + { + try + { + action.Invoke(); + } + catch (Exception ex) + { + AppendLine($"[ERROR] {ex.Message}"); + } + finally + { + _isRunning = false; + } + }); + } + + private string GetLastSyncedCommitKey() + { + var scope = $"{_repoUrl}|{_targetBranch}|{NormalizeRemotePath(FixedSkillSubdir)}"; + using var sha256 = SHA256.Create(); + var hash = sha256.ComputeHash(Encoding.UTF8.GetBytes(scope)); + var suffix = BitConverter.ToString(hash).Replace("-", string.Empty).ToLowerInvariant(); + return $"{LastSyncedCommitKey}.{suffix}"; + } + + private static bool TryParseGitHubRepository(string url, out GitHubRepoInfo repoInfo) + { + repoInfo = default; + if (string.IsNullOrWhiteSpace(url)) + { + return false; + } + + var trimmed = url.Trim(); + if (trimmed.StartsWith("git@github.com:", StringComparison.OrdinalIgnoreCase)) + { + var repoPath = trimmed.Substring("git@github.com:".Length).Trim('/'); + return TryParseOwnerAndRepo(repoPath, out repoInfo); + } + + if (!Uri.TryCreate(trimmed, UriKind.Absolute, out var uri)) + { + return false; + } + + if (!string.Equals(uri.Host, "github.com", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + var repoPathFromUri = uri.AbsolutePath.Trim('/'); + return TryParseOwnerAndRepo(repoPathFromUri, out repoInfo); + } + + private static bool TryParseOwnerAndRepo(string path, out GitHubRepoInfo repoInfo) + { + repoInfo = default; + var segments = path.Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries); + if (segments.Length < 2) + { + return false; + } + + var owner = segments[0].Trim(); + var repo = segments[1].Trim(); + if (repo.EndsWith(".git", StringComparison.OrdinalIgnoreCase)) + { + repo = repo.Substring(0, repo.Length - 4); + } + + if (string.IsNullOrWhiteSpace(owner) || string.IsNullOrWhiteSpace(repo)) + { + return false; + } + + repoInfo = new GitHubRepoInfo(owner, repo); + return true; + } + + private RemoteSnapshot FetchRemoteSnapshot(GitHubRepoInfo repoInfo, string branch, string subdir) + { + using var client = CreateGitHubClient(); + var treeApiUrl = BuildTreeApiUrl(repoInfo, branch); + AppendLine($"Fetching remote directory tree: {treeApiUrl}"); + var json = DownloadString(client, treeApiUrl); + var treeResponse = JsonUtility.FromJson(json); + if (treeResponse == null || treeResponse.tree == null) + { + throw new InvalidOperationException("Failed to parse GitHub directory tree response."); + } + + if (treeResponse.truncated) + { + throw new InvalidOperationException( + "GitHub returned a truncated directory tree (incomplete snapshot). " + + "Sync was aborted to prevent accidental deletion of valid local files."); + } + + var normalizedSubdir = NormalizeRemotePath(subdir); + var subdirPrefix = string.IsNullOrEmpty(normalizedSubdir) ? string.Empty : $"{normalizedSubdir}/"; + var remoteFiles = new Dictionary(StringComparer.Ordinal); + + foreach (var entry in treeResponse.tree) + { + if (!string.Equals(entry.type, "blob", StringComparison.Ordinal)) + { + continue; + } + + var remotePath = NormalizeRemotePath(entry.path); + if (string.IsNullOrEmpty(remotePath)) + { + continue; + } + + if (!string.IsNullOrEmpty(subdirPrefix) && + !remotePath.StartsWith(subdirPrefix, StringComparison.Ordinal)) + { + continue; + } + + var relativePath = string.IsNullOrEmpty(subdirPrefix) + ? remotePath + : remotePath.Substring(subdirPrefix.Length); + if (string.IsNullOrWhiteSpace(relativePath) || string.IsNullOrWhiteSpace(entry.sha)) + { + continue; + } + + remoteFiles[relativePath] = entry.sha.Trim().ToLowerInvariant(); + } + + if (remoteFiles.Count == 0) + { + throw new InvalidOperationException($"Remote directory not found: {normalizedSubdir}"); + } + + var commitSha = treeResponse.sha?.Trim(); + if (string.IsNullOrWhiteSpace(commitSha)) + { + throw new InvalidOperationException("Remote directory tree response is missing commit SHA."); + } + + AppendLine($"Remote file count: {remoteFiles.Count}"); + return new RemoteSnapshot(commitSha, normalizedSubdir, remoteFiles); + } + + private static string BuildTreeApiUrl(GitHubRepoInfo repoInfo, string branch) + { + return $"https://api.github.com/repos/{Uri.EscapeDataString(repoInfo.Owner)}/{Uri.EscapeDataString(repoInfo.Repo)}/git/trees/{Uri.EscapeDataString(branch)}?recursive=1"; + } + + private static string BuildRawFileUrl(GitHubRepoInfo repoInfo, string commitSha, string remoteFilePath) + { + var encodedPath = string.Join("/", + NormalizeRemotePath(remoteFilePath) + .Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries) + .Select(Uri.EscapeDataString)); + return $"https://raw.githubusercontent.com/{Uri.EscapeDataString(repoInfo.Owner)}/{Uri.EscapeDataString(repoInfo.Repo)}/{Uri.EscapeDataString(commitSha)}/{encodedPath}"; + } + + private static HttpClient CreateGitHubClient() + { + var client = new HttpClient + { + Timeout = TimeSpan.FromSeconds(60) + }; + client.DefaultRequestHeaders.UserAgent.ParseAdd("UnityMcpSkillSyncWindow/1.0"); + client.DefaultRequestHeaders.Accept.ParseAdd("application/vnd.github+json"); + return client; + } + + private static string DownloadString(HttpClient client, string url) + { + using var response = client.GetAsync(url).GetAwaiter().GetResult(); + var body = response.Content.ReadAsStringAsync().GetAwaiter().GetResult(); + if (!response.IsSuccessStatusCode) + { + throw new InvalidOperationException($"GitHub request failed: {(int)response.StatusCode} {response.ReasonPhrase} ({url})\n{body}"); + } + + return body; + } + + private static byte[] DownloadBytes(HttpClient client, string url) + { + using var response = client.GetAsync(url).GetAwaiter().GetResult(); + if (!response.IsSuccessStatusCode) + { + var body = response.Content.ReadAsStringAsync().GetAwaiter().GetResult(); + throw new InvalidOperationException($"File download failed: {(int)response.StatusCode} {response.ReasonPhrase} ({url})\n{body}"); + } + + return response.Content.ReadAsByteArrayAsync().GetAwaiter().GetResult(); + } + + private static string NormalizeRemotePath(string path) + { + if (string.IsNullOrWhiteSpace(path)) + { + return string.Empty; + } + + return path.Replace('\\', '/').Trim().Trim('/'); + } + + private static string CombineRemotePath(string left, string right) + { + var normalizedLeft = NormalizeRemotePath(left); + var normalizedRight = NormalizeRemotePath(right); + if (string.IsNullOrEmpty(normalizedLeft)) + { + return normalizedRight; + } + + if (string.IsNullOrEmpty(normalizedRight)) + { + return normalizedLeft; + } + + return $"{normalizedLeft}/{normalizedRight}"; + } + + private static SyncPlan BuildPlan(Dictionary remoteFiles, Dictionary localFiles) + { + var plan = new SyncPlan(); + foreach (var remoteEntry in remoteFiles) + { + if (!localFiles.TryGetValue(remoteEntry.Key, out var localPath)) + { + plan.Added.Add(remoteEntry.Key); + continue; + } + + var localBlobSha = ComputeGitBlobSha1(localPath); + if (!string.Equals(localBlobSha, remoteEntry.Value, StringComparison.Ordinal)) + { + plan.Updated.Add(remoteEntry.Key); + } + } + + foreach (var localRelativePath in localFiles.Keys) + { + if (!remoteFiles.ContainsKey(localRelativePath)) + { + plan.Deleted.Add(localRelativePath); + } + } + + plan.Added.Sort(StringComparer.Ordinal); + plan.Updated.Sort(StringComparer.Ordinal); + plan.Deleted.Sort(StringComparer.Ordinal); + return plan; + } + + private void ApplyPlan(GitHubRepoInfo repoInfo, string commitSha, string remoteSubdir, string targetRoot, SyncPlan plan) + { + using var client = CreateGitHubClient(); + foreach (var relativePath in plan.Added.Concat(plan.Updated)) + { + var remoteFilePath = CombineRemotePath(remoteSubdir, relativePath); + var downloadUrl = BuildRawFileUrl(repoInfo, commitSha, remoteFilePath); + var targetFile = Path.Combine(targetRoot, relativePath); + var targetDirectory = Path.GetDirectoryName(targetFile); + if (!string.IsNullOrEmpty(targetDirectory)) + { + Directory.CreateDirectory(targetDirectory); + } + + AppendLine($"Download: {relativePath}"); + var bytes = DownloadBytes(client, downloadUrl); + File.WriteAllBytes(targetFile, bytes); + } + + foreach (var relativePath in plan.Deleted) + { + var targetFile = Path.Combine(targetRoot, relativePath); + if (File.Exists(targetFile)) + { + File.Delete(targetFile); + } + } + + RemoveEmptyDirectories(targetRoot); + } + + private void ValidateFileHashes(string installRoot, Dictionary remoteFiles) + { + var checkedCount = 0; + foreach (var remoteEntry in remoteFiles) + { + var localPath = Path.Combine(installRoot, remoteEntry.Key); + if (!File.Exists(localPath)) + { + throw new InvalidOperationException($"Missing synced file: {remoteEntry.Key}"); + } + + var localBlobSha = ComputeGitBlobSha1(localPath); + if (!string.Equals(localBlobSha, remoteEntry.Value, StringComparison.Ordinal)) + { + throw new InvalidOperationException($"File hash mismatch: {remoteEntry.Key} ({ShortHash(localBlobSha)} != {ShortHash(remoteEntry.Value)})"); + } + + checkedCount++; + } + + AppendLine($"Hash checks passed ({checkedCount}/{remoteFiles.Count})."); + } + + private static string ComputeGitBlobSha1(string filePath) + { + var bytes = File.ReadAllBytes(filePath); + return ComputeGitBlobSha1(bytes); + } + + private static string ComputeGitBlobSha1(byte[] bytes) + { + var headerBytes = Encoding.UTF8.GetBytes($"blob {bytes.Length}\0"); + using var sha1 = SHA1.Create(); + sha1.TransformBlock(headerBytes, 0, headerBytes.Length, null, 0); + sha1.TransformFinalBlock(bytes, 0, bytes.Length); + return BitConverter.ToString(sha1.Hash ?? Array.Empty()).Replace("-", string.Empty).ToLowerInvariant(); + } + + private static Dictionary ListFiles(string root) + { + var map = new Dictionary(StringComparer.Ordinal); + if (!Directory.Exists(root)) + { + return map; + } + + var normalizedRoot = Path.GetFullPath(root); + foreach (var filePath in Directory.GetFiles(normalizedRoot, "*", SearchOption.AllDirectories)) + { + var relativePath = Path.GetRelativePath(normalizedRoot, filePath).Replace('\\', '/'); + map[relativePath] = filePath; + } + + return map; + } + + private static void RemoveEmptyDirectories(string root) + { + if (!Directory.Exists(root)) + { + return; + } + + var directories = Directory.GetDirectories(root, "*", SearchOption.AllDirectories); + Array.Sort(directories, (a, b) => string.CompareOrdinal(b, a)); + foreach (var directory in directories) + { + if (Directory.EnumerateFileSystemEntries(directory).Any()) + { + continue; + } + + Directory.Delete(directory, false); + } + } + + private string GetInstallPath() + { + return ExpandPath(_installDir); + } + + private void TryApplyCliDefaultInstallPath(string previousCli, string currentCli) + { + var userHome = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + var previousDefaultInstall = GetDefaultInstallDir(userHome, previousCli); + var currentDefaultInstall = GetDefaultInstallDir(userHome, currentCli); + + if (string.IsNullOrWhiteSpace(_installDir) || PathsEqual(_installDir, previousDefaultInstall)) + { + _installDir = currentDefaultInstall; + } + } + + private static string GetDefaultInstallDir(string userHome, string cliType) + { + var baseDir = IsClaudeCli(cliType) ? ".claude" : ".codex"; + return Path.Combine(userHome, baseDir, "skills/unity-mcp-skill"); + } + + private static bool IsClaudeCli(string cliType) + { + return string.Equals(cliType, ClaudeCli, StringComparison.Ordinal); + } + + private static bool PathsEqual(string left, string right) + { + if (string.IsNullOrWhiteSpace(left) || string.IsNullOrWhiteSpace(right)) + { + return false; + } + + try + { + return string.Equals(ExpandPath(left), ExpandPath(right), StringComparison.Ordinal); + } + catch + { + return false; + } + } + + private static string ExpandPath(string path) + { + if (string.IsNullOrWhiteSpace(path)) + { + return string.Empty; + } + + var expanded = path.Trim(); + if (expanded.StartsWith("~", StringComparison.Ordinal)) + { + var userHome = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + expanded = Path.Combine(userHome, expanded.Substring(1).TrimStart('/', '\\')); + } + + return Path.GetFullPath(expanded); + } + + private void EnqueueMainThreadAction(Action action) + { + if (action == null) + { + return; + } + + _mainThreadActions.Enqueue(action); + } + + private void ExecuteMainThreadActions() + { + while (_mainThreadActions.TryDequeue(out var action)) + { + try + { + action.Invoke(); + } + catch (Exception ex) + { + AppendLineImmediate($"[ERROR] Main-thread action execution failed: {ex.Message}"); + } + } + } + + private void AppendLine(string line) + { + var sanitized = SanitizeLogLine(line); + if (string.IsNullOrWhiteSpace(sanitized)) + { + return; + } + + _pendingLogs.Enqueue($"[{DateTime.Now:HH:mm:ss}] {sanitized}"); + } + + private void AppendLineImmediate(string line) + { + var sanitized = SanitizeLogLine(line); + if (string.IsNullOrWhiteSpace(sanitized)) + { + return; + } + + _logBuilder.AppendLine($"[{DateTime.Now:HH:mm:ss}] {sanitized}"); + _scroll.y = float.MaxValue; + Repaint(); + } + + private bool FlushPendingLogs() + { + var hasNewLine = false; + while (_pendingLogs.TryDequeue(out var line)) + { + _logBuilder.AppendLine(line); + hasNewLine = true; + } + + if (hasNewLine) + { + _scroll.y = float.MaxValue; + } + + return hasNewLine; + } + + private static string SanitizeLogLine(string line) + { + if (string.IsNullOrEmpty(line)) + { + return string.Empty; + } + + var sb = new StringBuilder(line.Length); + var inEscape = false; + foreach (var ch in line) + { + if (inEscape) + { + // End ANSI escape sequence on final byte. + if (ch >= '@' && ch <= '~') + { + inEscape = false; + } + continue; + } + + if (ch == '\u001b') + { + inEscape = true; + continue; + } + + if (ch == '\t' || (ch >= ' ' && ch != 127)) + { + sb.Append(ch); + } + } + + return sb.ToString().Trim(); + } + + private void LogPlanDetails(SyncPlan plan) + { + if (plan.Added.Count == 0 && plan.Updated.Count == 0 && plan.Deleted.Count == 0) + { + AppendLine("No file changes."); + return; + } + + foreach (var path in plan.Added) + { + AppendLine($"+ {path}"); + } + + foreach (var path in plan.Updated) + { + AppendLine($"~ {path}"); + } + + foreach (var path in plan.Deleted) + { + AppendLine($"- {path}"); + } + } + + private void AppendSummary(SyncPlan plan, bool commitChanged) + { + var added = plan.Added.Count; + var updated = plan.Updated.Count; + var deleted = plan.Deleted.Count; + + if (added == 0 && updated == 0 && deleted == 0) + { + AppendLine("Conclusion: No file changes in this run."); + return; + } + + if (added == 0 && updated == 0 && deleted > 0) + { + AppendLine(commitChanged + ? "Conclusion: A new commit was detected, but skill content was unchanged; only local redundant files were cleaned up." + : "Conclusion: Skill content was unchanged; only local redundant files were cleaned up."); + return; + } + + AppendLine($"Conclusion: Skill files were updated (added {added}, modified {updated}, deleted {deleted})."); + } + + private static string ShortCommit(string commit) + { + if (string.IsNullOrWhiteSpace(commit)) + { + return "(none)"; + } + + return commit.Length <= 8 ? commit : commit.Substring(0, 8); + } + + private static string ShortHash(string hash) + { + if (string.IsNullOrWhiteSpace(hash)) + { + return "(none)"; + } + + return hash.Length <= 6 ? hash : hash.Substring(0, 6); + } + + private readonly struct GitHubRepoInfo + { + public GitHubRepoInfo(string owner, string repo) + { + Owner = owner; + Repo = repo; + } + + public string Owner { get; } + public string Repo { get; } + } + + private readonly struct RemoteSnapshot + { + public RemoteSnapshot(string commitSha, string subdirPath, Dictionary files) + { + CommitSha = commitSha; + SubdirPath = subdirPath; + Files = files; + } + + public string CommitSha { get; } + public string SubdirPath { get; } + public Dictionary Files { get; } + } + + [Serializable] + private sealed class GitHubTreeResponse + { + public string sha; + public GitHubTreeEntry[] tree; + public bool truncated; + } + + [Serializable] + private sealed class GitHubTreeEntry + { + public string path; + public string type; + public string sha; + } + + private sealed class SyncPlan + { + public List Added { get; } = new(); + public List Updated { get; } = new(); + public List Deleted { get; } = new(); + } + } +} diff --git a/MCPForUnity/Editor/Setup/McpForUnitySkillInstaller.cs.meta b/MCPForUnity/Editor/Setup/McpForUnitySkillInstaller.cs.meta new file mode 100644 index 000000000..98d62e804 --- /dev/null +++ b/MCPForUnity/Editor/Setup/McpForUnitySkillInstaller.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7886932de812549f195fa4f6006ef0dd \ No newline at end of file From 49901a3b9a0c62c91b813a21e056f4e638b75804 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E6=B4=8B=E5=87=A1?= <378905096@qq.com> Date: Sat, 28 Feb 2026 16:36:01 +0800 Subject: [PATCH 2/6] fix namespace --- MCPForUnity/Editor/Setup/McpForUnitySkillInstaller.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/MCPForUnity/Editor/Setup/McpForUnitySkillInstaller.cs b/MCPForUnity/Editor/Setup/McpForUnitySkillInstaller.cs index a3910ed3e..c70fff844 100644 --- a/MCPForUnity/Editor/Setup/McpForUnitySkillInstaller.cs +++ b/MCPForUnity/Editor/Setup/McpForUnitySkillInstaller.cs @@ -10,7 +10,7 @@ using UnityEditor; using UnityEngine; -namespace Editor +namespace MCPForUnity.Editor.Setup { public class McpForUnitySkillInstaller : EditorWindow { From a031b5733cb22f2701490687c6f584b15461c374 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E6=B4=8B=E5=87=A1?= <378905096@qq.com> Date: Sat, 28 Feb 2026 17:13:45 +0800 Subject: [PATCH 3/6] =?UTF-8?q?fix:=20harden=20skill=20installer=20sync=20?= =?UTF-8?q?safety=20(=E7=94=B1codex=E7=94=9F=E6=88=90)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Resolve branch head commit SHA via GitHub branches API and use it for snapshot/raw download refs - Reject unsafe remote relative paths and enforce install-root containment for all file IO - Handle case-only renames on case-insensitive filesystems during sync planning - Add managed install-root marker guard to avoid destructive deletes in unmanaged directories --- .../Editor/Setup/McpForUnitySkillInstaller.cs | 260 ++++++++++++++++-- 1 file changed, 240 insertions(+), 20 deletions(-) diff --git a/MCPForUnity/Editor/Setup/McpForUnitySkillInstaller.cs b/MCPForUnity/Editor/Setup/McpForUnitySkillInstaller.cs index c70fff844..97d57cde5 100644 --- a/MCPForUnity/Editor/Setup/McpForUnitySkillInstaller.cs +++ b/MCPForUnity/Editor/Setup/McpForUnitySkillInstaller.cs @@ -20,6 +20,7 @@ public class McpForUnitySkillInstaller : EditorWindow private const string InstallDirKey = "UnityMcpSkillSync.InstallDir"; private const string LastSyncedCommitKey = "UnityMcpSkillSync.LastSyncedCommit"; private const string FixedSkillSubdir = "unity-mcp-skill"; + private const string SyncOwnershipMarker = ".unity-mcp-skill-sync"; private const string CodexCli = "codex"; private const string ClaudeCli = "claude"; private static readonly string[] BranchOptions = { "beta", "main" }; @@ -161,7 +162,10 @@ private void RunSyncLatest() } var localFiles = ListFiles(installPath); - var plan = BuildPlan(snapshot.Files, localFiles); + var pathComparison = GetPathComparison(installPath); + var pathComparer = GetPathComparer(pathComparison); + EnsureManagedInstallRoot(installPath, localFiles.Keys, snapshot.Files.Keys, pathComparer); + var plan = BuildPlan(snapshot.Files, localFiles, pathComparer); var commitChanged = !string.Equals(lastSyncedCommit, snapshot.CommitSha, StringComparison.Ordinal); AppendLine($"Remote Commit: {ShortCommit(lastSyncedCommit)} -> {ShortCommit(snapshot.CommitSha)}"); @@ -172,10 +176,10 @@ private void RunSyncLatest() AppendSummary(plan, commitChanged); LogPlanDetails(plan); - ApplyPlan(repoInfo, snapshot.CommitSha, snapshot.SubdirPath, installPath, plan); + ApplyPlan(repoInfo, snapshot.CommitSha, snapshot.SubdirPath, installPath, plan, pathComparison); AppendLine("Files mirrored to install directory."); - ValidateFileHashes(installPath, snapshot.Files); + ValidateFileHashes(installPath, snapshot.Files, pathComparison); EnqueueMainThreadAction(() => EditorPrefs.SetString(lastSyncedCommitKey, snapshot.CommitSha)); AppendLine($"Synced to commit: {snapshot.CommitSha}"); AppendLine("=== Sync Done ==="); @@ -273,8 +277,9 @@ private static bool TryParseOwnerAndRepo(string path, out GitHubRepoInfo repoInf private RemoteSnapshot FetchRemoteSnapshot(GitHubRepoInfo repoInfo, string branch, string subdir) { using var client = CreateGitHubClient(); - var treeApiUrl = BuildTreeApiUrl(repoInfo, branch); - AppendLine($"Fetching remote directory tree: {treeApiUrl}"); + var commitSha = FetchBranchHeadCommitSha(client, repoInfo, branch); + var treeApiUrl = BuildTreeApiUrl(repoInfo, commitSha); + AppendLine($"Fetching remote directory tree at commit {ShortCommit(commitSha)}: {treeApiUrl}"); var json = DownloadString(client, treeApiUrl); var treeResponse = JsonUtility.FromJson(json); if (treeResponse == null || treeResponse.tree == null) @@ -320,7 +325,13 @@ private RemoteSnapshot FetchRemoteSnapshot(GitHubRepoInfo repoInfo, string branc continue; } - remoteFiles[relativePath] = entry.sha.Trim().ToLowerInvariant(); + if (!TryNormalizeRelativePath(relativePath, out var safeRelativePath)) + { + AppendLine($"Skip unsafe remote path: {remotePath}"); + continue; + } + + remoteFiles[safeRelativePath] = entry.sha.Trim().ToLowerInvariant(); } if (remoteFiles.Count == 0) @@ -328,19 +339,33 @@ private RemoteSnapshot FetchRemoteSnapshot(GitHubRepoInfo repoInfo, string branc throw new InvalidOperationException($"Remote directory not found: {normalizedSubdir}"); } - var commitSha = treeResponse.sha?.Trim(); + AppendLine($"Remote file count: {remoteFiles.Count}"); + return new RemoteSnapshot(commitSha, normalizedSubdir, remoteFiles); + } + + private string FetchBranchHeadCommitSha(HttpClient client, GitHubRepoInfo repoInfo, string branch) + { + var branchApiUrl = BuildBranchApiUrl(repoInfo, branch); + AppendLine($"Fetching branch head commit: {branchApiUrl}"); + var branchJson = DownloadString(client, branchApiUrl); + var branchResponse = JsonUtility.FromJson(branchJson); + var commitSha = branchResponse?.commit?.sha?.Trim(); if (string.IsNullOrWhiteSpace(commitSha)) { - throw new InvalidOperationException("Remote directory tree response is missing commit SHA."); + throw new InvalidOperationException($"Failed to resolve branch head commit SHA for: {branch}"); } - AppendLine($"Remote file count: {remoteFiles.Count}"); - return new RemoteSnapshot(commitSha, normalizedSubdir, remoteFiles); + return commitSha; + } + + private static string BuildBranchApiUrl(GitHubRepoInfo repoInfo, string branch) + { + return $"https://api.github.com/repos/{Uri.EscapeDataString(repoInfo.Owner)}/{Uri.EscapeDataString(repoInfo.Repo)}/branches/{Uri.EscapeDataString(branch)}"; } - private static string BuildTreeApiUrl(GitHubRepoInfo repoInfo, string branch) + private static string BuildTreeApiUrl(GitHubRepoInfo repoInfo, string reference) { - return $"https://api.github.com/repos/{Uri.EscapeDataString(repoInfo.Owner)}/{Uri.EscapeDataString(repoInfo.Repo)}/git/trees/{Uri.EscapeDataString(branch)}?recursive=1"; + return $"https://api.github.com/repos/{Uri.EscapeDataString(repoInfo.Owner)}/{Uri.EscapeDataString(repoInfo.Repo)}/git/trees/{Uri.EscapeDataString(reference)}?recursive=1"; } private static string BuildRawFileUrl(GitHubRepoInfo repoInfo, string commitSha, string remoteFilePath) @@ -414,12 +439,73 @@ private static string CombineRemotePath(string left, string right) return $"{normalizedLeft}/{normalizedRight}"; } - private static SyncPlan BuildPlan(Dictionary remoteFiles, Dictionary localFiles) + private static bool TryNormalizeRelativePath(string relativePath, out string normalizedPath) + { + normalizedPath = NormalizeRemotePath(relativePath); + if (string.IsNullOrWhiteSpace(normalizedPath) || Path.IsPathRooted(normalizedPath)) + { + return false; + } + + var segments = normalizedPath.Split('/'); + if (segments.Length == 0) + { + return false; + } + + foreach (var segment in segments) + { + if (string.IsNullOrWhiteSpace(segment) || + string.Equals(segment, ".", StringComparison.Ordinal) || + string.Equals(segment, "..", StringComparison.Ordinal) || + segment.IndexOf(':') >= 0) + { + return false; + } + } + + normalizedPath = string.Join("/", segments); + return true; + } + + private static string ResolvePathUnderRoot(string root, string relativePath, StringComparison pathComparison) + { + if (!TryNormalizeRelativePath(relativePath, out var safeRelativePath)) + { + throw new InvalidOperationException($"Unsafe relative path: {relativePath}"); + } + + var normalizedRoot = EnsureTrailingDirectorySeparator(Path.GetFullPath(root)); + var combinedPath = Path.Combine(normalizedRoot, safeRelativePath.Replace('/', Path.DirectorySeparatorChar)); + var fullPath = Path.GetFullPath(combinedPath); + if (!fullPath.StartsWith(normalizedRoot, pathComparison)) + { + throw new InvalidOperationException($"Path escapes install root: {relativePath}"); + } + + return fullPath; + } + + private static string EnsureTrailingDirectorySeparator(string path) + { + return path.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar; + } + + private static SyncPlan BuildPlan(Dictionary remoteFiles, Dictionary localFiles, StringComparer pathComparer) { var plan = new SyncPlan(); + var localLookup = new Dictionary(pathComparer); + foreach (var localEntry in localFiles) + { + if (!localLookup.ContainsKey(localEntry.Key)) + { + localLookup[localEntry.Key] = localEntry.Value; + } + } + foreach (var remoteEntry in remoteFiles) { - if (!localFiles.TryGetValue(remoteEntry.Key, out var localPath)) + if (!localLookup.TryGetValue(remoteEntry.Key, out var localPath)) { plan.Added.Add(remoteEntry.Key); continue; @@ -432,9 +518,10 @@ private static SyncPlan BuildPlan(Dictionary remoteFiles, Dictio } } + var remoteLookup = new HashSet(remoteFiles.Keys, pathComparer); foreach (var localRelativePath in localFiles.Keys) { - if (!remoteFiles.ContainsKey(localRelativePath)) + if (!remoteLookup.Contains(localRelativePath)) { plan.Deleted.Add(localRelativePath); } @@ -446,14 +533,14 @@ private static SyncPlan BuildPlan(Dictionary remoteFiles, Dictio return plan; } - private void ApplyPlan(GitHubRepoInfo repoInfo, string commitSha, string remoteSubdir, string targetRoot, SyncPlan plan) + private void ApplyPlan(GitHubRepoInfo repoInfo, string commitSha, string remoteSubdir, string targetRoot, SyncPlan plan, StringComparison pathComparison) { using var client = CreateGitHubClient(); foreach (var relativePath in plan.Added.Concat(plan.Updated)) { var remoteFilePath = CombineRemotePath(remoteSubdir, relativePath); var downloadUrl = BuildRawFileUrl(repoInfo, commitSha, remoteFilePath); - var targetFile = Path.Combine(targetRoot, relativePath); + var targetFile = ResolvePathUnderRoot(targetRoot, relativePath, pathComparison); var targetDirectory = Path.GetDirectoryName(targetFile); if (!string.IsNullOrEmpty(targetDirectory)) { @@ -467,7 +554,7 @@ private void ApplyPlan(GitHubRepoInfo repoInfo, string commitSha, string remoteS foreach (var relativePath in plan.Deleted) { - var targetFile = Path.Combine(targetRoot, relativePath); + var targetFile = ResolvePathUnderRoot(targetRoot, relativePath, pathComparison); if (File.Exists(targetFile)) { File.Delete(targetFile); @@ -477,12 +564,12 @@ private void ApplyPlan(GitHubRepoInfo repoInfo, string commitSha, string remoteS RemoveEmptyDirectories(targetRoot); } - private void ValidateFileHashes(string installRoot, Dictionary remoteFiles) + private void ValidateFileHashes(string installRoot, Dictionary remoteFiles, StringComparison pathComparison) { var checkedCount = 0; foreach (var remoteEntry in remoteFiles) { - var localPath = Path.Combine(installRoot, remoteEntry.Key); + var localPath = ResolvePathUnderRoot(installRoot, remoteEntry.Key, pathComparison); if (!File.Exists(localPath)) { throw new InvalidOperationException($"Missing synced file: {remoteEntry.Key}"); @@ -527,12 +614,133 @@ private static Dictionary ListFiles(string root) foreach (var filePath in Directory.GetFiles(normalizedRoot, "*", SearchOption.AllDirectories)) { var relativePath = Path.GetRelativePath(normalizedRoot, filePath).Replace('\\', '/'); + if (string.Equals(relativePath, SyncOwnershipMarker, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + map[relativePath] = filePath; } return map; } + private static void EnsureManagedInstallRoot( + string installPath, + ICollection localRelativePaths, + ICollection remoteRelativePaths, + StringComparer pathComparer) + { + var markerPath = Path.Combine(installPath, SyncOwnershipMarker); + if (File.Exists(markerPath)) + { + return; + } + + if (localRelativePaths.Count > 0 && !CanAdoptLegacyManagedRoot(localRelativePaths, remoteRelativePaths, pathComparer)) + { + throw new InvalidOperationException( + "Install Dir contains unmanaged files. " + + "Please choose an empty folder or an existing unity-mcp-skill folder."); + } + + File.WriteAllText(markerPath, "managed-by-unity-mcp-skill-sync"); + } + + private static bool CanAdoptLegacyManagedRoot( + ICollection localRelativePaths, + ICollection remoteRelativePaths, + StringComparer pathComparer) + { + if (localRelativePaths.Count == 0) + { + return true; + } + + var remoteTopLevels = new HashSet(pathComparer); + foreach (var remotePath in remoteRelativePaths) + { + var topLevel = GetTopLevelSegment(remotePath); + if (!string.IsNullOrWhiteSpace(topLevel)) + { + remoteTopLevels.Add(topLevel); + } + } + + if (remoteTopLevels.Count == 0) + { + return false; + } + + var hasSkillDefinition = false; + foreach (var localPath in localRelativePaths) + { + if (pathComparer.Equals(localPath, "SKILL.md")) + { + hasSkillDefinition = true; + } + + var topLevel = GetTopLevelSegment(localPath); + if (string.IsNullOrWhiteSpace(topLevel) || !remoteTopLevels.Contains(topLevel)) + { + return false; + } + } + + return hasSkillDefinition; + } + + private static string GetTopLevelSegment(string relativePath) + { + if (string.IsNullOrWhiteSpace(relativePath)) + { + return string.Empty; + } + + var normalized = NormalizeRemotePath(relativePath); + var separatorIndex = normalized.IndexOf('/'); + return separatorIndex < 0 ? normalized : normalized.Substring(0, separatorIndex); + } + + private static StringComparison GetPathComparison(string root) + { + return IsCaseSensitiveFileSystem(root) ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase; + } + + private static StringComparer GetPathComparer(StringComparison pathComparison) + { + return pathComparison == StringComparison.Ordinal + ? StringComparer.Ordinal + : StringComparer.OrdinalIgnoreCase; + } + + private static bool IsCaseSensitiveFileSystem(string root) + { + try + { + var probeName = $".mcp-case-probe-{Guid.NewGuid():N}"; + var lowercasePath = Path.Combine(root, probeName.ToLowerInvariant()); + var uppercasePath = Path.Combine(root, probeName.ToUpperInvariant()); + File.WriteAllText(lowercasePath, string.Empty); + try + { + return !File.Exists(uppercasePath); + } + finally + { + if (File.Exists(lowercasePath)) + { + File.Delete(lowercasePath); + } + } + } + catch + { + // Conservative fallback for security checks. + return true; + } + } + private static void RemoveEmptyDirectories(string root) { if (!Directory.Exists(root)) @@ -818,6 +1026,18 @@ private sealed class GitHubTreeResponse public bool truncated; } + [Serializable] + private sealed class GitHubBranchResponse + { + public GitHubBranchCommit commit; + } + + [Serializable] + private sealed class GitHubBranchCommit + { + public string sha; + } + [Serializable] private sealed class GitHubTreeEntry { From d1eca2e9b55df2ff570ec4145b945fd033260011 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E6=B4=8B=E5=87=A1?= <378905096@qq.com> Date: Sat, 28 Feb 2026 17:18:23 +0800 Subject: [PATCH 4/6] meta update --- .../Editor/Setup/McpForUnitySkillInstaller.cs.meta | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/MCPForUnity/Editor/Setup/McpForUnitySkillInstaller.cs.meta b/MCPForUnity/Editor/Setup/McpForUnitySkillInstaller.cs.meta index 98d62e804..575ecb0f9 100644 --- a/MCPForUnity/Editor/Setup/McpForUnitySkillInstaller.cs.meta +++ b/MCPForUnity/Editor/Setup/McpForUnitySkillInstaller.cs.meta @@ -1,2 +1,11 @@ fileFormatVersion: 2 -guid: 7886932de812549f195fa4f6006ef0dd \ No newline at end of file +guid: 7886932de812549f195fa4f6006ef0dd +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: From 5f833dc46e1527271211d71c350249dfad0a0d22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E6=B4=8B=E5=87=A1?= <378905096@qq.com> Date: Sat, 28 Feb 2026 17:43:39 +0800 Subject: [PATCH 5/6] =?UTF-8?q?fix:=20freeze=20sync=20inputs=20and=20valid?= =?UTF-8?q?ate=20install=20path=20(=E7=94=B1codex=E7=94=9F=E6=88=90)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Snapshot repo/branch/installDir before starting background sync task - Disable config inputs while sync is running to avoid mid-run mutations - Validate install directory explicitly with clear errors before filesystem operations - Scope last-synced-commit key derivation to captured repo/branch values --- .../Editor/Setup/McpForUnitySkillInstaller.cs | 96 ++++++++++++------- 1 file changed, 64 insertions(+), 32 deletions(-) diff --git a/MCPForUnity/Editor/Setup/McpForUnitySkillInstaller.cs b/MCPForUnity/Editor/Setup/McpForUnitySkillInstaller.cs index 97d57cde5..87cc29809 100644 --- a/MCPForUnity/Editor/Setup/McpForUnitySkillInstaller.cs +++ b/MCPForUnity/Editor/Setup/McpForUnitySkillInstaller.cs @@ -76,31 +76,34 @@ private void OnGUI() EditorGUILayout.Space(4f); EditorGUILayout.LabelField("Config", EditorStyles.boldLabel); - _repoUrl = EditorGUILayout.TextField("Repo URL", _repoUrl); - var branchIndex = Array.IndexOf(BranchOptions, _targetBranch); - if (branchIndex < 0) + using (new EditorGUI.DisabledScope(_isRunning)) { - branchIndex = 0; - } + _repoUrl = EditorGUILayout.TextField("Repo URL", _repoUrl); + var branchIndex = Array.IndexOf(BranchOptions, _targetBranch); + if (branchIndex < 0) + { + branchIndex = 0; + } - var selectedBranchIndex = EditorGUILayout.Popup("Branch", branchIndex, BranchOptions); - _targetBranch = BranchOptions[selectedBranchIndex]; + var selectedBranchIndex = EditorGUILayout.Popup("Branch", branchIndex, BranchOptions); + _targetBranch = BranchOptions[selectedBranchIndex]; - var cliIndex = Array.IndexOf(CliOptions, _cliType); - if (cliIndex < 0) - { - cliIndex = 0; - } + var cliIndex = Array.IndexOf(CliOptions, _cliType); + if (cliIndex < 0) + { + cliIndex = 0; + } - var selectedCliIndex = EditorGUILayout.Popup("CLI", cliIndex, CliOptions); - if (selectedCliIndex != cliIndex) - { - var previousCli = _cliType; - _cliType = CliOptions[selectedCliIndex]; - TryApplyCliDefaultInstallPath(previousCli, _cliType); - } + var selectedCliIndex = EditorGUILayout.Popup("CLI", cliIndex, CliOptions); + if (selectedCliIndex != cliIndex) + { + var previousCli = _cliType; + _cliType = CliOptions[selectedCliIndex]; + TryApplyCliDefaultInstallPath(previousCli, _cliType); + } - _installDir = EditorGUILayout.TextField("Install Dir", _installDir); + _installDir = EditorGUILayout.TextField("Install Dir", _installDir); + } EditorGUILayout.Space(8f); EditorGUILayout.BeginHorizontal(); @@ -142,19 +145,22 @@ private void OnEditorUpdate() private void RunSyncLatest() { - var lastSyncedCommitKey = GetLastSyncedCommitKey(); + var repoUrl = _repoUrl; + var targetBranch = _targetBranch; + var installDir = _installDir; + var lastSyncedCommitKey = GetLastSyncedCommitKey(repoUrl, targetBranch); var lastSyncedCommit = EditorPrefs.GetString(lastSyncedCommitKey, string.Empty); ExecuteWithGuard(() => { AppendLine("=== Sync Start ==="); - if (!TryParseGitHubRepository(_repoUrl, out var repoInfo)) + if (!TryParseGitHubRepository(repoUrl, out var repoInfo)) { - throw new InvalidOperationException($"Repo URL is not a recognized GitHub repository URL: {_repoUrl}"); + throw new InvalidOperationException($"Repo URL is not a recognized GitHub repository URL: {repoUrl}"); } - AppendLine($"Target repository: {repoInfo.Owner}/{repoInfo.Repo}@{_targetBranch}"); - var snapshot = FetchRemoteSnapshot(repoInfo, _targetBranch, FixedSkillSubdir); - var installPath = GetInstallPath(); + AppendLine($"Target repository: {repoInfo.Owner}/{repoInfo.Repo}@{targetBranch}"); + var snapshot = FetchRemoteSnapshot(repoInfo, targetBranch, FixedSkillSubdir); + var installPath = ResolveAndValidateInstallPath(installDir); if (!Directory.Exists(installPath)) { @@ -170,8 +176,8 @@ private void RunSyncLatest() AppendLine($"Remote Commit: {ShortCommit(lastSyncedCommit)} -> {ShortCommit(snapshot.CommitSha)}"); AppendLine(commitChanged - ? $"Commit: detected newer commit on {_targetBranch}." - : $"Commit: no new commit on {_targetBranch} since last sync."); + ? $"Commit: detected newer commit on {targetBranch}." + : $"Commit: no new commit on {targetBranch} since last sync."); AppendLine($"Plan => Added:{plan.Added.Count} Updated:{plan.Updated.Count} Deleted:{plan.Deleted.Count}"); AppendSummary(plan, commitChanged); LogPlanDetails(plan); @@ -211,9 +217,9 @@ private void ExecuteWithGuard(Action action) }); } - private string GetLastSyncedCommitKey() + private string GetLastSyncedCommitKey(string repoUrl, string targetBranch) { - var scope = $"{_repoUrl}|{_targetBranch}|{NormalizeRemotePath(FixedSkillSubdir)}"; + var scope = $"{repoUrl}|{targetBranch}|{NormalizeRemotePath(FixedSkillSubdir)}"; using var sha256 = SHA256.Create(); var hash = sha256.ComputeHash(Encoding.UTF8.GetBytes(scope)); var suffix = BitConverter.ToString(hash).Replace("-", string.Empty).ToLowerInvariant(); @@ -761,9 +767,35 @@ private static void RemoveEmptyDirectories(string root) } } - private string GetInstallPath() + private static string ResolveAndValidateInstallPath(string installDir) { - return ExpandPath(_installDir); + if (string.IsNullOrWhiteSpace(installDir)) + { + throw new InvalidOperationException("Install Dir is empty. Please set a valid directory before syncing."); + } + + var trimmed = installDir.Trim(); + if (trimmed.IndexOfAny(Path.GetInvalidPathChars()) >= 0) + { + throw new InvalidOperationException($"Install Dir contains invalid path characters: {installDir}"); + } + + string expandedPath; + try + { + expandedPath = ExpandPath(trimmed); + } + catch (Exception ex) + { + throw new InvalidOperationException($"Install Dir is invalid and cannot be resolved: {installDir}", ex); + } + + if (string.IsNullOrWhiteSpace(expandedPath)) + { + throw new InvalidOperationException("Install Dir resolved to an empty path. Please set a valid directory before syncing."); + } + + return expandedPath; } private void TryApplyCliDefaultInstallPath(string previousCli, string currentCli) From b5d8e2a8865cdc1e2df01728b27a70db44296ec9 Mon Sep 17 00:00:00 2001 From: Shutong Wu <51266340+Scriptwonder@users.noreply.github.com> Date: Sat, 28 Feb 2026 21:52:49 -0500 Subject: [PATCH 6/6] Update to incorporate the skill installation into current config 1. Incorporate the Config into a simple one-click in Client Configuration. 2. Simplify the logs to only 1 entry, convenient for LLM checking. --- .../Configurators/ClaudeCodeConfigurator.cs | 10 + .../ClaudeDesktopConfigurator.cs | 8 + .../Configurators/CodexConfigurator.cs | 8 + .../Editor/Clients/IMcpClientConfigurator.cs | 6 + .../Clients/McpClientConfiguratorBase.cs | 2 + .../Editor/Setup/McpForUnitySkillInstaller.cs | 847 +----------------- MCPForUnity/Editor/Setup/SkillSyncService.cs | 816 +++++++++++++++++ .../Editor/Setup/SkillSyncService.cs.meta | 11 + .../ClientConfig/McpClientConfigSection.cs | 65 ++ .../ClientConfig/McpClientConfigSection.uxml | 1 + 10 files changed, 941 insertions(+), 833 deletions(-) create mode 100644 MCPForUnity/Editor/Setup/SkillSyncService.cs create mode 100644 MCPForUnity/Editor/Setup/SkillSyncService.cs.meta diff --git a/MCPForUnity/Editor/Clients/Configurators/ClaudeCodeConfigurator.cs b/MCPForUnity/Editor/Clients/Configurators/ClaudeCodeConfigurator.cs index d2545b8d7..22504207c 100644 --- a/MCPForUnity/Editor/Clients/Configurators/ClaudeCodeConfigurator.cs +++ b/MCPForUnity/Editor/Clients/Configurators/ClaudeCodeConfigurator.cs @@ -1,4 +1,6 @@ +using System; using System.Collections.Generic; +using System.IO; using MCPForUnity.Editor.Models; namespace MCPForUnity.Editor.Clients.Configurators @@ -16,6 +18,14 @@ public ClaudeCodeConfigurator() : base(new McpClient }) { } + public override bool SupportsSkills => true; + + public override string GetSkillInstallPath() + { + var userHome = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + return Path.Combine(userHome, ".claude", "skills", "unity-mcp-skill"); + } + public override IList GetInstallationSteps() => new List { "Ensure Claude CLI is installed (comes with Claude Code)", diff --git a/MCPForUnity/Editor/Clients/Configurators/ClaudeDesktopConfigurator.cs b/MCPForUnity/Editor/Clients/Configurators/ClaudeDesktopConfigurator.cs index 72861349b..d784ebea2 100644 --- a/MCPForUnity/Editor/Clients/Configurators/ClaudeDesktopConfigurator.cs +++ b/MCPForUnity/Editor/Clients/Configurators/ClaudeDesktopConfigurator.cs @@ -23,6 +23,14 @@ public ClaudeDesktopConfigurator() : base(new McpClient }) { } + public override bool SupportsSkills => true; + + public override string GetSkillInstallPath() + { + var userHome = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + return Path.Combine(userHome, ".claude", "skills", "unity-mcp-skill"); + } + public override IList GetInstallationSteps() => new List { "Open Claude Desktop", diff --git a/MCPForUnity/Editor/Clients/Configurators/CodexConfigurator.cs b/MCPForUnity/Editor/Clients/Configurators/CodexConfigurator.cs index 9337d4cd5..00cc0fe63 100644 --- a/MCPForUnity/Editor/Clients/Configurators/CodexConfigurator.cs +++ b/MCPForUnity/Editor/Clients/Configurators/CodexConfigurator.cs @@ -16,6 +16,14 @@ public CodexConfigurator() : base(new McpClient }) { } + public override bool SupportsSkills => true; + + public override string GetSkillInstallPath() + { + var userHome = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + return Path.Combine(userHome, ".codex", "skills", "unity-mcp-skill"); + } + public override IList GetInstallationSteps() => new List { "Run 'codex config edit' in a terminal\nOR open the config file at the path above", diff --git a/MCPForUnity/Editor/Clients/IMcpClientConfigurator.cs b/MCPForUnity/Editor/Clients/IMcpClientConfigurator.cs index 9fdea29c8..10fefff08 100644 --- a/MCPForUnity/Editor/Clients/IMcpClientConfigurator.cs +++ b/MCPForUnity/Editor/Clients/IMcpClientConfigurator.cs @@ -43,5 +43,11 @@ public interface IMcpClientConfigurator /// Returns ordered human-readable installation steps. System.Collections.Generic.IList GetInstallationSteps(); + + /// True if this client supports skill installation/sync. + bool SupportsSkills { get; } + + /// Returns the absolute path where skills should be installed, or null if unsupported. + string GetSkillInstallPath(); } } diff --git a/MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs b/MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs index 5708626ab..f2f170736 100644 --- a/MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs +++ b/MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs @@ -30,7 +30,9 @@ protected McpClientConfiguratorBase(McpClient client) public McpStatus Status => client.status; public ConfiguredTransport ConfiguredTransport => client.configuredTransport; public virtual bool SupportsAutoConfigure => true; + public virtual bool SupportsSkills => false; public virtual string GetConfigureActionLabel() => "Configure"; + public virtual string GetSkillInstallPath() => null; public abstract string GetConfigPath(); public abstract McpStatus CheckStatus(bool attemptAutoRewrite = true); diff --git a/MCPForUnity/Editor/Setup/McpForUnitySkillInstaller.cs b/MCPForUnity/Editor/Setup/McpForUnitySkillInstaller.cs index 87cc29809..ade13f40a 100644 --- a/MCPForUnity/Editor/Setup/McpForUnitySkillInstaller.cs +++ b/MCPForUnity/Editor/Setup/McpForUnitySkillInstaller.cs @@ -1,12 +1,8 @@ using System; using System.Collections.Concurrent; -using System.Collections.Generic; using System.IO; using System.Linq; -using System.Net.Http; -using System.Security.Cryptography; using System.Text; -using System.Threading.Tasks; using UnityEditor; using UnityEngine; @@ -18,9 +14,6 @@ public class McpForUnitySkillInstaller : EditorWindow private const string BranchKey = "UnityMcpSkillSync.Branch"; private const string CliKey = "UnityMcpSkillSync.Cli"; private const string InstallDirKey = "UnityMcpSkillSync.InstallDir"; - private const string LastSyncedCommitKey = "UnityMcpSkillSync.LastSyncedCommit"; - private const string FixedSkillSubdir = "unity-mcp-skill"; - private const string SyncOwnershipMarker = ".unity-mcp-skill-sync"; private const string CodexCli = "codex"; private const string ClaudeCli = "claude"; private static readonly string[] BranchOptions = { "beta", "main" }; @@ -32,7 +25,6 @@ public class McpForUnitySkillInstaller : EditorWindow private string _installDir; private Vector2 _scroll; private volatile bool _isRunning; - private readonly ConcurrentQueue _mainThreadActions = new(); private readonly ConcurrentQueue _pendingLogs = new(); private readonly StringBuilder _logBuilder = new(4096); @@ -135,7 +127,6 @@ private void OnGUI() private void OnEditorUpdate() { - ExecuteMainThreadActions(); var changed = FlushPendingLogs(); if (_isRunning || changed) { @@ -144,55 +135,6 @@ private void OnEditorUpdate() } private void RunSyncLatest() - { - var repoUrl = _repoUrl; - var targetBranch = _targetBranch; - var installDir = _installDir; - var lastSyncedCommitKey = GetLastSyncedCommitKey(repoUrl, targetBranch); - var lastSyncedCommit = EditorPrefs.GetString(lastSyncedCommitKey, string.Empty); - ExecuteWithGuard(() => - { - AppendLine("=== Sync Start ==="); - if (!TryParseGitHubRepository(repoUrl, out var repoInfo)) - { - throw new InvalidOperationException($"Repo URL is not a recognized GitHub repository URL: {repoUrl}"); - } - - AppendLine($"Target repository: {repoInfo.Owner}/{repoInfo.Repo}@{targetBranch}"); - var snapshot = FetchRemoteSnapshot(repoInfo, targetBranch, FixedSkillSubdir); - var installPath = ResolveAndValidateInstallPath(installDir); - - if (!Directory.Exists(installPath)) - { - Directory.CreateDirectory(installPath); - } - - var localFiles = ListFiles(installPath); - var pathComparison = GetPathComparison(installPath); - var pathComparer = GetPathComparer(pathComparison); - EnsureManagedInstallRoot(installPath, localFiles.Keys, snapshot.Files.Keys, pathComparer); - var plan = BuildPlan(snapshot.Files, localFiles, pathComparer); - var commitChanged = !string.Equals(lastSyncedCommit, snapshot.CommitSha, StringComparison.Ordinal); - - AppendLine($"Remote Commit: {ShortCommit(lastSyncedCommit)} -> {ShortCommit(snapshot.CommitSha)}"); - AppendLine(commitChanged - ? $"Commit: detected newer commit on {targetBranch}." - : $"Commit: no new commit on {targetBranch} since last sync."); - AppendLine($"Plan => Added:{plan.Added.Count} Updated:{plan.Updated.Count} Deleted:{plan.Deleted.Count}"); - AppendSummary(plan, commitChanged); - LogPlanDetails(plan); - - ApplyPlan(repoInfo, snapshot.CommitSha, snapshot.SubdirPath, installPath, plan, pathComparison); - AppendLine("Files mirrored to install directory."); - - ValidateFileHashes(installPath, snapshot.Files, pathComparison); - EnqueueMainThreadAction(() => EditorPrefs.SetString(lastSyncedCommitKey, snapshot.CommitSha)); - AppendLine($"Synced to commit: {snapshot.CommitSha}"); - AppendLine("=== Sync Done ==="); - }); - } - - private void ExecuteWithGuard(Action action) { if (_isRunning) { @@ -200,602 +142,20 @@ private void ExecuteWithGuard(Action action) } _isRunning = true; - Task.Run(() => - { - try - { - action.Invoke(); - } - catch (Exception ex) - { - AppendLine($"[ERROR] {ex.Message}"); - } - finally + SkillSyncService.SyncAsync(_repoUrl, _installDir, _targetBranch, + line => _pendingLogs.Enqueue($"[{DateTime.Now:HH:mm:ss}] {SanitizeLogLine(line)}"), + result => { _isRunning = false; - } - }); - } - - private string GetLastSyncedCommitKey(string repoUrl, string targetBranch) - { - var scope = $"{repoUrl}|{targetBranch}|{NormalizeRemotePath(FixedSkillSubdir)}"; - using var sha256 = SHA256.Create(); - var hash = sha256.ComputeHash(Encoding.UTF8.GetBytes(scope)); - var suffix = BitConverter.ToString(hash).Replace("-", string.Empty).ToLowerInvariant(); - return $"{LastSyncedCommitKey}.{suffix}"; - } - - private static bool TryParseGitHubRepository(string url, out GitHubRepoInfo repoInfo) - { - repoInfo = default; - if (string.IsNullOrWhiteSpace(url)) - { - return false; - } - - var trimmed = url.Trim(); - if (trimmed.StartsWith("git@github.com:", StringComparison.OrdinalIgnoreCase)) - { - var repoPath = trimmed.Substring("git@github.com:".Length).Trim('/'); - return TryParseOwnerAndRepo(repoPath, out repoInfo); - } - - if (!Uri.TryCreate(trimmed, UriKind.Absolute, out var uri)) - { - return false; - } - - if (!string.Equals(uri.Host, "github.com", StringComparison.OrdinalIgnoreCase)) - { - return false; - } - - var repoPathFromUri = uri.AbsolutePath.Trim('/'); - return TryParseOwnerAndRepo(repoPathFromUri, out repoInfo); - } - - private static bool TryParseOwnerAndRepo(string path, out GitHubRepoInfo repoInfo) - { - repoInfo = default; - var segments = path.Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries); - if (segments.Length < 2) - { - return false; - } - - var owner = segments[0].Trim(); - var repo = segments[1].Trim(); - if (repo.EndsWith(".git", StringComparison.OrdinalIgnoreCase)) - { - repo = repo.Substring(0, repo.Length - 4); - } - - if (string.IsNullOrWhiteSpace(owner) || string.IsNullOrWhiteSpace(repo)) - { - return false; - } - - repoInfo = new GitHubRepoInfo(owner, repo); - return true; - } - - private RemoteSnapshot FetchRemoteSnapshot(GitHubRepoInfo repoInfo, string branch, string subdir) - { - using var client = CreateGitHubClient(); - var commitSha = FetchBranchHeadCommitSha(client, repoInfo, branch); - var treeApiUrl = BuildTreeApiUrl(repoInfo, commitSha); - AppendLine($"Fetching remote directory tree at commit {ShortCommit(commitSha)}: {treeApiUrl}"); - var json = DownloadString(client, treeApiUrl); - var treeResponse = JsonUtility.FromJson(json); - if (treeResponse == null || treeResponse.tree == null) - { - throw new InvalidOperationException("Failed to parse GitHub directory tree response."); - } - - if (treeResponse.truncated) - { - throw new InvalidOperationException( - "GitHub returned a truncated directory tree (incomplete snapshot). " + - "Sync was aborted to prevent accidental deletion of valid local files."); - } - - var normalizedSubdir = NormalizeRemotePath(subdir); - var subdirPrefix = string.IsNullOrEmpty(normalizedSubdir) ? string.Empty : $"{normalizedSubdir}/"; - var remoteFiles = new Dictionary(StringComparer.Ordinal); - - foreach (var entry in treeResponse.tree) - { - if (!string.Equals(entry.type, "blob", StringComparison.Ordinal)) - { - continue; - } - - var remotePath = NormalizeRemotePath(entry.path); - if (string.IsNullOrEmpty(remotePath)) - { - continue; - } - - if (!string.IsNullOrEmpty(subdirPrefix) && - !remotePath.StartsWith(subdirPrefix, StringComparison.Ordinal)) - { - continue; - } - - var relativePath = string.IsNullOrEmpty(subdirPrefix) - ? remotePath - : remotePath.Substring(subdirPrefix.Length); - if (string.IsNullOrWhiteSpace(relativePath) || string.IsNullOrWhiteSpace(entry.sha)) - { - continue; - } - - if (!TryNormalizeRelativePath(relativePath, out var safeRelativePath)) - { - AppendLine($"Skip unsafe remote path: {remotePath}"); - continue; - } - - remoteFiles[safeRelativePath] = entry.sha.Trim().ToLowerInvariant(); - } - - if (remoteFiles.Count == 0) - { - throw new InvalidOperationException($"Remote directory not found: {normalizedSubdir}"); - } - - AppendLine($"Remote file count: {remoteFiles.Count}"); - return new RemoteSnapshot(commitSha, normalizedSubdir, remoteFiles); - } - - private string FetchBranchHeadCommitSha(HttpClient client, GitHubRepoInfo repoInfo, string branch) - { - var branchApiUrl = BuildBranchApiUrl(repoInfo, branch); - AppendLine($"Fetching branch head commit: {branchApiUrl}"); - var branchJson = DownloadString(client, branchApiUrl); - var branchResponse = JsonUtility.FromJson(branchJson); - var commitSha = branchResponse?.commit?.sha?.Trim(); - if (string.IsNullOrWhiteSpace(commitSha)) - { - throw new InvalidOperationException($"Failed to resolve branch head commit SHA for: {branch}"); - } - - return commitSha; - } - - private static string BuildBranchApiUrl(GitHubRepoInfo repoInfo, string branch) - { - return $"https://api.github.com/repos/{Uri.EscapeDataString(repoInfo.Owner)}/{Uri.EscapeDataString(repoInfo.Repo)}/branches/{Uri.EscapeDataString(branch)}"; - } - - private static string BuildTreeApiUrl(GitHubRepoInfo repoInfo, string reference) - { - return $"https://api.github.com/repos/{Uri.EscapeDataString(repoInfo.Owner)}/{Uri.EscapeDataString(repoInfo.Repo)}/git/trees/{Uri.EscapeDataString(reference)}?recursive=1"; - } - - private static string BuildRawFileUrl(GitHubRepoInfo repoInfo, string commitSha, string remoteFilePath) - { - var encodedPath = string.Join("/", - NormalizeRemotePath(remoteFilePath) - .Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries) - .Select(Uri.EscapeDataString)); - return $"https://raw.githubusercontent.com/{Uri.EscapeDataString(repoInfo.Owner)}/{Uri.EscapeDataString(repoInfo.Repo)}/{Uri.EscapeDataString(commitSha)}/{encodedPath}"; - } - - private static HttpClient CreateGitHubClient() - { - var client = new HttpClient - { - Timeout = TimeSpan.FromSeconds(60) - }; - client.DefaultRequestHeaders.UserAgent.ParseAdd("UnityMcpSkillSyncWindow/1.0"); - client.DefaultRequestHeaders.Accept.ParseAdd("application/vnd.github+json"); - return client; - } - - private static string DownloadString(HttpClient client, string url) - { - using var response = client.GetAsync(url).GetAwaiter().GetResult(); - var body = response.Content.ReadAsStringAsync().GetAwaiter().GetResult(); - if (!response.IsSuccessStatusCode) - { - throw new InvalidOperationException($"GitHub request failed: {(int)response.StatusCode} {response.ReasonPhrase} ({url})\n{body}"); - } - - return body; - } - - private static byte[] DownloadBytes(HttpClient client, string url) - { - using var response = client.GetAsync(url).GetAwaiter().GetResult(); - if (!response.IsSuccessStatusCode) - { - var body = response.Content.ReadAsStringAsync().GetAwaiter().GetResult(); - throw new InvalidOperationException($"File download failed: {(int)response.StatusCode} {response.ReasonPhrase} ({url})\n{body}"); - } - - return response.Content.ReadAsByteArrayAsync().GetAwaiter().GetResult(); - } - - private static string NormalizeRemotePath(string path) - { - if (string.IsNullOrWhiteSpace(path)) - { - return string.Empty; - } - - return path.Replace('\\', '/').Trim().Trim('/'); - } - - private static string CombineRemotePath(string left, string right) - { - var normalizedLeft = NormalizeRemotePath(left); - var normalizedRight = NormalizeRemotePath(right); - if (string.IsNullOrEmpty(normalizedLeft)) - { - return normalizedRight; - } - - if (string.IsNullOrEmpty(normalizedRight)) - { - return normalizedLeft; - } - - return $"{normalizedLeft}/{normalizedRight}"; - } - - private static bool TryNormalizeRelativePath(string relativePath, out string normalizedPath) - { - normalizedPath = NormalizeRemotePath(relativePath); - if (string.IsNullOrWhiteSpace(normalizedPath) || Path.IsPathRooted(normalizedPath)) - { - return false; - } - - var segments = normalizedPath.Split('/'); - if (segments.Length == 0) - { - return false; - } - - foreach (var segment in segments) - { - if (string.IsNullOrWhiteSpace(segment) || - string.Equals(segment, ".", StringComparison.Ordinal) || - string.Equals(segment, "..", StringComparison.Ordinal) || - segment.IndexOf(':') >= 0) - { - return false; - } - } - - normalizedPath = string.Join("/", segments); - return true; - } - - private static string ResolvePathUnderRoot(string root, string relativePath, StringComparison pathComparison) - { - if (!TryNormalizeRelativePath(relativePath, out var safeRelativePath)) - { - throw new InvalidOperationException($"Unsafe relative path: {relativePath}"); - } - - var normalizedRoot = EnsureTrailingDirectorySeparator(Path.GetFullPath(root)); - var combinedPath = Path.Combine(normalizedRoot, safeRelativePath.Replace('/', Path.DirectorySeparatorChar)); - var fullPath = Path.GetFullPath(combinedPath); - if (!fullPath.StartsWith(normalizedRoot, pathComparison)) - { - throw new InvalidOperationException($"Path escapes install root: {relativePath}"); - } - - return fullPath; - } - - private static string EnsureTrailingDirectorySeparator(string path) - { - return path.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar; - } - - private static SyncPlan BuildPlan(Dictionary remoteFiles, Dictionary localFiles, StringComparer pathComparer) - { - var plan = new SyncPlan(); - var localLookup = new Dictionary(pathComparer); - foreach (var localEntry in localFiles) - { - if (!localLookup.ContainsKey(localEntry.Key)) - { - localLookup[localEntry.Key] = localEntry.Value; - } - } - - foreach (var remoteEntry in remoteFiles) - { - if (!localLookup.TryGetValue(remoteEntry.Key, out var localPath)) - { - plan.Added.Add(remoteEntry.Key); - continue; - } - - var localBlobSha = ComputeGitBlobSha1(localPath); - if (!string.Equals(localBlobSha, remoteEntry.Value, StringComparison.Ordinal)) - { - plan.Updated.Add(remoteEntry.Key); - } - } - - var remoteLookup = new HashSet(remoteFiles.Keys, pathComparer); - foreach (var localRelativePath in localFiles.Keys) - { - if (!remoteLookup.Contains(localRelativePath)) - { - plan.Deleted.Add(localRelativePath); - } - } - - plan.Added.Sort(StringComparer.Ordinal); - plan.Updated.Sort(StringComparer.Ordinal); - plan.Deleted.Sort(StringComparer.Ordinal); - return plan; - } - - private void ApplyPlan(GitHubRepoInfo repoInfo, string commitSha, string remoteSubdir, string targetRoot, SyncPlan plan, StringComparison pathComparison) - { - using var client = CreateGitHubClient(); - foreach (var relativePath in plan.Added.Concat(plan.Updated)) - { - var remoteFilePath = CombineRemotePath(remoteSubdir, relativePath); - var downloadUrl = BuildRawFileUrl(repoInfo, commitSha, remoteFilePath); - var targetFile = ResolvePathUnderRoot(targetRoot, relativePath, pathComparison); - var targetDirectory = Path.GetDirectoryName(targetFile); - if (!string.IsNullOrEmpty(targetDirectory)) - { - Directory.CreateDirectory(targetDirectory); - } - - AppendLine($"Download: {relativePath}"); - var bytes = DownloadBytes(client, downloadUrl); - File.WriteAllBytes(targetFile, bytes); - } - - foreach (var relativePath in plan.Deleted) - { - var targetFile = ResolvePathUnderRoot(targetRoot, relativePath, pathComparison); - if (File.Exists(targetFile)) - { - File.Delete(targetFile); - } - } - - RemoveEmptyDirectories(targetRoot); - } - - private void ValidateFileHashes(string installRoot, Dictionary remoteFiles, StringComparison pathComparison) - { - var checkedCount = 0; - foreach (var remoteEntry in remoteFiles) - { - var localPath = ResolvePathUnderRoot(installRoot, remoteEntry.Key, pathComparison); - if (!File.Exists(localPath)) - { - throw new InvalidOperationException($"Missing synced file: {remoteEntry.Key}"); - } - - var localBlobSha = ComputeGitBlobSha1(localPath); - if (!string.Equals(localBlobSha, remoteEntry.Value, StringComparison.Ordinal)) - { - throw new InvalidOperationException($"File hash mismatch: {remoteEntry.Key} ({ShortHash(localBlobSha)} != {ShortHash(remoteEntry.Value)})"); - } - - checkedCount++; - } - - AppendLine($"Hash checks passed ({checkedCount}/{remoteFiles.Count})."); - } - - private static string ComputeGitBlobSha1(string filePath) - { - var bytes = File.ReadAllBytes(filePath); - return ComputeGitBlobSha1(bytes); - } - - private static string ComputeGitBlobSha1(byte[] bytes) - { - var headerBytes = Encoding.UTF8.GetBytes($"blob {bytes.Length}\0"); - using var sha1 = SHA1.Create(); - sha1.TransformBlock(headerBytes, 0, headerBytes.Length, null, 0); - sha1.TransformFinalBlock(bytes, 0, bytes.Length); - return BitConverter.ToString(sha1.Hash ?? Array.Empty()).Replace("-", string.Empty).ToLowerInvariant(); - } - - private static Dictionary ListFiles(string root) - { - var map = new Dictionary(StringComparer.Ordinal); - if (!Directory.Exists(root)) - { - return map; - } - - var normalizedRoot = Path.GetFullPath(root); - foreach (var filePath in Directory.GetFiles(normalizedRoot, "*", SearchOption.AllDirectories)) - { - var relativePath = Path.GetRelativePath(normalizedRoot, filePath).Replace('\\', '/'); - if (string.Equals(relativePath, SyncOwnershipMarker, StringComparison.OrdinalIgnoreCase)) - { - continue; - } - - map[relativePath] = filePath; - } - - return map; - } - - private static void EnsureManagedInstallRoot( - string installPath, - ICollection localRelativePaths, - ICollection remoteRelativePaths, - StringComparer pathComparer) - { - var markerPath = Path.Combine(installPath, SyncOwnershipMarker); - if (File.Exists(markerPath)) - { - return; - } - - if (localRelativePaths.Count > 0 && !CanAdoptLegacyManagedRoot(localRelativePaths, remoteRelativePaths, pathComparer)) - { - throw new InvalidOperationException( - "Install Dir contains unmanaged files. " + - "Please choose an empty folder or an existing unity-mcp-skill folder."); - } - - File.WriteAllText(markerPath, "managed-by-unity-mcp-skill-sync"); - } - - private static bool CanAdoptLegacyManagedRoot( - ICollection localRelativePaths, - ICollection remoteRelativePaths, - StringComparer pathComparer) - { - if (localRelativePaths.Count == 0) - { - return true; - } - - var remoteTopLevels = new HashSet(pathComparer); - foreach (var remotePath in remoteRelativePaths) - { - var topLevel = GetTopLevelSegment(remotePath); - if (!string.IsNullOrWhiteSpace(topLevel)) - { - remoteTopLevels.Add(topLevel); - } - } - - if (remoteTopLevels.Count == 0) - { - return false; - } - - var hasSkillDefinition = false; - foreach (var localPath in localRelativePaths) - { - if (pathComparer.Equals(localPath, "SKILL.md")) - { - hasSkillDefinition = true; - } - - var topLevel = GetTopLevelSegment(localPath); - if (string.IsNullOrWhiteSpace(topLevel) || !remoteTopLevels.Contains(topLevel)) - { - return false; - } - } - - return hasSkillDefinition; - } - - private static string GetTopLevelSegment(string relativePath) - { - if (string.IsNullOrWhiteSpace(relativePath)) - { - return string.Empty; - } - - var normalized = NormalizeRemotePath(relativePath); - var separatorIndex = normalized.IndexOf('/'); - return separatorIndex < 0 ? normalized : normalized.Substring(0, separatorIndex); - } - - private static StringComparison GetPathComparison(string root) - { - return IsCaseSensitiveFileSystem(root) ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase; - } - - private static StringComparer GetPathComparer(StringComparison pathComparison) - { - return pathComparison == StringComparison.Ordinal - ? StringComparer.Ordinal - : StringComparer.OrdinalIgnoreCase; - } - - private static bool IsCaseSensitiveFileSystem(string root) - { - try - { - var probeName = $".mcp-case-probe-{Guid.NewGuid():N}"; - var lowercasePath = Path.Combine(root, probeName.ToLowerInvariant()); - var uppercasePath = Path.Combine(root, probeName.ToUpperInvariant()); - File.WriteAllText(lowercasePath, string.Empty); - try - { - return !File.Exists(uppercasePath); - } - finally - { - if (File.Exists(lowercasePath)) + if (result.Success) { - File.Delete(lowercasePath); + _pendingLogs.Enqueue($"[{DateTime.Now:HH:mm:ss}] Sync complete: +{result.Added} ~{result.Updated} -{result.Deleted}"); } - } - } - catch - { - // Conservative fallback for security checks. - return true; - } - } - - private static void RemoveEmptyDirectories(string root) - { - if (!Directory.Exists(root)) - { - return; - } - - var directories = Directory.GetDirectories(root, "*", SearchOption.AllDirectories); - Array.Sort(directories, (a, b) => string.CompareOrdinal(b, a)); - foreach (var directory in directories) - { - if (Directory.EnumerateFileSystemEntries(directory).Any()) - { - continue; - } - - Directory.Delete(directory, false); - } - } - - private static string ResolveAndValidateInstallPath(string installDir) - { - if (string.IsNullOrWhiteSpace(installDir)) - { - throw new InvalidOperationException("Install Dir is empty. Please set a valid directory before syncing."); - } - - var trimmed = installDir.Trim(); - if (trimmed.IndexOfAny(Path.GetInvalidPathChars()) >= 0) - { - throw new InvalidOperationException($"Install Dir contains invalid path characters: {installDir}"); - } - - string expandedPath; - try - { - expandedPath = ExpandPath(trimmed); - } - catch (Exception ex) - { - throw new InvalidOperationException($"Install Dir is invalid and cannot be resolved: {installDir}", ex); - } - - if (string.IsNullOrWhiteSpace(expandedPath)) - { - throw new InvalidOperationException("Install Dir resolved to an empty path. Please set a valid directory before syncing."); - } - - return expandedPath; + else + { + _pendingLogs.Enqueue($"[{DateTime.Now:HH:mm:ss}] [ERROR] {result.Error}"); + } + }); } private void TryApplyCliDefaultInstallPath(string previousCli, string currentCli) @@ -830,7 +190,10 @@ private static bool PathsEqual(string left, string right) try { - return string.Equals(ExpandPath(left), ExpandPath(right), StringComparison.Ordinal); + return string.Equals( + SkillSyncService.ExpandPath(left), + SkillSyncService.ExpandPath(right), + StringComparison.Ordinal); } catch { @@ -838,59 +201,6 @@ private static bool PathsEqual(string left, string right) } } - private static string ExpandPath(string path) - { - if (string.IsNullOrWhiteSpace(path)) - { - return string.Empty; - } - - var expanded = path.Trim(); - if (expanded.StartsWith("~", StringComparison.Ordinal)) - { - var userHome = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); - expanded = Path.Combine(userHome, expanded.Substring(1).TrimStart('/', '\\')); - } - - return Path.GetFullPath(expanded); - } - - private void EnqueueMainThreadAction(Action action) - { - if (action == null) - { - return; - } - - _mainThreadActions.Enqueue(action); - } - - private void ExecuteMainThreadActions() - { - while (_mainThreadActions.TryDequeue(out var action)) - { - try - { - action.Invoke(); - } - catch (Exception ex) - { - AppendLineImmediate($"[ERROR] Main-thread action execution failed: {ex.Message}"); - } - } - } - - private void AppendLine(string line) - { - var sanitized = SanitizeLogLine(line); - if (string.IsNullOrWhiteSpace(sanitized)) - { - return; - } - - _pendingLogs.Enqueue($"[{DateTime.Now:HH:mm:ss}] {sanitized}"); - } - private void AppendLineImmediate(string line) { var sanitized = SanitizeLogLine(line); @@ -934,7 +244,6 @@ private static string SanitizeLogLine(string line) { if (inEscape) { - // End ANSI escape sequence on final byte. if (ch >= '@' && ch <= '~') { inEscape = false; @@ -956,133 +265,5 @@ private static string SanitizeLogLine(string line) return sb.ToString().Trim(); } - - private void LogPlanDetails(SyncPlan plan) - { - if (plan.Added.Count == 0 && plan.Updated.Count == 0 && plan.Deleted.Count == 0) - { - AppendLine("No file changes."); - return; - } - - foreach (var path in plan.Added) - { - AppendLine($"+ {path}"); - } - - foreach (var path in plan.Updated) - { - AppendLine($"~ {path}"); - } - - foreach (var path in plan.Deleted) - { - AppendLine($"- {path}"); - } - } - - private void AppendSummary(SyncPlan plan, bool commitChanged) - { - var added = plan.Added.Count; - var updated = plan.Updated.Count; - var deleted = plan.Deleted.Count; - - if (added == 0 && updated == 0 && deleted == 0) - { - AppendLine("Conclusion: No file changes in this run."); - return; - } - - if (added == 0 && updated == 0 && deleted > 0) - { - AppendLine(commitChanged - ? "Conclusion: A new commit was detected, but skill content was unchanged; only local redundant files were cleaned up." - : "Conclusion: Skill content was unchanged; only local redundant files were cleaned up."); - return; - } - - AppendLine($"Conclusion: Skill files were updated (added {added}, modified {updated}, deleted {deleted})."); - } - - private static string ShortCommit(string commit) - { - if (string.IsNullOrWhiteSpace(commit)) - { - return "(none)"; - } - - return commit.Length <= 8 ? commit : commit.Substring(0, 8); - } - - private static string ShortHash(string hash) - { - if (string.IsNullOrWhiteSpace(hash)) - { - return "(none)"; - } - - return hash.Length <= 6 ? hash : hash.Substring(0, 6); - } - - private readonly struct GitHubRepoInfo - { - public GitHubRepoInfo(string owner, string repo) - { - Owner = owner; - Repo = repo; - } - - public string Owner { get; } - public string Repo { get; } - } - - private readonly struct RemoteSnapshot - { - public RemoteSnapshot(string commitSha, string subdirPath, Dictionary files) - { - CommitSha = commitSha; - SubdirPath = subdirPath; - Files = files; - } - - public string CommitSha { get; } - public string SubdirPath { get; } - public Dictionary Files { get; } - } - - [Serializable] - private sealed class GitHubTreeResponse - { - public string sha; - public GitHubTreeEntry[] tree; - public bool truncated; - } - - [Serializable] - private sealed class GitHubBranchResponse - { - public GitHubBranchCommit commit; - } - - [Serializable] - private sealed class GitHubBranchCommit - { - public string sha; - } - - [Serializable] - private sealed class GitHubTreeEntry - { - public string path; - public string type; - public string sha; - } - - private sealed class SyncPlan - { - public List Added { get; } = new(); - public List Updated { get; } = new(); - public List Deleted { get; } = new(); - } } } diff --git a/MCPForUnity/Editor/Setup/SkillSyncService.cs b/MCPForUnity/Editor/Setup/SkillSyncService.cs new file mode 100644 index 000000000..745d05856 --- /dev/null +++ b/MCPForUnity/Editor/Setup/SkillSyncService.cs @@ -0,0 +1,816 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Security.Cryptography; +using System.Text; +using System.Threading.Tasks; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Setup +{ + public static class SkillSyncService + { + private const string DefaultRepoUrl = "https://github.com/CoplayDev/unity-mcp"; + private const string SkillSubdir = ".claude/skills/unity-mcp-skill"; + private const string SyncOwnershipMarker = ".unity-mcp-skill-sync"; + private const string LastSyncedCommitKeyPrefix = "UnityMcpSkillSync.LastSyncedCommit"; + + public sealed class SyncResult + { + public bool Success { get; set; } + public int Added { get; set; } + public int Updated { get; set; } + public int Deleted { get; set; } + public string CommitSha { get; set; } + public string Error { get; set; } + } + + public static void SyncAsync(string installDir, string branch, Action log, Action onComplete) + { + SyncAsync(DefaultRepoUrl, installDir, branch, log, onComplete); + } + + public static void SyncAsync(string repoUrl, string installDir, string branch, Action log, Action onComplete) + { + var lastSyncedCommitKey = GetLastSyncedCommitKey(repoUrl, branch); + var lastSyncedCommit = EditorPrefs.GetString(lastSyncedCommitKey, string.Empty); + + Task.Run(() => + { + try + { + var result = RunSync(repoUrl, installDir, branch, lastSyncedCommit, log); + EditorApplication.delayCall += () => + { + if (result.Success && !string.IsNullOrEmpty(result.CommitSha)) + { + EditorPrefs.SetString(lastSyncedCommitKey, result.CommitSha); + } + onComplete?.Invoke(result); + }; + } + catch (Exception ex) + { + EditorApplication.delayCall += () => + { + onComplete?.Invoke(new SyncResult { Success = false, Error = ex.Message }); + }; + } + }); + } + + private static SyncResult RunSync(string repoUrl, string installDir, string branch, string lastSyncedCommit, Action log) + { + log?.Invoke("=== Sync Start ==="); + + if (!TryParseGitHubRepository(repoUrl, out var repoInfo)) + { + throw new InvalidOperationException($"Repo URL is not a recognized GitHub repository URL: {repoUrl}"); + } + + log?.Invoke($"Target repository: {repoInfo.Owner}/{repoInfo.Repo}@{branch}"); + var snapshot = FetchRemoteSnapshot(repoInfo, branch, SkillSubdir, log); + var installPath = ResolveAndValidateInstallPath(installDir); + + if (!Directory.Exists(installPath)) + { + Directory.CreateDirectory(installPath); + } + + var localFiles = ListFiles(installPath); + var pathComparison = GetPathComparison(installPath); + var pathComparer = GetPathComparer(pathComparison); + EnsureManagedInstallRoot(installPath, localFiles.Keys, snapshot.Files.Keys, pathComparer); + var plan = BuildPlan(snapshot.Files, localFiles, pathComparer); + var commitChanged = !string.Equals(lastSyncedCommit, snapshot.CommitSha, StringComparison.Ordinal); + + log?.Invoke($"Remote Commit: {ShortCommit(lastSyncedCommit)} -> {ShortCommit(snapshot.CommitSha)}"); + log?.Invoke(commitChanged + ? $"Commit: detected newer commit on {branch}." + : $"Commit: no new commit on {branch} since last sync."); + log?.Invoke($"Plan => Added:{plan.Added.Count} Updated:{plan.Updated.Count} Deleted:{plan.Deleted.Count}"); + LogPlanDetails(plan, log); + + ApplyPlan(repoInfo, snapshot.CommitSha, snapshot.SubdirPath, installPath, plan, pathComparison, log); + log?.Invoke("Files mirrored to install directory."); + + ValidateFileHashes(installPath, snapshot.Files, pathComparison, log); + log?.Invoke($"Synced to commit: {snapshot.CommitSha}"); + log?.Invoke("=== Sync Done ==="); + + return new SyncResult + { + Success = true, + Added = plan.Added.Count, + Updated = plan.Updated.Count, + Deleted = plan.Deleted.Count, + CommitSha = snapshot.CommitSha + }; + } + + private static string GetLastSyncedCommitKey(string repoUrl, string branch) + { + var scope = $"{repoUrl}|{branch}|{NormalizeRemotePath(SkillSubdir)}"; + using var sha256 = SHA256.Create(); + var hash = sha256.ComputeHash(Encoding.UTF8.GetBytes(scope)); + var suffix = BitConverter.ToString(hash).Replace("-", string.Empty).ToLowerInvariant(); + return $"{LastSyncedCommitKeyPrefix}.{suffix}"; + } + + internal static bool TryParseGitHubRepository(string url, out GitHubRepoInfo repoInfo) + { + repoInfo = default; + if (string.IsNullOrWhiteSpace(url)) + { + return false; + } + + var trimmed = url.Trim(); + if (trimmed.StartsWith("git@github.com:", StringComparison.OrdinalIgnoreCase)) + { + var repoPath = trimmed.Substring("git@github.com:".Length).Trim('/'); + return TryParseOwnerAndRepo(repoPath, out repoInfo); + } + + if (!Uri.TryCreate(trimmed, UriKind.Absolute, out var uri)) + { + return false; + } + + if (!string.Equals(uri.Host, "github.com", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + var repoPathFromUri = uri.AbsolutePath.Trim('/'); + return TryParseOwnerAndRepo(repoPathFromUri, out repoInfo); + } + + private static bool TryParseOwnerAndRepo(string path, out GitHubRepoInfo repoInfo) + { + repoInfo = default; + var segments = path.Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries); + if (segments.Length < 2) + { + return false; + } + + var owner = segments[0].Trim(); + var repo = segments[1].Trim(); + if (repo.EndsWith(".git", StringComparison.OrdinalIgnoreCase)) + { + repo = repo.Substring(0, repo.Length - 4); + } + + if (string.IsNullOrWhiteSpace(owner) || string.IsNullOrWhiteSpace(repo)) + { + return false; + } + + repoInfo = new GitHubRepoInfo(owner, repo); + return true; + } + + private static RemoteSnapshot FetchRemoteSnapshot(GitHubRepoInfo repoInfo, string branch, string subdir, Action log) + { + using var client = CreateGitHubClient(); + var commitSha = FetchBranchHeadCommitSha(client, repoInfo, branch, log); + var treeApiUrl = BuildTreeApiUrl(repoInfo, commitSha); + log?.Invoke($"Fetching remote directory tree at commit {ShortCommit(commitSha)}..."); + var json = DownloadString(client, treeApiUrl); + var treeResponse = JsonUtility.FromJson(json); + if (treeResponse == null || treeResponse.tree == null) + { + throw new InvalidOperationException("Failed to parse GitHub directory tree response."); + } + + if (treeResponse.truncated) + { + throw new InvalidOperationException( + "GitHub returned a truncated directory tree (incomplete snapshot). " + + "Sync was aborted to prevent accidental deletion of valid local files."); + } + + var normalizedSubdir = NormalizeRemotePath(subdir); + var subdirPrefix = string.IsNullOrEmpty(normalizedSubdir) ? string.Empty : $"{normalizedSubdir}/"; + var remoteFiles = new Dictionary(StringComparer.Ordinal); + + foreach (var entry in treeResponse.tree) + { + if (!string.Equals(entry.type, "blob", StringComparison.Ordinal)) + { + continue; + } + + var remotePath = NormalizeRemotePath(entry.path); + if (string.IsNullOrEmpty(remotePath)) + { + continue; + } + + if (!string.IsNullOrEmpty(subdirPrefix) && + !remotePath.StartsWith(subdirPrefix, StringComparison.Ordinal)) + { + continue; + } + + var relativePath = string.IsNullOrEmpty(subdirPrefix) + ? remotePath + : remotePath.Substring(subdirPrefix.Length); + if (string.IsNullOrWhiteSpace(relativePath) || string.IsNullOrWhiteSpace(entry.sha)) + { + continue; + } + + if (!TryNormalizeRelativePath(relativePath, out var safeRelativePath)) + { + log?.Invoke($"Skip unsafe remote path: {remotePath}"); + continue; + } + + remoteFiles[safeRelativePath] = entry.sha.Trim().ToLowerInvariant(); + } + + if (remoteFiles.Count == 0) + { + throw new InvalidOperationException($"Remote directory not found: {normalizedSubdir}"); + } + + log?.Invoke($"Remote file count: {remoteFiles.Count}"); + return new RemoteSnapshot(commitSha, normalizedSubdir, remoteFiles); + } + + private static string FetchBranchHeadCommitSha(HttpClient client, GitHubRepoInfo repoInfo, string branch, Action log) + { + var branchApiUrl = BuildBranchApiUrl(repoInfo, branch); + log?.Invoke($"Fetching branch head commit..."); + var branchJson = DownloadString(client, branchApiUrl); + var branchResponse = JsonUtility.FromJson(branchJson); + var commitSha = branchResponse?.commit?.sha?.Trim(); + if (string.IsNullOrWhiteSpace(commitSha)) + { + throw new InvalidOperationException($"Failed to resolve branch head commit SHA for: {branch}"); + } + + return commitSha; + } + + private static string BuildBranchApiUrl(GitHubRepoInfo repoInfo, string branch) + { + return $"https://api.github.com/repos/{Uri.EscapeDataString(repoInfo.Owner)}/{Uri.EscapeDataString(repoInfo.Repo)}/branches/{Uri.EscapeDataString(branch)}"; + } + + private static string BuildTreeApiUrl(GitHubRepoInfo repoInfo, string reference) + { + return $"https://api.github.com/repos/{Uri.EscapeDataString(repoInfo.Owner)}/{Uri.EscapeDataString(repoInfo.Repo)}/git/trees/{Uri.EscapeDataString(reference)}?recursive=1"; + } + + private static string BuildRawFileUrl(GitHubRepoInfo repoInfo, string commitSha, string remoteFilePath) + { + var encodedPath = string.Join("/", + NormalizeRemotePath(remoteFilePath) + .Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries) + .Select(Uri.EscapeDataString)); + return $"https://raw.githubusercontent.com/{Uri.EscapeDataString(repoInfo.Owner)}/{Uri.EscapeDataString(repoInfo.Repo)}/{Uri.EscapeDataString(commitSha)}/{encodedPath}"; + } + + internal static HttpClient CreateGitHubClient() + { + var client = new HttpClient + { + Timeout = TimeSpan.FromSeconds(60) + }; + client.DefaultRequestHeaders.UserAgent.ParseAdd("UnityMcpSkillSync/1.0"); + client.DefaultRequestHeaders.Accept.ParseAdd("application/vnd.github+json"); + return client; + } + + internal static string DownloadString(HttpClient client, string url) + { + using var response = client.GetAsync(url).GetAwaiter().GetResult(); + var body = response.Content.ReadAsStringAsync().GetAwaiter().GetResult(); + if (!response.IsSuccessStatusCode) + { + throw new InvalidOperationException($"GitHub request failed: {(int)response.StatusCode} {response.ReasonPhrase} ({url})\n{body}"); + } + + return body; + } + + private static byte[] DownloadBytes(HttpClient client, string url) + { + using var response = client.GetAsync(url).GetAwaiter().GetResult(); + if (!response.IsSuccessStatusCode) + { + var body = response.Content.ReadAsStringAsync().GetAwaiter().GetResult(); + throw new InvalidOperationException($"File download failed: {(int)response.StatusCode} {response.ReasonPhrase} ({url})\n{body}"); + } + + return response.Content.ReadAsByteArrayAsync().GetAwaiter().GetResult(); + } + + internal static string NormalizeRemotePath(string path) + { + if (string.IsNullOrWhiteSpace(path)) + { + return string.Empty; + } + + return path.Replace('\\', '/').Trim().Trim('/'); + } + + private static string CombineRemotePath(string left, string right) + { + var normalizedLeft = NormalizeRemotePath(left); + var normalizedRight = NormalizeRemotePath(right); + if (string.IsNullOrEmpty(normalizedLeft)) + { + return normalizedRight; + } + + if (string.IsNullOrEmpty(normalizedRight)) + { + return normalizedLeft; + } + + return $"{normalizedLeft}/{normalizedRight}"; + } + + internal static bool TryNormalizeRelativePath(string relativePath, out string normalizedPath) + { + normalizedPath = NormalizeRemotePath(relativePath); + if (string.IsNullOrWhiteSpace(normalizedPath) || Path.IsPathRooted(normalizedPath)) + { + return false; + } + + var segments = normalizedPath.Split('/'); + if (segments.Length == 0) + { + return false; + } + + foreach (var segment in segments) + { + if (string.IsNullOrWhiteSpace(segment) || + string.Equals(segment, ".", StringComparison.Ordinal) || + string.Equals(segment, "..", StringComparison.Ordinal) || + segment.IndexOf(':') >= 0) + { + return false; + } + } + + normalizedPath = string.Join("/", segments); + return true; + } + + internal static string ResolvePathUnderRoot(string root, string relativePath, StringComparison pathComparison) + { + if (!TryNormalizeRelativePath(relativePath, out var safeRelativePath)) + { + throw new InvalidOperationException($"Unsafe relative path: {relativePath}"); + } + + var normalizedRoot = EnsureTrailingDirectorySeparator(Path.GetFullPath(root)); + var combinedPath = Path.Combine(normalizedRoot, safeRelativePath.Replace('/', Path.DirectorySeparatorChar)); + var fullPath = Path.GetFullPath(combinedPath); + if (!fullPath.StartsWith(normalizedRoot, pathComparison)) + { + throw new InvalidOperationException($"Path escapes install root: {relativePath}"); + } + + return fullPath; + } + + private static string EnsureTrailingDirectorySeparator(string path) + { + return path.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar; + } + + internal static SyncPlan BuildPlan(Dictionary remoteFiles, Dictionary localFiles, StringComparer pathComparer) + { + var plan = new SyncPlan(); + var localLookup = new Dictionary(pathComparer); + foreach (var localEntry in localFiles) + { + if (!localLookup.ContainsKey(localEntry.Key)) + { + localLookup[localEntry.Key] = localEntry.Value; + } + } + + foreach (var remoteEntry in remoteFiles) + { + if (!localLookup.TryGetValue(remoteEntry.Key, out var localPath)) + { + plan.Added.Add(remoteEntry.Key); + continue; + } + + var localBlobSha = ComputeGitBlobSha1(localPath); + if (!string.Equals(localBlobSha, remoteEntry.Value, StringComparison.Ordinal)) + { + plan.Updated.Add(remoteEntry.Key); + } + } + + var remoteLookup = new HashSet(remoteFiles.Keys, pathComparer); + foreach (var localRelativePath in localFiles.Keys) + { + if (!remoteLookup.Contains(localRelativePath)) + { + plan.Deleted.Add(localRelativePath); + } + } + + plan.Added.Sort(StringComparer.Ordinal); + plan.Updated.Sort(StringComparer.Ordinal); + plan.Deleted.Sort(StringComparer.Ordinal); + return plan; + } + + private static void ApplyPlan(GitHubRepoInfo repoInfo, string commitSha, string remoteSubdir, string targetRoot, SyncPlan plan, StringComparison pathComparison, Action log) + { + using var client = CreateGitHubClient(); + foreach (var relativePath in plan.Added.Concat(plan.Updated)) + { + var remoteFilePath = CombineRemotePath(remoteSubdir, relativePath); + var downloadUrl = BuildRawFileUrl(repoInfo, commitSha, remoteFilePath); + var targetFile = ResolvePathUnderRoot(targetRoot, relativePath, pathComparison); + var targetDirectory = Path.GetDirectoryName(targetFile); + if (!string.IsNullOrEmpty(targetDirectory)) + { + Directory.CreateDirectory(targetDirectory); + } + + log?.Invoke($"Download: {relativePath}"); + var bytes = DownloadBytes(client, downloadUrl); + File.WriteAllBytes(targetFile, bytes); + } + + foreach (var relativePath in plan.Deleted) + { + var targetFile = ResolvePathUnderRoot(targetRoot, relativePath, pathComparison); + if (File.Exists(targetFile)) + { + File.Delete(targetFile); + } + } + + RemoveEmptyDirectories(targetRoot); + } + + private static void ValidateFileHashes(string installRoot, Dictionary remoteFiles, StringComparison pathComparison, Action log) + { + var checkedCount = 0; + foreach (var remoteEntry in remoteFiles) + { + var localPath = ResolvePathUnderRoot(installRoot, remoteEntry.Key, pathComparison); + if (!File.Exists(localPath)) + { + throw new InvalidOperationException($"Missing synced file: {remoteEntry.Key}"); + } + + var localBlobSha = ComputeGitBlobSha1(localPath); + if (!string.Equals(localBlobSha, remoteEntry.Value, StringComparison.Ordinal)) + { + throw new InvalidOperationException($"File hash mismatch: {remoteEntry.Key} ({ShortHash(localBlobSha)} != {ShortHash(remoteEntry.Value)})"); + } + + checkedCount++; + } + + log?.Invoke($"Hash checks passed ({checkedCount}/{remoteFiles.Count})."); + } + + internal static string ComputeGitBlobSha1(string filePath) + { + var bytes = File.ReadAllBytes(filePath); + return ComputeGitBlobSha1(bytes); + } + + internal static string ComputeGitBlobSha1(byte[] bytes) + { + var headerBytes = Encoding.UTF8.GetBytes($"blob {bytes.Length}\0"); + using var sha1 = SHA1.Create(); + sha1.TransformBlock(headerBytes, 0, headerBytes.Length, null, 0); + sha1.TransformFinalBlock(bytes, 0, bytes.Length); + return BitConverter.ToString(sha1.Hash ?? Array.Empty()).Replace("-", string.Empty).ToLowerInvariant(); + } + + internal static Dictionary ListFiles(string root) + { + var map = new Dictionary(StringComparer.Ordinal); + if (!Directory.Exists(root)) + { + return map; + } + + var normalizedRoot = Path.GetFullPath(root); + foreach (var filePath in Directory.GetFiles(normalizedRoot, "*", SearchOption.AllDirectories)) + { + var relativePath = Path.GetRelativePath(normalizedRoot, filePath).Replace('\\', '/'); + if (string.Equals(relativePath, SyncOwnershipMarker, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + map[relativePath] = filePath; + } + + return map; + } + + private static void EnsureManagedInstallRoot( + string installPath, + ICollection localRelativePaths, + ICollection remoteRelativePaths, + StringComparer pathComparer) + { + var markerPath = Path.Combine(installPath, SyncOwnershipMarker); + if (File.Exists(markerPath)) + { + return; + } + + if (localRelativePaths.Count > 0 && !CanAdoptLegacyManagedRoot(localRelativePaths, remoteRelativePaths, pathComparer)) + { + throw new InvalidOperationException( + "Install Dir contains unmanaged files. " + + "Please choose an empty folder or an existing unity-mcp-skill folder."); + } + + File.WriteAllText(markerPath, "managed-by-unity-mcp-skill-sync"); + } + + private static bool CanAdoptLegacyManagedRoot( + ICollection localRelativePaths, + ICollection remoteRelativePaths, + StringComparer pathComparer) + { + if (localRelativePaths.Count == 0) + { + return true; + } + + var remoteTopLevels = new HashSet(pathComparer); + foreach (var remotePath in remoteRelativePaths) + { + var topLevel = GetTopLevelSegment(remotePath); + if (!string.IsNullOrWhiteSpace(topLevel)) + { + remoteTopLevels.Add(topLevel); + } + } + + if (remoteTopLevels.Count == 0) + { + return false; + } + + var hasSkillDefinition = false; + foreach (var localPath in localRelativePaths) + { + if (pathComparer.Equals(localPath, "SKILL.md")) + { + hasSkillDefinition = true; + } + + var topLevel = GetTopLevelSegment(localPath); + if (string.IsNullOrWhiteSpace(topLevel) || !remoteTopLevels.Contains(topLevel)) + { + return false; + } + } + + return hasSkillDefinition; + } + + private static string GetTopLevelSegment(string relativePath) + { + if (string.IsNullOrWhiteSpace(relativePath)) + { + return string.Empty; + } + + var normalized = NormalizeRemotePath(relativePath); + var separatorIndex = normalized.IndexOf('/'); + return separatorIndex < 0 ? normalized : normalized.Substring(0, separatorIndex); + } + + internal static StringComparison GetPathComparison(string root) + { + return IsCaseSensitiveFileSystem(root) ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase; + } + + internal static StringComparer GetPathComparer(StringComparison pathComparison) + { + return pathComparison == StringComparison.Ordinal + ? StringComparer.Ordinal + : StringComparer.OrdinalIgnoreCase; + } + + private static bool IsCaseSensitiveFileSystem(string root) + { + try + { + var probeName = $".mcp-case-probe-{Guid.NewGuid():N}"; + var lowercasePath = Path.Combine(root, probeName.ToLowerInvariant()); + var uppercasePath = Path.Combine(root, probeName.ToUpperInvariant()); + File.WriteAllText(lowercasePath, string.Empty); + try + { + return !File.Exists(uppercasePath); + } + finally + { + if (File.Exists(lowercasePath)) + { + File.Delete(lowercasePath); + } + } + } + catch + { + return true; + } + } + + private static void RemoveEmptyDirectories(string root) + { + if (!Directory.Exists(root)) + { + return; + } + + var directories = Directory.GetDirectories(root, "*", SearchOption.AllDirectories); + Array.Sort(directories, (a, b) => string.CompareOrdinal(b, a)); + foreach (var directory in directories) + { + if (Directory.EnumerateFileSystemEntries(directory).Any()) + { + continue; + } + + Directory.Delete(directory, false); + } + } + + internal static string ResolveAndValidateInstallPath(string installDir) + { + if (string.IsNullOrWhiteSpace(installDir)) + { + throw new InvalidOperationException("Install Dir is empty."); + } + + var trimmed = installDir.Trim(); + if (trimmed.IndexOfAny(Path.GetInvalidPathChars()) >= 0) + { + throw new InvalidOperationException($"Install Dir contains invalid path characters: {installDir}"); + } + + string expandedPath; + try + { + expandedPath = ExpandPath(trimmed); + } + catch (Exception ex) + { + throw new InvalidOperationException($"Install Dir is invalid and cannot be resolved: {installDir}", ex); + } + + if (string.IsNullOrWhiteSpace(expandedPath)) + { + throw new InvalidOperationException("Install Dir resolved to an empty path."); + } + + return expandedPath; + } + + internal static string ExpandPath(string path) + { + if (string.IsNullOrWhiteSpace(path)) + { + return string.Empty; + } + + var expanded = path.Trim(); + if (expanded.StartsWith("~", StringComparison.Ordinal)) + { + var userHome = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + expanded = Path.Combine(userHome, expanded.Substring(1).TrimStart('/', '\\')); + } + + return Path.GetFullPath(expanded); + } + + private static string ShortCommit(string commit) + { + if (string.IsNullOrWhiteSpace(commit)) + { + return "(none)"; + } + + return commit.Length <= 8 ? commit : commit.Substring(0, 8); + } + + private static string ShortHash(string hash) + { + if (string.IsNullOrWhiteSpace(hash)) + { + return "(none)"; + } + + return hash.Length <= 6 ? hash : hash.Substring(0, 6); + } + + private static void LogPlanDetails(SyncPlan plan, Action log) + { + if (plan.Added.Count == 0 && plan.Updated.Count == 0 && plan.Deleted.Count == 0) + { + log?.Invoke("No file changes."); + return; + } + + foreach (var path in plan.Added) + { + log?.Invoke($"+ {path}"); + } + + foreach (var path in plan.Updated) + { + log?.Invoke($"~ {path}"); + } + + foreach (var path in plan.Deleted) + { + log?.Invoke($"- {path}"); + } + } + + internal readonly struct GitHubRepoInfo + { + public GitHubRepoInfo(string owner, string repo) + { + Owner = owner; + Repo = repo; + } + + public string Owner { get; } + public string Repo { get; } + } + + internal readonly struct RemoteSnapshot + { + public RemoteSnapshot(string commitSha, string subdirPath, Dictionary files) + { + CommitSha = commitSha; + SubdirPath = subdirPath; + Files = files; + } + + public string CommitSha { get; } + public string SubdirPath { get; } + public Dictionary Files { get; } + } + + [Serializable] + internal sealed class GitHubTreeResponse + { + public string sha; + public GitHubTreeEntry[] tree; + public bool truncated; + } + + [Serializable] + internal sealed class GitHubBranchResponse + { + public GitHubBranchCommit commit; + } + + [Serializable] + internal sealed class GitHubBranchCommit + { + public string sha; + } + + [Serializable] + internal sealed class GitHubTreeEntry + { + public string path; + public string type; + public string sha; + } + + internal sealed class SyncPlan + { + public List Added { get; } = new(); + public List Updated { get; } = new(); + public List Deleted { get; } = new(); + } + } +} diff --git a/MCPForUnity/Editor/Setup/SkillSyncService.cs.meta b/MCPForUnity/Editor/Setup/SkillSyncService.cs.meta new file mode 100644 index 000000000..b48924c32 --- /dev/null +++ b/MCPForUnity/Editor/Setup/SkillSyncService.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a3f7b2c1d4e5f6a7b8c9d0e1f2a3b4c5 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Windows/Components/ClientConfig/McpClientConfigSection.cs b/MCPForUnity/Editor/Windows/Components/ClientConfig/McpClientConfigSection.cs index 31069022e..df2aa1781 100644 --- a/MCPForUnity/Editor/Windows/Components/ClientConfig/McpClientConfigSection.cs +++ b/MCPForUnity/Editor/Windows/Components/ClientConfig/McpClientConfigSection.cs @@ -10,6 +10,7 @@ using MCPForUnity.Editor.Helpers; using MCPForUnity.Editor.Models; using MCPForUnity.Editor.Services; +using MCPForUnity.Editor.Setup; using UnityEditor; using UnityEngine; using UnityEngine.UIElements; @@ -28,6 +29,7 @@ public class McpClientConfigSection private VisualElement clientStatusIndicator; private Label clientStatusLabel; private Button configureButton; + private Button installSkillsButton; private VisualElement claudeCliPathRow; private TextField claudeCliPath; private Button browseClaudeButton; @@ -45,6 +47,7 @@ public class McpClientConfigSection private readonly HashSet statusRefreshInFlight = new(); private static readonly TimeSpan StatusRefreshInterval = TimeSpan.FromSeconds(45); private int selectedClientIndex = 0; + private bool isSkillSyncInProgress; // Events /// @@ -77,6 +80,7 @@ private void CacheUIElements() clientStatusIndicator = Root.Q("client-status-indicator"); clientStatusLabel = Root.Q