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 new file mode 100644 index 000000000..ade13f40a --- /dev/null +++ b/MCPForUnity/Editor/Setup/McpForUnitySkillInstaller.cs @@ -0,0 +1,269 @@ +using System; +using System.Collections.Concurrent; +using System.IO; +using System.Linq; +using System.Text; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Setup +{ + 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 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 _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); + using (new EditorGUI.DisabledScope(_isRunning)) + { + _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() + { + var changed = FlushPendingLogs(); + if (_isRunning || changed) + { + Repaint(); + } + } + + private void RunSyncLatest() + { + if (_isRunning) + { + return; + } + + _isRunning = true; + SkillSyncService.SyncAsync(_repoUrl, _installDir, _targetBranch, + line => _pendingLogs.Enqueue($"[{DateTime.Now:HH:mm:ss}] {SanitizeLogLine(line)}"), + result => + { + _isRunning = false; + if (result.Success) + { + _pendingLogs.Enqueue($"[{DateTime.Now:HH:mm:ss}] Sync complete: +{result.Added} ~{result.Updated} -{result.Deleted}"); + } + else + { + _pendingLogs.Enqueue($"[{DateTime.Now:HH:mm:ss}] [ERROR] {result.Error}"); + } + }); + } + + 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( + SkillSyncService.ExpandPath(left), + SkillSyncService.ExpandPath(right), + StringComparison.Ordinal); + } + catch + { + return false; + } + } + + 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) + { + 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(); + } + } +} diff --git a/MCPForUnity/Editor/Setup/McpForUnitySkillInstaller.cs.meta b/MCPForUnity/Editor/Setup/McpForUnitySkillInstaller.cs.meta new file mode 100644 index 000000000..575ecb0f9 --- /dev/null +++ b/MCPForUnity/Editor/Setup/McpForUnitySkillInstaller.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7886932de812549f195fa4f6006ef0dd +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: 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