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

975 lines
37 KiB
C#

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using UnityEngine;
using Debug = UnityEngine.Debug;
namespace io.github.hatayama.uLoopMCP
{
public enum SkillInstallState
{
Missing,
Checking,
Installed,
Outdated
}
/// <summary>
/// Synchronizes skill files when tools are enabled/disabled.
/// Removes skill directories on disable, re-installs on enable.
/// </summary>
public static class ToolSkillSynchronizer
{
public readonly struct SkillTargetDefinition
{
public readonly string DirName;
public readonly string Flag;
public readonly string DisplayName;
public SkillTargetDefinition(string dirName, string flag, string displayName)
{
DirName = dirName;
Flag = flag;
DisplayName = displayName;
}
}
public readonly struct SkillInstallResult
{
public readonly int AttemptedTargets;
public readonly int SucceededTargets;
public SkillInstallResult(int attemptedTargets, int succeededTargets)
{
AttemptedTargets = attemptedTargets;
SucceededTargets = succeededTargets;
}
public int FailedTargets => AttemptedTargets - SucceededTargets;
public bool IsSuccessful => FailedTargets == 0;
}
public readonly struct SkillTargetInfo
{
public readonly string DisplayName;
public readonly string DirName;
public readonly string InstallFlag;
public readonly bool HasSkillsDirectory;
public readonly bool HasExistingSkills;
public readonly bool HasDifferentLayoutSkills;
public readonly SkillInstallState InstallState;
public SkillTargetInfo(
string displayName,
string dirName,
string installFlag,
bool hasSkillsDirectory,
bool hasExistingSkills,
bool hasDifferentLayoutSkills = false,
SkillInstallState installState = SkillInstallState.Missing)
{
DisplayName = displayName;
DirName = dirName;
InstallFlag = installFlag;
HasSkillsDirectory = hasSkillsDirectory;
HasExistingSkills = hasExistingSkills;
HasDifferentLayoutSkills = hasDifferentLayoutSkills;
InstallState = installState;
}
}
private static readonly SkillTargetDefinition[] SkillTargets =
{
new(".claude", "--claude", "Claude Code"),
new(".cursor", "--cursor", "Cursor"),
new(".gemini", "--gemini", "Gemini CLI"),
new(".codex", "--codex", "Codex CLI"),
new(".agents", "--agents", "Other (.agents)"),
new(".agent", "--antigravity", "Antigravity")
};
private static readonly string[] DeprecatedSkillNames =
{
"uloop-capture-window",
"uloop-get-provider-details",
"uloop-unity-search",
"uloop-get-menu-items",
"uloop-get-unity-search-providers",
"uloop-execute-menu-item"
};
private static readonly string[] ExcludedSkillFileNames =
{
".meta",
".DS_Store",
".gitkeep"
};
internal static readonly string[] SkillTargetDirs = SkillTargets.Select(t => t.DirName).ToArray();
public static void RemoveSkillFiles(string toolName)
{
Debug.Assert(!string.IsNullOrEmpty(toolName), "toolName must not be null or empty");
string projectRoot = UnityMcpPathResolver.GetProjectRoot();
RemoveSkillFilesAtProjectRoot(projectRoot, toolName);
}
internal static void RemoveSkillFilesAtProjectRoot(string projectRoot, string toolName)
{
Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty");
Debug.Assert(!string.IsNullOrEmpty(toolName), "toolName must not be null or empty");
foreach (string targetDir in SkillTargetDirs)
{
string targetRoot = Path.Combine(projectRoot, targetDir);
if (!Directory.Exists(targetRoot))
{
continue;
}
foreach (string skillDir in SkillInstallLayout.EnumerateInstalledSkillDirectories(targetRoot))
{
if (SkillInstallLayout.SkillMatchesTool(skillDir, toolName))
{
Debug.Log($"[uLoopMCP] Removing skill '{toolName}' from '{skillDir}'");
Directory.Delete(skillDir, true);
}
}
}
}
public static bool IsSkillInstalled(string toolName)
{
Debug.Assert(!string.IsNullOrEmpty(toolName), "toolName must not be null or empty");
string projectRoot = UnityMcpPathResolver.GetProjectRoot();
foreach (string targetDir in SkillTargetDirs)
{
string targetRoot = Path.Combine(projectRoot, targetDir);
if (!Directory.Exists(targetRoot))
{
continue;
}
foreach (string skillDir in SkillInstallLayout.EnumerateInstalledSkillDirectories(targetRoot))
{
if (SkillInstallLayout.SkillMatchesTool(skillDir, toolName))
{
return true;
}
}
}
return false;
}
public static List<SkillTargetInfo> DetectTargets()
{
return DetectTargets(requireSkillsDirectory: false);
}
public static List<SkillTargetInfo> DetectTargetsForLayout(bool groupSkillsUnderUnityCliLoop)
{
string projectRoot = UnityMcpPathResolver.GetProjectRoot();
Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty");
return DetectTargetsForLayoutAtProjectRoot(projectRoot, groupSkillsUnderUnityCliLoop);
}
public static List<SkillTargetInfo> DetectTargetsForLayoutFast(bool groupSkillsUnderUnityCliLoop)
{
string projectRoot = UnityMcpPathResolver.GetProjectRoot();
Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty");
return DetectTargetsForLayoutFastAtProjectRoot(projectRoot, groupSkillsUnderUnityCliLoop);
}
internal static List<SkillTargetInfo> DetectTargetsForLayoutAtProjectRoot(
string projectRoot,
bool groupSkillsUnderUnityCliLoop)
{
Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty");
return DetectTargets(
projectRoot,
requireSkillsDirectory: false,
groupSkillsUnderUnityCliLoop,
includeFreshnessCheck: true);
}
internal static List<SkillTargetInfo> DetectTargetsForLayoutFastAtProjectRoot(
string projectRoot,
bool groupSkillsUnderUnityCliLoop)
{
Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty");
return DetectTargets(
projectRoot,
requireSkillsDirectory: false,
groupSkillsUnderUnityCliLoop,
includeFreshnessCheck: false);
}
internal static List<SkillTargetInfo> DetectTargets(bool requireSkillsDirectory)
{
string projectRoot = UnityMcpPathResolver.GetProjectRoot();
Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty");
return DetectTargets(projectRoot, requireSkillsDirectory);
}
internal static List<SkillTargetInfo> DetectTargets(
bool requireSkillsDirectory,
bool groupSkillsUnderUnityCliLoop)
{
string projectRoot = UnityMcpPathResolver.GetProjectRoot();
Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty");
return DetectTargets(
projectRoot,
requireSkillsDirectory,
groupSkillsUnderUnityCliLoop,
includeFreshnessCheck: true);
}
internal static List<SkillTargetInfo> DetectTargets(
bool requireSkillsDirectory,
bool groupSkillsUnderUnityCliLoop,
bool includeFreshnessCheck)
{
string projectRoot = UnityMcpPathResolver.GetProjectRoot();
Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty");
return DetectTargets(
projectRoot,
requireSkillsDirectory,
groupSkillsUnderUnityCliLoop,
includeFreshnessCheck);
}
internal static List<SkillTargetInfo> DetectTargets(string projectRoot, bool requireSkillsDirectory)
{
Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty");
List<SkillTargetInfo> targets = new();
foreach (SkillTargetDefinition target in SkillTargets)
{
string targetRoot = Path.Combine(projectRoot, target.DirName);
if (!Directory.Exists(targetRoot))
{
continue;
}
bool hasSkillsDirectory = SkillInstallLayout.HasOptedInSkillsDirectory(targetRoot);
if (requireSkillsDirectory && !hasSkillsDirectory)
{
continue;
}
bool hasULoopSkills = hasSkillsDirectory
&& SkillInstallLayout.HasInstalledSkills(projectRoot, targetRoot);
targets.Add(new SkillTargetInfo(
target.DisplayName,
target.DirName,
target.Flag,
hasSkillsDirectory,
hasULoopSkills,
installState: hasULoopSkills
? SkillInstallState.Installed
: SkillInstallState.Missing));
}
return targets;
}
internal static List<SkillTargetInfo> DetectTargets(
string projectRoot,
bool requireSkillsDirectory,
bool groupSkillsUnderUnityCliLoop)
{
return DetectTargets(
projectRoot,
requireSkillsDirectory,
groupSkillsUnderUnityCliLoop,
includeFreshnessCheck: true);
}
internal static List<SkillTargetInfo> DetectTargets(
string projectRoot,
bool requireSkillsDirectory,
bool groupSkillsUnderUnityCliLoop,
bool includeFreshnessCheck)
{
Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty");
List<SkillTargetInfo> targets = new();
foreach (SkillTargetDefinition target in SkillTargets)
{
string targetRoot = Path.Combine(projectRoot, target.DirName);
if (!Directory.Exists(targetRoot))
{
continue;
}
bool hasSkillsDirectory = SkillInstallLayout.HasOptedInSkillsDirectory(targetRoot);
if (requireSkillsDirectory && !hasSkillsDirectory)
{
continue;
}
SkillInstallState installState = ResolveInstallState(
projectRoot,
targetRoot,
hasSkillsDirectory,
groupSkillsUnderUnityCliLoop,
includeFreshnessCheck);
bool hasULoopSkills = installState == SkillInstallState.Installed
|| installState == SkillInstallState.Checking
|| installState == SkillInstallState.Outdated;
bool hasDifferentLayoutSkills = hasSkillsDirectory
&& SkillInstallLayout.HasInstalledSkills(projectRoot, targetRoot, !groupSkillsUnderUnityCliLoop);
targets.Add(new SkillTargetInfo(
target.DisplayName,
target.DirName,
target.Flag,
hasSkillsDirectory,
hasULoopSkills,
hasDifferentLayoutSkills,
installState));
}
return targets;
}
private static SkillInstallState ResolveInstallState(
string projectRoot,
string targetRoot,
bool hasSkillsDirectory,
bool groupSkillsUnderUnityCliLoop,
bool includeFreshnessCheck)
{
if (!hasSkillsDirectory)
{
return SkillInstallState.Missing;
}
if (!includeFreshnessCheck)
{
return SkillInstallLayout.HasInstalledSkills(projectRoot, targetRoot, groupSkillsUnderUnityCliLoop)
? SkillInstallState.Checking
: SkillInstallState.Missing;
}
return SkillInstallLayout.GetInstalledState(projectRoot, targetRoot, groupSkillsUnderUnityCliLoop);
}
/// <summary>
/// Re-installs skills only for targets that already opted in via an existing skills directory.
/// </summary>
public static async Task<SkillInstallResult> InstallSkillFiles()
{
return await InstallSkillFiles(groupSkillsUnderUnityCliLoop: false);
}
public static async Task<SkillInstallResult> InstallSkillFiles(bool groupSkillsUnderUnityCliLoop)
{
List<SkillTargetInfo> targets = DetectTargets(requireSkillsDirectory: true);
return await InstallSkillFiles(targets, groupSkillsUnderUnityCliLoop);
}
public static async Task<SkillInstallResult> InstallSkillFilesForTool(
string toolName,
bool groupSkillsUnderUnityCliLoop)
{
Debug.Assert(!string.IsNullOrEmpty(toolName), "toolName must not be null or empty");
string projectRoot = UnityMcpPathResolver.GetProjectRoot();
Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty");
return await InstallSkillFilesForToolAtProjectRoot(
projectRoot,
toolName,
groupSkillsUnderUnityCliLoop);
}
public static async Task<SkillInstallResult> InstallSkillFiles(List<SkillTargetInfo> targets)
{
return await InstallSkillFiles(targets, groupSkillsUnderUnityCliLoop: false);
}
public static async Task<SkillInstallResult> InstallSkillFiles(
List<SkillTargetInfo> targets,
bool groupSkillsUnderUnityCliLoop)
{
Debug.Assert(targets != null, "targets must not be null");
string projectRoot = UnityMcpPathResolver.GetProjectRoot();
Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty");
return await InstallSkillFilesAtProjectRoot(projectRoot, targets, groupSkillsUnderUnityCliLoop);
}
internal static async Task<SkillInstallResult> InstallSkillFilesAtProjectRoot(
string projectRoot,
IEnumerable<SkillTargetInfo> targets,
bool groupSkillsUnderUnityCliLoop)
{
Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty");
Debug.Assert(targets != null, "targets must not be null");
SkillTargetInfo[] targetArray = targets.ToArray();
return await Task.Run(() =>
{
string[] disabledTools = ToolSettings.GetDisabledTools();
List<SkillInstallLayout.SkillSourceInfo> allSkills = SkillInstallLayout.GetSkillSourceInfos(projectRoot);
List<SkillInstallLayout.SkillSourceInfo> disabledSkills = allSkills
.Where(skill => IsSkillDisabledByToolSettings(
skill,
disabledTools,
ToolExecutionAvailability.IsTestFrameworkAvailable))
.ToList();
List<SkillInstallLayout.SkillSourceInfo> enabledSkills = allSkills
.Except(disabledSkills)
.ToList();
int succeeded = 0;
foreach (SkillTargetInfo target in targetArray)
{
InstallSkillsForTarget(
projectRoot,
target,
disabledSkills,
enabledSkills,
groupSkillsUnderUnityCliLoop);
succeeded++;
}
return new SkillInstallResult(targetArray.Length, succeeded);
});
}
internal static async Task<SkillInstallResult> InstallSkillFilesForToolAtProjectRoot(
string projectRoot,
string toolName,
bool groupSkillsUnderUnityCliLoop)
{
Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty");
Debug.Assert(!string.IsNullOrEmpty(toolName), "toolName must not be null or empty");
SkillTargetInfo[] targetArray = DetectTargetsWithSkillsDirectory(projectRoot).ToArray();
return await Task.Run(() =>
{
string[] disabledTools = ToolSettings.GetDisabledTools();
List<SkillInstallLayout.SkillSourceInfo> allSkills = SkillInstallLayout.GetSkillSourceInfos(projectRoot);
List<SkillInstallLayout.SkillSourceInfo> disabledSkills = allSkills
.Where(skill => IsSkillDisabledByToolSettings(
skill,
disabledTools,
ToolExecutionAvailability.IsTestFrameworkAvailable))
.ToList();
List<SkillInstallLayout.SkillSourceInfo> toolSkills = allSkills
.Where(skill => IsSkillForTool(skill, toolName))
.ToList();
if (toolSkills.Count == 0)
{
return new SkillInstallResult(0, 0);
}
int succeeded = 0;
foreach (SkillTargetInfo target in targetArray)
{
InstallSpecificSkillsForTarget(
projectRoot,
target,
disabledSkills,
toolSkills,
groupSkillsUnderUnityCliLoop);
succeeded++;
}
return new SkillInstallResult(targetArray.Length, succeeded);
});
}
private static void InstallSkillsForTarget(
string projectRoot,
SkillTargetInfo target,
IReadOnlyCollection<SkillInstallLayout.SkillSourceInfo> disabledSkills,
IReadOnlyCollection<SkillInstallLayout.SkillSourceInfo> enabledSkills,
bool groupSkillsUnderUnityCliLoop)
{
string targetRoot = Path.Combine(projectRoot, target.DirName);
string skillsRoot = SkillInstallLayout.GetSkillsRoot(targetRoot);
HashSet<string> managedSkillNames = new(
enabledSkills.Select(skill => skill.Name),
StringComparer.Ordinal);
managedSkillNames.UnionWith(disabledSkills.Select(skill => skill.Name));
managedSkillNames.UnionWith(DeprecatedSkillNames);
Directory.CreateDirectory(skillsRoot);
if (groupSkillsUnderUnityCliLoop)
{
Directory.CreateDirectory(SkillInstallLayout.GetManagedSkillsRoot(targetRoot));
}
DeleteDeprecatedSkillDirectoriesFromAllLayouts(targetRoot);
DeleteDisabledSkillDirectoriesFromAllLayouts(targetRoot, disabledSkills);
foreach (SkillInstallLayout.SkillSourceInfo skill in enabledSkills)
{
string installedSkillDirectory = SkillInstallLayout.GetInstalledSkillDirectoryPathForLayout(
targetRoot,
skill.Name,
groupSkillsUnderUnityCliLoop);
SyncInstalledSkillDirectory(installedSkillDirectory, skill.SkillFiles);
DeleteSkillDirectoryIfExists(targetRoot, skill.Name, !groupSkillsUnderUnityCliLoop);
}
DeleteUnexpectedInstalledSkillDirectories(
targetRoot,
enabledSkills.Select(skill => skill.Name),
managedSkillNames,
groupSkillsUnderUnityCliLoop);
if (!groupSkillsUnderUnityCliLoop)
{
DeleteUnexpectedInstalledSkillDirectories(
targetRoot,
enabledSkills.Select(skill => skill.Name),
managedSkillNames,
groupSkillsUnderUnityCliLoop: true);
DeleteEmptyManagedSkillsParentDirectoryIfNeeded(
targetRoot,
groupSkillsUnderUnityCliLoop: true);
}
}
private static void InstallSpecificSkillsForTarget(
string projectRoot,
SkillTargetInfo target,
IReadOnlyCollection<SkillInstallLayout.SkillSourceInfo> disabledSkills,
IReadOnlyCollection<SkillInstallLayout.SkillSourceInfo> skills,
bool groupSkillsUnderUnityCliLoop)
{
string targetRoot = Path.Combine(projectRoot, target.DirName);
string skillsRoot = SkillInstallLayout.GetSkillsRoot(targetRoot);
Directory.CreateDirectory(skillsRoot);
if (groupSkillsUnderUnityCliLoop)
{
Directory.CreateDirectory(SkillInstallLayout.GetManagedSkillsRoot(targetRoot));
}
DeleteDeprecatedSkillDirectoriesForLayout(targetRoot, groupSkillsUnderUnityCliLoop);
DeleteDisabledSkillDirectoriesForLayout(targetRoot, disabledSkills, groupSkillsUnderUnityCliLoop);
foreach (SkillInstallLayout.SkillSourceInfo skill in skills)
{
string installedSkillDirectory = SkillInstallLayout.GetInstalledSkillDirectoryPathForLayout(
targetRoot,
skill.Name,
groupSkillsUnderUnityCliLoop);
SyncInstalledSkillDirectory(installedSkillDirectory, skill.SkillFiles);
DeleteSkillDirectoryIfExists(targetRoot, skill.Name, !groupSkillsUnderUnityCliLoop);
}
if (!groupSkillsUnderUnityCliLoop)
{
DeleteEmptyManagedSkillsParentDirectoryIfNeeded(
targetRoot,
groupSkillsUnderUnityCliLoop: true);
}
}
internal static bool IsSkillDisabledByToolSettings(
SkillInstallLayout.SkillSourceInfo skill,
IReadOnlyCollection<string> disabledTools,
bool isTestFrameworkAvailable)
{
if (disabledTools.Count == 0)
{
return false;
}
string toolName = GetToolNameForSkill(skill);
if (string.IsNullOrEmpty(toolName))
{
return false;
}
return disabledTools.Contains(toolName)
&& !ToolExecutionAvailability.ShouldReportDependencyUnavailableBeforeDisabled(
toolName,
isTestFrameworkAvailable);
}
private static bool IsSkillForTool(
SkillInstallLayout.SkillSourceInfo skill,
string toolName)
{
string skillToolName = GetToolNameForSkill(skill);
return string.Equals(skillToolName, toolName, StringComparison.Ordinal);
}
private static string GetToolNameForSkill(SkillInstallLayout.SkillSourceInfo skill)
{
string toolName = skill.ToolName;
if (string.IsNullOrEmpty(toolName) && skill.Name.StartsWith(CliConstants.SKILL_DIR_PREFIX, StringComparison.Ordinal))
{
toolName = skill.Name.Substring(CliConstants.SKILL_DIR_PREFIX.Length);
}
return toolName;
}
private static List<SkillTargetInfo> DetectTargetsWithSkillsDirectory(string projectRoot)
{
List<SkillTargetInfo> targets = new();
foreach (SkillTargetDefinition target in SkillTargets)
{
string targetRoot = Path.Combine(projectRoot, target.DirName);
if (!Directory.Exists(targetRoot))
{
continue;
}
bool hasSkillsDirectory = SkillInstallLayout.HasOptedInSkillsDirectory(targetRoot);
if (!hasSkillsDirectory)
{
continue;
}
targets.Add(new SkillTargetInfo(
target.DisplayName,
target.DirName,
target.Flag,
hasSkillsDirectory,
hasExistingSkills: false));
}
return targets;
}
private static void DeleteUnexpectedInstalledSkillDirectories(
string targetRoot,
IEnumerable<string> expectedSkillNames,
IEnumerable<string> removableSkillNames,
bool groupSkillsUnderUnityCliLoop)
{
HashSet<string> expectedSkillNameSet = new(expectedSkillNames, StringComparer.Ordinal);
HashSet<string> removableSkillNameSet = new(removableSkillNames, StringComparer.Ordinal);
foreach (string installedSkillName in SkillInstallLayout.EnumerateInstalledSkillDirectoryNamesForLayout(
targetRoot,
groupSkillsUnderUnityCliLoop))
{
if (expectedSkillNameSet.Contains(installedSkillName))
{
continue;
}
if (!removableSkillNameSet.Contains(installedSkillName))
{
continue;
}
DeleteSkillDirectoryIfExists(targetRoot, installedSkillName, groupSkillsUnderUnityCliLoop);
}
}
private static void SyncInstalledSkillDirectory(
string skillDirectory,
IReadOnlyDictionary<string, byte[]> skillFiles)
{
Debug.Assert(!string.IsNullOrEmpty(skillDirectory), "skillDirectory must not be null or empty");
Debug.Assert(skillFiles != null, "skillFiles must not be null");
Debug.Assert(skillFiles.ContainsKey(SkillInstallLayout.SkillFileName),
"skillFiles must contain SKILL.md");
string parentDirectory = Path.GetDirectoryName(skillDirectory);
Debug.Assert(!string.IsNullOrEmpty(parentDirectory), "parentDirectory must not be null or empty");
Directory.CreateDirectory(parentDirectory);
bool skillDirectoryExisted = Directory.Exists(skillDirectory);
Dictionary<string, byte[]> backupFiles = skillDirectoryExisted
? ReadSkillFiles(skillDirectory)
: new Dictionary<string, byte[]>(StringComparer.Ordinal);
Directory.CreateDirectory(skillDirectory);
bool syncCompleted = false;
try
{
WriteSkillFiles(skillDirectory, skillFiles);
DeleteUnexpectedSkillFiles(skillDirectory, skillFiles.Keys);
DeleteEmptyDirectories(skillDirectory);
syncCompleted = true;
}
finally
{
if (!syncCompleted)
{
RollbackSkillDirectory(skillDirectory, backupFiles, skillDirectoryExisted);
}
}
}
private static void DeleteDeprecatedSkillDirectoriesFromAllLayouts(string targetRoot)
{
foreach (string deprecatedSkillName in DeprecatedSkillNames)
{
DeleteSkillDirectoryIfExists(targetRoot, deprecatedSkillName, groupSkillsUnderUnityCliLoop: true);
DeleteSkillDirectoryIfExists(targetRoot, deprecatedSkillName, groupSkillsUnderUnityCliLoop: false);
}
}
private static void DeleteDisabledSkillDirectoriesFromAllLayouts(
string targetRoot,
IReadOnlyCollection<SkillInstallLayout.SkillSourceInfo> disabledSkills)
{
foreach (SkillInstallLayout.SkillSourceInfo skill in disabledSkills)
{
DeleteSkillDirectoryIfExists(targetRoot, skill.Name, groupSkillsUnderUnityCliLoop: true);
DeleteSkillDirectoryIfExists(targetRoot, skill.Name, groupSkillsUnderUnityCliLoop: false);
}
}
private static void DeleteDeprecatedSkillDirectoriesForLayout(
string targetRoot,
bool groupSkillsUnderUnityCliLoop)
{
foreach (string deprecatedSkillName in DeprecatedSkillNames)
{
DeleteSkillDirectoryIfExists(targetRoot, deprecatedSkillName, groupSkillsUnderUnityCliLoop);
}
}
private static void DeleteDisabledSkillDirectoriesForLayout(
string targetRoot,
IReadOnlyCollection<SkillInstallLayout.SkillSourceInfo> disabledSkills,
bool groupSkillsUnderUnityCliLoop)
{
foreach (SkillInstallLayout.SkillSourceInfo skill in disabledSkills)
{
DeleteSkillDirectoryIfExists(targetRoot, skill.Name, groupSkillsUnderUnityCliLoop);
}
}
private static void WriteSkillFiles(
string skillDirectory,
IReadOnlyDictionary<string, byte[]> skillFiles)
{
foreach (KeyValuePair<string, byte[]> skillFile in skillFiles)
{
string fullPath = Path.Combine(skillDirectory, skillFile.Key);
string fileDirectory = Path.GetDirectoryName(fullPath);
Debug.Assert(!string.IsNullOrEmpty(fileDirectory), "fileDirectory must not be null or empty");
Directory.CreateDirectory(fileDirectory);
if (File.Exists(fullPath) && File.ReadAllBytes(fullPath).SequenceEqual(skillFile.Value))
{
continue;
}
WriteFileAtomically(fullPath, skillFile.Value);
}
}
private static Dictionary<string, byte[]> ReadSkillFiles(string skillDirectory)
{
Dictionary<string, byte[]> files = new(StringComparer.Ordinal);
foreach (string filePath in Directory.EnumerateFiles(skillDirectory, "*", SearchOption.AllDirectories))
{
string fileName = Path.GetFileName(filePath);
if (IsExcludedSkillFile(fileName))
{
continue;
}
string relativePath = Path.GetRelativePath(skillDirectory, filePath);
files[relativePath] = File.ReadAllBytes(filePath);
}
return files;
}
private static void WriteFileAtomically(string fullPath, byte[] content)
{
string fileDirectory = Path.GetDirectoryName(fullPath);
Debug.Assert(!string.IsNullOrEmpty(fileDirectory), "fileDirectory must not be null or empty");
string tempPath = Path.Combine(
fileDirectory,
$"{Path.GetFileName(fullPath)}.tmp-{Guid.NewGuid():N}");
File.WriteAllBytes(tempPath, content);
try
{
if (File.Exists(fullPath))
{
File.Replace(tempPath, fullPath, null, true);
return;
}
File.Move(tempPath, fullPath);
}
finally
{
if (File.Exists(tempPath))
{
File.Delete(tempPath);
}
}
}
private static void DeleteUnexpectedSkillFiles(
string skillDirectory,
IEnumerable<string> expectedRelativePaths)
{
HashSet<string> expectedPaths = new(expectedRelativePaths, StringComparer.Ordinal);
foreach (string filePath in Directory.EnumerateFiles(skillDirectory, "*", SearchOption.AllDirectories))
{
string fileName = Path.GetFileName(filePath);
if (IsExcludedSkillFile(fileName))
{
continue;
}
string relativePath = Path.GetRelativePath(skillDirectory, filePath);
if (expectedPaths.Contains(relativePath))
{
continue;
}
File.Delete(filePath);
}
}
private static void DeleteEmptyDirectories(string skillDirectory)
{
foreach (string directoryPath in Directory.EnumerateDirectories(skillDirectory, "*", SearchOption.AllDirectories)
.OrderByDescending(path => path.Length))
{
if (Directory.EnumerateFileSystemEntries(directoryPath).Any())
{
continue;
}
Directory.Delete(directoryPath);
}
}
private static void RollbackSkillDirectory(
string skillDirectory,
IReadOnlyDictionary<string, byte[]> backupFiles,
bool skillDirectoryExisted)
{
if (!skillDirectoryExisted)
{
if (Directory.Exists(skillDirectory))
{
Directory.Delete(skillDirectory, true);
}
return;
}
Directory.CreateDirectory(skillDirectory);
WriteSkillFiles(skillDirectory, backupFiles);
DeleteUnexpectedSkillFiles(skillDirectory, backupFiles.Keys);
DeleteEmptyDirectories(skillDirectory);
}
private static bool IsExcludedSkillFile(string fileName)
{
if (ExcludedSkillFileNames.Contains(fileName))
{
return true;
}
foreach (string excludedPattern in ExcludedSkillFileNames)
{
if (fileName.EndsWith(excludedPattern, StringComparison.Ordinal))
{
return true;
}
}
return false;
}
private static void DeleteSkillDirectoryIfExists(
string targetRoot,
string skillName,
bool groupSkillsUnderUnityCliLoop)
{
string installedSkillDirectory = SkillInstallLayout.GetInstalledSkillDirectoryPathForLayout(
targetRoot,
skillName,
groupSkillsUnderUnityCliLoop);
if (!Directory.Exists(installedSkillDirectory))
{
return;
}
Directory.Delete(installedSkillDirectory, true);
DeleteEmptyManagedSkillsParentDirectoryIfNeeded(targetRoot, groupSkillsUnderUnityCliLoop);
}
private static void DeleteEmptyManagedSkillsParentDirectoryIfNeeded(
string targetRoot,
bool groupSkillsUnderUnityCliLoop)
{
if (!groupSkillsUnderUnityCliLoop)
{
return;
}
string managedSkillsRoot = SkillInstallLayout.GetManagedSkillsRoot(targetRoot);
if (!Directory.Exists(managedSkillsRoot))
{
return;
}
DeleteExcludedFilesAtRoot(managedSkillsRoot);
DeleteEmptyDirectoriesAtRoot(managedSkillsRoot);
if (Directory.EnumerateFileSystemEntries(managedSkillsRoot).Any())
{
return;
}
Directory.Delete(managedSkillsRoot);
}
private static void DeleteExcludedFilesAtRoot(string directoryPath)
{
foreach (string filePath in Directory.EnumerateFiles(directoryPath))
{
string fileName = Path.GetFileName(filePath);
if (!IsExcludedSkillFile(fileName))
{
continue;
}
File.Delete(filePath);
}
}
private static void DeleteEmptyDirectoriesAtRoot(string directoryPath)
{
foreach (string childDirectoryPath in Directory.EnumerateDirectories(directoryPath))
{
DeleteEmptyDirectories(childDirectoryPath);
if (Directory.EnumerateFileSystemEntries(childDirectoryPath).Any())
{
continue;
}
Directory.Delete(childDirectoryPath);
}
}
}
}