Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
43774e4
Add V3 CLI invocation migration skill
hatayama Jun 20, 2026
c75c6e1
Separate migration wizard sections
hatayama Jun 20, 2026
b48b309
Refine migration wizard section actions
hatayama Jun 20, 2026
0f6b3a7
Clarify migration wizard section separation
hatayama Jun 20, 2026
c4f4b77
Make migration wizard text selectable
hatayama Jun 20, 2026
b7de65d
Add migration skill usage foldout
hatayama Jun 20, 2026
325a017
Align migrate button styling
hatayama Jun 20, 2026
2fb8c3c
Resolve migration skill from file packages
hatayama Jun 20, 2026
7f58dd6
Fix migration skill remove state handling
hatayama Jun 20, 2026
31b5ea7
Fix migration skill detector and package root lookup
hatayama Jun 20, 2026
fb5f745
Preserve manifest local skill discovery
hatayama Jun 20, 2026
fcccb04
Make migration detectors converge after updates
hatayama Jun 20, 2026
aea0e38
Broaden migration detector inline Markdown matches
hatayama Jun 20, 2026
abb5d56
Prune bundled migration skill sources broadly
hatayama Jun 20, 2026
f5e007f
Decouple migration skill uninstall from source lookup
hatayama Jun 20, 2026
fe84ae5
Remove migration skill from alternate layouts
hatayama Jun 20, 2026
18cba34
Hide temporary migration skill from Unity import
hatayama Jun 21, 2026
804c6f8
Harden V3 migration skill detection
hatayama Jun 21, 2026
dfae617
Fix Windows migration skill verification
hatayama Jun 21, 2026
9f97ad6
Add Windows compatibility guardrails
hatayama Jun 21, 2026
2a9231e
Fix Windows path comparison in skill test
hatayama Jun 21, 2026
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
13 changes: 13 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,19 @@ These files are generated copies. Update the source skill definitions instead, t
Write GitHub Actions and release automation logic in Go when it needs JSON parsing, workflow polling, state transitions, or non-trivial branching.
Shell scripts are acceptable only as thin wrappers or simple command sequences.

## Windows Compatibility Guardrails

Most day-to-day development happens on macOS, but this project must keep working on Windows.
Before changing scripts, skill files, generated-file synchronization, path handling, or text parsing, assume Windows will expose bugs that macOS hides.

