975 lines
37 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|
|
}
|