Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 72 additions & 1 deletion Assets/Tests/Editor/ToolSkillSynchronizerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,11 @@ namespace io.github.hatayama.uLoopMCP
[TestFixture]
public class ToolSkillSynchronizerTests
{
private const string SkillsDirName = "skills";

private string _projectRoot;
private string[] _nonExistentDirsBefore;
private string[] _temporaryRoots;

[SetUp]
public void SetUp()
Expand All @@ -19,6 +22,7 @@ public void SetUp()
_nonExistentDirsBefore = ToolSkillSynchronizer.SkillTargetDirs
.Where(dir => !Directory.Exists(Path.Combine(_projectRoot, dir)))
.ToArray();
_temporaryRoots = new string[0];
}

[TearDown]
Expand All @@ -31,7 +35,7 @@ public void TearDown()
if (Directory.Exists(fullPath))
{
// Only delete if it was created by this test (didn't exist before)
string skillsPath = Path.Combine(fullPath, "skills");
string skillsPath = Path.Combine(fullPath, SkillsDirName);
if (Directory.Exists(skillsPath))
{
Directory.Delete(skillsPath, true);
Expand All @@ -43,6 +47,14 @@ public void TearDown()
}
}
}

foreach (string temporaryRoot in _temporaryRoots)
{
if (Directory.Exists(temporaryRoot))
{
Directory.Delete(temporaryRoot, true);
}
}
}

[Test]
Expand All @@ -64,6 +76,54 @@ public async Task InstallSkillFiles_DoesNotCreateNonExistentTargetDirectories()
}
}

[Test]
public void DetectTargets_DoesNotIncludeTargetsWithOnlyParentDirectory()
{
// Arrange
string temporaryRoot = CreateTemporaryProjectRoot();
foreach (string dir in ToolSkillSynchronizer.SkillTargetDirs)
{
Directory.CreateDirectory(Path.Combine(temporaryRoot, dir));
}

// Act
string[] detectedTargetDirs = ToolSkillSynchronizer.DetectTargets(temporaryRoot, requireSkillsDirectory: true)
.Select(target => target.DirName)
.ToArray();

// Assert
foreach (string dir in ToolSkillSynchronizer.SkillTargetDirs)
{
Assert.IsFalse(detectedTargetDirs.Contains(dir),
$"Target '{dir}' should not be detected when only the parent directory exists");
}
}

[Test]
public void DetectTargets_IncludesTargetsWhenSkillsDirectoryExists()
{
// Arrange
string temporaryRoot = CreateTemporaryProjectRoot();
foreach (string dir in ToolSkillSynchronizer.SkillTargetDirs)
{
Directory.CreateDirectory(Path.Combine(temporaryRoot, dir, SkillsDirName));
}

// Act
ToolSkillSynchronizer.SkillTargetInfo[] detectedTargets = ToolSkillSynchronizer.DetectTargets(temporaryRoot, requireSkillsDirectory: true)
.ToArray();

// Assert
Assert.AreEqual(ToolSkillSynchronizer.SkillTargetDirs.Length, detectedTargets.Length,
"Targets with a skills directory should be detected");

foreach (ToolSkillSynchronizer.SkillTargetInfo target in detectedTargets)
{
Assert.IsFalse(target.HasExistingSkills,
$"Target '{target.DirName}' should not be treated as already installed when skills directory is empty");
}
}

[Test]
public void RemoveSkillFiles_DoesNotCreateNonExistentTargetDirectories()
{
Expand Down Expand Up @@ -99,5 +159,16 @@ public void IsSkillInstalled_DoesNotCreateNonExistentTargetDirectories()
$"Directory '{dir}' should not be created by IsSkillInstalled");
}
}

private string CreateTemporaryProjectRoot()
{
string temporaryRoot = Path.Combine(
Path.GetTempPath(),
"ToolSkillSynchronizerTests",
System.Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(temporaryRoot);
_temporaryRoots = _temporaryRoots.Append(temporaryRoot).ToArray();
return temporaryRoot;
}
}
}
42 changes: 29 additions & 13 deletions Packages/src/Editor/Config/ToolSkillSynchronizer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ namespace io.github.hatayama.uLoopMCP
/// </summary>
public static class ToolSkillSynchronizer
{
private const string SkillsDirName = "skills";
private const string SkillFileName = "SKILL.md";

public readonly struct SkillTargetDefinition
{
public readonly string DirName;
Expand Down Expand Up @@ -78,7 +81,7 @@ public static void RemoveSkillFiles(string toolName)

foreach (string targetDir in SkillTargetDirs)
{
string skillsRoot = Path.Combine(projectRoot, targetDir, "skills");
string skillsRoot = Path.Combine(projectRoot, targetDir, SkillsDirName);
if (!Directory.Exists(skillsRoot))
{
continue;
Expand All @@ -103,7 +106,7 @@ public static bool IsSkillInstalled(string toolName)

foreach (string targetDir in SkillTargetDirs)
{
string skillsRoot = Path.Combine(projectRoot, targetDir, "skills");
string skillsRoot = Path.Combine(projectRoot, targetDir, SkillsDirName);
if (!Directory.Exists(skillsRoot))
{
continue;
Expand All @@ -122,41 +125,54 @@ public static bool IsSkillInstalled(string toolName)
return false;
}

/// <summary>
/// Checks parent directories (.claude/, .agents/, etc.), not skills/ subdirectories.
/// </summary>
public static List<SkillTargetInfo> DetectTargets()
{
return DetectTargets(requireSkillsDirectory: 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(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 parentDir = Path.Combine(projectRoot, target.DirName);
if (!Directory.Exists(parentDir))
string targetRoot = Path.Combine(projectRoot, target.DirName);
if (!Directory.Exists(targetRoot))
{
continue;
}

string skillsRoot = Path.Combine(targetRoot, SkillsDirName);
if (requireSkillsDirectory && !Directory.Exists(skillsRoot))
{
continue;
}

string skillsRoot = Path.Combine(parentDir, "skills");
bool hasULoopSkills = Directory.Exists(skillsRoot)
&& Directory.EnumerateDirectories(skillsRoot, CliConstants.SKILL_DIR_GLOB)
.Any(skillDir => File.Exists(Path.Combine(skillDir, "SKILL.md")));
.Any(skillDir => File.Exists(Path.Combine(skillDir, SkillFileName)));
targets.Add(new SkillTargetInfo(target.DisplayName, target.DirName, hasULoopSkills));
}

return targets;
}

/// <summary>
/// Re-install skills for all detected targets.
/// Checks parent directories (.claude/, .agents/, etc.) to determine targets.
/// Re-installs skills only for targets that already opted in via an existing skills directory.
/// </summary>
public static async Task<SkillInstallResult> InstallSkillFiles()
{
List<SkillTargetInfo> targets = DetectTargets();
List<SkillTargetInfo> targets = DetectTargets(requireSkillsDirectory: true);
return await InstallSkillFiles(targets);
}

Expand Down Expand Up @@ -196,7 +212,7 @@ public static async Task<SkillInstallResult> InstallSkillFiles(List<SkillTargetI

private static bool SkillMatchesTool(string skillDir, string toolName)
{
string skillMdPath = Path.Combine(skillDir, "SKILL.md");
string skillMdPath = Path.Combine(skillDir, SkillFileName);
if (File.Exists(skillMdPath))
{
string content = File.ReadAllText(skillMdPath);
Expand Down
Loading