- Treat encoding as explicit input. When PowerShell reads UTF-8 repository files, pass `-Encoding UTF8`; Windows PowerShell 5.1 otherwise uses a legacy default that can corrupt non-ASCII text and even report wrong line numbers.
- Treat line endings as data. POSIX shell scripts must remain LF-friendly, and PowerShell/Markdown/skill files must be read in a way that works with both LF and CRLF. If a script fails only under bash, WSL, or Git Bash, check CRLF before changing logic.
- Normalize relative paths at API boundaries. Do not compare raw path strings that may contain `/` on one side and `\` on another. Convert separators before storing, comparing, deleting, or syncing generated files.
- Prefer forward slashes in JSON `file:` paths and other cross-platform config values. Use escaped backslashes only when the target format explicitly requires them.
- Validate Windows-facing PowerShell with both `pwsh` and Windows PowerShell when practical, especially for multiline arguments, here-strings, UTF-8 files, and native executable calls.
- When validating this checkout on Windows, use the repo-local native binary (`cli/dist/windows-amd64/uloop.exe`) instead of a `PATH`-resolved `uloop`. If a bash validation command cannot see the expected Go toolchain on Windows, retry through a login shell such as `bash -lc`.
- Add or update a regression test whenever a fix depends on encoding, line endings, or separator normalization. A passing macOS test alone is not enough for these cases.

## Dead Code Scanner

Use the C# dead-code scanner before deleting apparently unreferenced C# code or before adding comments to explain why an apparently unreferenced type must stay.
Expand Down
40 changes: 36 additions & 4 deletions Assets/Tests/Editor/ThirdPartyToolMigrationWizardWindowTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -95,12 +95,12 @@ public async Task RunAutoScanAsync_WhenScanThrows_LogsExceptionAndConsumesState(

[TestCase(
1,
"1 file needs V3 custom tool migration.\n" +
"1 file needs V3 C# source structure migration.\n" +
"The Unity Console is showing errors because this file still uses the old custom tool API.\n\n" +
"Click Migrate to update it automatically. The errors should disappear after migration.")]
[TestCase(
3,
"3 files need V3 custom tool migration.\n" +
"3 files need V3 C# source structure migration.\n" +
"The Unity Console is showing errors because these files still use the old custom tool API.\n\n" +
"Click Migrate to update them automatically. The errors should disappear after migration.")]
public void GetMigrationStatusText_WhenTargetsExist_ReturnsFileCount(
Expand All @@ -125,7 +125,7 @@ public void GetMigrationProgressText_WhenPreviewProgressExists_ReturnsCheckCount

Assert.That(
text,
Is.EqualTo("Scanning project for V3 custom tool migration...\n3/12 steps complete."));
Is.EqualTo("Scanning C# source files for V3 custom tool API migration...\n3/12 steps complete."));
}

[Test]
Expand All @@ -140,7 +140,7 @@ public void GetMigrationProgressText_WhenApplyProgressExists_ReturnsMigrationCou

Assert.That(
text,
Is.EqualTo("Migrating project files for V3 custom tools...\n4/12 steps complete."));
Is.EqualTo("Migrating C# source files to V3 custom tool APIs...\n4/12 steps complete."));
}

[TestCase(false, false, false, "Check required")]
Expand All @@ -162,6 +162,38 @@ public void GetMigrationButtonText_ReturnsExpectedLabel(
Assert.That(label, Is.EqualTo(expectedLabel));
}

[TestCase(false, SkillInstallState.Missing, "Install Migration Skill")]
[TestCase(false, SkillInstallState.Checking, "Install Migration Skill")]
[TestCase(false, SkillInstallState.Installed, "Remove Migration Skill")]
[TestCase(false, SkillInstallState.Outdated, "Remove Migration Skill")]
[TestCase(true, SkillInstallState.Installed, "Updating...")]
public void GetMigrationSkillButtonText_ReturnsExpectedLabel(
bool isUpdating,
SkillInstallState installState,
string expectedLabel)
{
// Verifies that the migration skill action reflects install, remove, and busy states.
string label = ThirdPartyToolMigrationWizardWindow.GetMigrationSkillButtonText(
isUpdating,
installState);

Assert.That(label, Is.EqualTo(expectedLabel));
}

[TestCase(SkillInstallState.Installed, true)]
[TestCase(SkillInstallState.Outdated, true)]
[TestCase(SkillInstallState.Missing, false)]
[TestCase(SkillInstallState.Checking, false)]
public void ShouldRemoveMigrationSkill_ReturnsExpectedValue(
SkillInstallState installState,
bool expected)
{
// Verifies that the migration skill action removes only when files are detected.
bool shouldRemove = ThirdPartyToolMigrationWizardWindow.ShouldRemoveMigrationSkill(installState);

Assert.That(shouldRemove, Is.EqualTo(expected));
}

[TestCase(0, 1, 1, 4, 1000, 100, true)]
[TestCase(10, 50, 1, 4, 1000, 100, false)]
[TestCase(10, 110, 1, 4, 1000, 100, true)]
Expand Down
147 changes: 147 additions & 0 deletions Assets/Tests/Editor/ToolSkillSynchronizerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1640,6 +1640,153 @@ public void DetectTargets_WhenSourceSkillNameContainsUnsafePathCharacters_Ignore
Assert.That(detectedTargets[0].InstallState, Is.EqualTo(SkillInstallState.Missing));
}

[Test]
public async Task InstallSpecificSkillFilesAtProjectRoot_WhenSingleSkillSourceIsProvided_InstallsOnlyThatSkill()
{
// Tests that temporary migration skill installation can reuse the synchronizer without installing normal skills.
string temporaryRoot = CreateTemporaryProjectRoot();
ToolSkillSynchronizer.SkillTargetInfo target = new(
"Codex CLI",
".codex",
"--codex",
hasSkillsDirectory: true,
hasExistingSkills: false);
SkillInstallLayout.SkillSourceInfo skill = new(
CliConstants.V3_CLI_INVOCATION_MIGRATION_SKILL_NAME,
string.Empty,
new Dictionary<string, byte[]>
{
[SkillInstallLayout.SkillFileName] = Encoding.UTF8.GetBytes(
"---\nname: v3-cli-invocation-migration\n---\n"),
["scripts/detect.sh"] = Encoding.UTF8.GetBytes("#!/bin/sh\n")
});

ToolSkillSynchronizer.SkillInstallResult result =
await ToolSkillSynchronizer.InstallSpecificSkillFilesAtProjectRoot(
temporaryRoot,
new[] { target },
skill,
groupSkillsUnderUnityCliLoop: false);

string installedSkillDir = Path.Combine(
temporaryRoot,
".codex",
SkillInstallLayout.SkillsDirName,
CliConstants.V3_CLI_INVOCATION_MIGRATION_SKILL_NAME);
SkillInstallState installState = ToolSkillSynchronizer.GetSkillInstallStateAtProjectRoot(
temporaryRoot,
target,
skill,
groupSkillsUnderUnityCliLoop: false);

Assert.That(result.IsSuccessful, Is.True);
Assert.That(File.Exists(Path.Combine(installedSkillDir, SkillInstallLayout.SkillFileName)), Is.True);
Assert.That(File.Exists(Path.Combine(installedSkillDir, "scripts", "detect.sh")), Is.True);
Assert.That(installState, Is.EqualTo(SkillInstallState.Installed));
}

[Test]
public async Task GetV3MigrationSkillInstallStateAtProjectRoot_WhenSkillExistsInAlternateLayout_ReturnsInstalled()
{
// Tests that the temporary migration skill is detected even when it exists in the alternate layout.
string temporaryRoot = CreateTemporaryProjectRoot();
ToolSkillSynchronizer.SkillTargetInfo target = new(
"Claude Code",
".claude",
"--claude",
hasSkillsDirectory: true,
hasExistingSkills: false);

ToolSkillSynchronizer.SkillInstallResult result =
await ToolSkillSynchronizer.InstallV3MigrationSkillFilesAtProjectRoot(
temporaryRoot,
new[] { target },
groupSkillsUnderUnityCliLoop: true);

SkillInstallState installState = ToolSkillSynchronizer.GetV3MigrationSkillInstallStateAtProjectRoot(
temporaryRoot,
target,
groupSkillsUnderUnityCliLoop: false);

Assert.That(result.IsSuccessful, Is.True);
Assert.That(installState, Is.EqualTo(SkillInstallState.Installed));
}

[Test]
public async Task RemoveSpecificSkillFilesAtProjectRoot_WhenSkillExists_RemovesOnlyThatSkill()
{
// Tests that temporary migration skill removal leaves unrelated installed skills in place.
string temporaryRoot = CreateTemporaryProjectRoot();
ToolSkillSynchronizer.SkillTargetInfo target = new(
"Codex CLI",
".codex",
"--codex",
hasSkillsDirectory: true,
hasExistingSkills: false);
string targetRoot = Path.Combine(temporaryRoot, ".codex");
string migrationSkillDir = Path.Combine(
targetRoot,
SkillInstallLayout.SkillsDirName,
CliConstants.V3_CLI_INVOCATION_MIGRATION_SKILL_NAME);
string unrelatedSkillDir = Path.Combine(
targetRoot,
SkillInstallLayout.SkillsDirName,
"uloop-compile");
WriteSkillFile(migrationSkillDir, "---\nname: v3-cli-invocation-migration\n---\n");
WriteSkillFile(unrelatedSkillDir, "---\nname: uloop-compile\n---\n");

ToolSkillSynchronizer.SkillInstallResult result =
await ToolSkillSynchronizer.RemoveSpecificSkillFilesAtProjectRoot(
temporaryRoot,
new[] { target },
CliConstants.V3_CLI_INVOCATION_MIGRATION_SKILL_NAME,
groupSkillsUnderUnityCliLoop: false);

Assert.That(result.IsSuccessful, Is.True);
Assert.That(Directory.Exists(migrationSkillDir), Is.False);
Assert.That(Directory.Exists(unrelatedSkillDir), Is.True);
}

[Test]
public async Task RemoveV3MigrationSkillFilesAtProjectRoot_WhenSkillExistsInBothLayouts_RemovesBothLayouts()
{
// Tests that temporary migration skill removal cleans up both supported install layouts.
string temporaryRoot = CreateTemporaryProjectRoot();
ToolSkillSynchronizer.SkillTargetInfo target = new(
"Claude Code",
".claude",
"--claude",
hasSkillsDirectory: true,
hasExistingSkills: false);
string targetRoot = Path.Combine(temporaryRoot, ".claude");
string flatMigrationSkillDir = SkillInstallLayout.GetInstalledSkillDirectoryPathForLayout(
targetRoot,
CliConstants.V3_CLI_INVOCATION_MIGRATION_SKILL_NAME,
groupSkillsUnderUnityCliLoop: false);
string groupedMigrationSkillDir = SkillInstallLayout.GetInstalledSkillDirectoryPathForLayout(
targetRoot,
CliConstants.V3_CLI_INVOCATION_MIGRATION_SKILL_NAME,
groupSkillsUnderUnityCliLoop: true);
string unrelatedSkillDir = Path.Combine(
targetRoot,
SkillInstallLayout.SkillsDirName,
"uloop-compile");
WriteSkillFile(flatMigrationSkillDir, "---\nname: v3-cli-invocation-migration\n---\n");
WriteSkillFile(groupedMigrationSkillDir, "---\nname: v3-cli-invocation-migration\n---\n");
WriteSkillFile(unrelatedSkillDir, "---\nname: uloop-compile\n---\n");

ToolSkillSynchronizer.SkillInstallResult result =
await ToolSkillSynchronizer.RemoveV3MigrationSkillFilesAtProjectRoot(
temporaryRoot,
new[] { target },
groupSkillsUnderUnityCliLoop: false);

Assert.That(result.IsSuccessful, Is.True);
Assert.That(Directory.Exists(flatMigrationSkillDir), Is.False);
Assert.That(Directory.Exists(groupedMigrationSkillDir), Is.False);
Assert.That(Directory.Exists(unrelatedSkillDir), Is.True);
}

private string CreateTemporaryProjectRoot()
{
string temporaryRoot = Path.Combine(
Expand Down
41 changes: 41 additions & 0 deletions Packages/src/Editor/Application/UseCases/SkillSetupUseCase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -73,5 +73,46 @@ public Task InstallSkillFilesForToolAsync(
groupSkillsUnderUnityCliLoop,
ct);
}

public SkillInstallState GetV3MigrationSkillInstallStateAtProjectRoot(
string projectRoot,
SkillSetupTargetInfo target,
bool groupSkillsUnderUnityCliLoop)
{
return _skillSetupService.GetV3MigrationSkillInstallStateAtProjectRoot(
projectRoot,
target,
groupSkillsUnderUnityCliLoop);
}

public Task InstallV3MigrationSkillFilesAsync(
string projectRoot,
List<SkillSetupTargetInfo> targets,
bool groupSkillsUnderUnityCliLoop,
CancellationToken ct)
{
Debug.Assert(targets != null, "targets must not be null");

return _skillSetupService.InstallV3MigrationSkillFilesAsync(
projectRoot,
targets,
groupSkillsUnderUnityCliLoop,
ct);
}

public Task RemoveV3MigrationSkillFilesAsync(
string projectRoot,
List<SkillSetupTargetInfo> targets,
bool groupSkillsUnderUnityCliLoop,
CancellationToken ct)
{
Debug.Assert(targets != null, "targets must not be null");

return _skillSetupService.RemoveV3MigrationSkillFilesAsync(
projectRoot,
targets,
groupSkillsUnderUnityCliLoop,
ct);
}
}
}
2 changes: 2 additions & 0 deletions Packages/src/Editor/Domain/CliConstants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ public static class CliConstants
public const string CLI_RELEASE_TAG_PREFIX = "cli-v";
public const string BETA_VERSION_MARKER = "-beta.";
public const string SKILL_DIR_PREFIX = "uloop-";
public const string TEMPORARY_SKILLS_DIR_NAME = "TemporarySkills~";
public const string V3_CLI_INVOCATION_MIGRATION_SKILL_NAME = "v3-cli-invocation-migration";
public const string UNITY_PACKAGES_DIR_NAME = "Packages";
public const string PACKAGE_SOURCE_DIR_NAME = "src";
public const string CLI_LAYOUT_CONTRACT_FILE_NAME = "layout-contract.json";
Expand Down
61 changes: 61 additions & 0 deletions Packages/src/Editor/Domain/SkillSetupService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,20 @@ Task InstallSkillFilesForToolAsync(
string toolName,
bool groupSkillsUnderUnityCliLoop,
CancellationToken ct);
SkillInstallState GetV3MigrationSkillInstallStateAtProjectRoot(
string projectRoot,
SkillSetupTargetInfo target,
bool groupSkillsUnderUnityCliLoop);
Task InstallV3MigrationSkillFilesAsync(
string projectRoot,
List<SkillSetupTargetInfo> targets,
bool groupSkillsUnderUnityCliLoop,
CancellationToken ct);
Task RemoveV3MigrationSkillFilesAsync(
string projectRoot,
List<SkillSetupTargetInfo> targets,
bool groupSkillsUnderUnityCliLoop,
CancellationToken ct);
}

/// <summary>
Expand Down Expand Up @@ -130,5 +144,52 @@ public Task InstallSkillFilesForToolAsync(
groupSkillsUnderUnityCliLoop,
ct);
}

public SkillInstallState GetV3MigrationSkillInstallStateAtProjectRoot(
string projectRoot,
SkillSetupTargetInfo target,
bool groupSkillsUnderUnityCliLoop)
{
Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty");

return _skillSetupPort.GetV3MigrationSkillInstallStateAtProjectRoot(
projectRoot,
target,
groupSkillsUnderUnityCliLoop);
}

public Task InstallV3MigrationSkillFilesAsync(
string projectRoot,
List<SkillSetupTargetInfo> targets,
bool groupSkillsUnderUnityCliLoop,
CancellationToken ct)
{
Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty");
Debug.Assert(targets != null, "targets must not be null");
ct.ThrowIfCancellationRequested();

return _skillSetupPort.InstallV3MigrationSkillFilesAsync(
projectRoot,
targets,
groupSkillsUnderUnityCliLoop,
ct);
}

public Task RemoveV3MigrationSkillFilesAsync(
string projectRoot,
List<SkillSetupTargetInfo> targets,
bool groupSkillsUnderUnityCliLoop,
CancellationToken ct)
{
Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty");
Debug.Assert(targets != null, "targets must not be null");
ct.ThrowIfCancellationRequested();

return _skillSetupPort.RemoveV3MigrationSkillFilesAsync(
projectRoot,
targets,
groupSkillsUnderUnityCliLoop,
ct);
}
}
}
Loading
Loading