From 43774e451cc66ed69c7112b5f531c6e53dc7f5eb Mon Sep 17 00:00:00 2001 From: hatayama Date: Sun, 21 Jun 2026 00:30:05 +0900 Subject: [PATCH 01/21] Add V3 CLI invocation migration skill Add a temporary AI skill for migrating V2 uloop CLI invocations, expose dedicated CLI and migration-window install/remove paths, and cover first-party option and output-field detection without changing normal skill installs. --- ...hirdPartyToolMigrationWizardWindowTests.cs | 18 ++ .../Editor/ToolSkillSynchronizerTests.cs | 80 +++++++ .../Application/UseCases/SkillSetupUseCase.cs | 41 ++++ Packages/src/Editor/Domain/CliConstants.cs | 2 + .../src/Editor/Domain/SkillSetupService.cs | 61 ++++++ .../SkillSetup/SkillInstallLayout.cs | 39 ++++ .../SkillSetup/ToolSkillSetupService.cs | 51 +++++ .../SkillSetup/ToolSkillSynchronizer.cs | 126 +++++++++++ .../ThirdPartyToolMigrationWizardText.cs | 17 ++ .../ThirdPartyToolMigrationWizardView.cs | 67 +++++- .../ThirdPartyToolMigrationWizardWindow.cs | 92 ++++++++ Packages/src/TemporarySkills.meta | 8 + .../v3-cli-invocation-migration.meta | 8 + .../v3-cli-invocation-migration/Skill.meta | 8 + .../Skill/SKILL.md | 27 +++ .../Skill/SKILL.md.meta | 7 + .../Skill/references.meta | 8 + .../Skill/references/first-party-v2-to-v3.md | 86 ++++++++ .../references/first-party-v2-to-v3.md.meta | 7 + .../Skill/scripts.meta | 8 + .../detect-v3-cli-invocation-candidates.ps1 | 79 +++++++ ...tect-v3-cli-invocation-candidates.ps1.meta | 7 + .../detect-v3-cli-invocation-candidates.sh | 37 ++++ ...etect-v3-cli-invocation-candidates.sh.meta | 7 + cli/internal/cli/skills.go | 31 ++- cli/internal/cli/skills_discovery.go | 22 ++ cli/internal/cli/skills_display.go | 10 + cli/internal/cli/skills_test.go | 207 ++++++++++++++++++ cli/internal/cli/skills_v3_migration.go | 119 ++++++++++ 29 files changed, 1274 insertions(+), 6 deletions(-) create mode 100644 Packages/src/TemporarySkills.meta create mode 100644 Packages/src/TemporarySkills/v3-cli-invocation-migration.meta create mode 100644 Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill.meta create mode 100644 Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill/SKILL.md create mode 100644 Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill/SKILL.md.meta create mode 100644 Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill/references.meta create mode 100644 Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill/references/first-party-v2-to-v3.md create mode 100644 Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill/references/first-party-v2-to-v3.md.meta create mode 100644 Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill/scripts.meta create mode 100644 Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill/scripts/detect-v3-cli-invocation-candidates.ps1 create mode 100644 Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill/scripts/detect-v3-cli-invocation-candidates.ps1.meta create mode 100755 Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill/scripts/detect-v3-cli-invocation-candidates.sh create mode 100644 Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill/scripts/detect-v3-cli-invocation-candidates.sh.meta create mode 100644 cli/internal/cli/skills_v3_migration.go diff --git a/Assets/Tests/Editor/ThirdPartyToolMigrationWizardWindowTests.cs b/Assets/Tests/Editor/ThirdPartyToolMigrationWizardWindowTests.cs index 812f93285d..dc61b08fe6 100644 --- a/Assets/Tests/Editor/ThirdPartyToolMigrationWizardWindowTests.cs +++ b/Assets/Tests/Editor/ThirdPartyToolMigrationWizardWindowTests.cs @@ -162,6 +162,24 @@ 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(0, 1, 1, 4, 1000, 100, true)] [TestCase(10, 50, 1, 4, 1000, 100, false)] [TestCase(10, 110, 1, 4, 1000, 100, true)] diff --git a/Assets/Tests/Editor/ToolSkillSynchronizerTests.cs b/Assets/Tests/Editor/ToolSkillSynchronizerTests.cs index 21166184e3..19400e4891 100644 --- a/Assets/Tests/Editor/ToolSkillSynchronizerTests.cs +++ b/Assets/Tests/Editor/ToolSkillSynchronizerTests.cs @@ -1640,6 +1640,86 @@ 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 + { + [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 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); + } + private string CreateTemporaryProjectRoot() { string temporaryRoot = Path.Combine( diff --git a/Packages/src/Editor/Application/UseCases/SkillSetupUseCase.cs b/Packages/src/Editor/Application/UseCases/SkillSetupUseCase.cs index 7831a99581..d5e09bdae7 100644 --- a/Packages/src/Editor/Application/UseCases/SkillSetupUseCase.cs +++ b/Packages/src/Editor/Application/UseCases/SkillSetupUseCase.cs @@ -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 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 targets, + bool groupSkillsUnderUnityCliLoop, + CancellationToken ct) + { + Debug.Assert(targets != null, "targets must not be null"); + + return _skillSetupService.RemoveV3MigrationSkillFilesAsync( + projectRoot, + targets, + groupSkillsUnderUnityCliLoop, + ct); + } } } diff --git a/Packages/src/Editor/Domain/CliConstants.cs b/Packages/src/Editor/Domain/CliConstants.cs index 8c17d89f0c..531806c9b1 100644 --- a/Packages/src/Editor/Domain/CliConstants.cs +++ b/Packages/src/Editor/Domain/CliConstants.cs @@ -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"; diff --git a/Packages/src/Editor/Domain/SkillSetupService.cs b/Packages/src/Editor/Domain/SkillSetupService.cs index 2a908c6555..a87f2f39bc 100644 --- a/Packages/src/Editor/Domain/SkillSetupService.cs +++ b/Packages/src/Editor/Domain/SkillSetupService.cs @@ -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 targets, + bool groupSkillsUnderUnityCliLoop, + CancellationToken ct); + Task RemoveV3MigrationSkillFilesAsync( + string projectRoot, + List targets, + bool groupSkillsUnderUnityCliLoop, + CancellationToken ct); } /// @@ -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 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 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); + } } } diff --git a/Packages/src/Editor/Infrastructure/SkillSetup/SkillInstallLayout.cs b/Packages/src/Editor/Infrastructure/SkillSetup/SkillInstallLayout.cs index 3cf49593ec..cace82a645 100644 --- a/Packages/src/Editor/Infrastructure/SkillSetup/SkillInstallLayout.cs +++ b/Packages/src/Editor/Infrastructure/SkillSetup/SkillInstallLayout.cs @@ -260,6 +260,45 @@ internal static List GetSkillSourceInfos(string projectRoot) .ToList(); } + internal static SkillSourceInfo GetSkillSourceInfoFromDirectory(string skillDirectory) + { + Debug.Assert(!string.IsNullOrEmpty(skillDirectory), "skillDirectory must not be null or empty"); + + string skillFilePath = Path.Combine(skillDirectory, SkillFileName); + Debug.Assert(File.Exists(skillFilePath), "skill source must contain SKILL.md"); + + string skillContent = File.ReadAllText(skillFilePath); + string skillName = ParseNameFromFrontmatter(skillContent); + Debug.Assert(IsSafeSkillPathComponent(skillName), "skillName must be a single safe path component"); + + return new SkillSourceInfo( + skillName, + ParseToolNameFromFrontmatter(skillContent), + CollectSourceSkillFiles(skillDirectory, skillFilePath)); + } + + internal static SkillInstallState GetInstalledStateForSkillSource( + string targetRoot, + SkillSourceInfo skill, + bool groupSkillsUnderUnityCliLoop) + { + Debug.Assert(!string.IsNullOrEmpty(targetRoot), "targetRoot must not be null or empty"); + Debug.Assert(!string.IsNullOrEmpty(skill.Name), "skill name must not be null or empty"); + + string installedSkillDirectory = GetInstalledSkillDirectoryPath( + targetRoot, + skill.Name, + groupSkillsUnderUnityCliLoop); + if (!Directory.Exists(installedSkillDirectory)) + { + return SkillInstallState.Missing; + } + + return IsSkillDirectoryOutdated(skill.SkillFiles, installedSkillDirectory) + ? SkillInstallState.Outdated + : SkillInstallState.Installed; + } + internal static string GetInstalledSkillDirectoryPathForLayout( string targetRoot, string skillName, diff --git a/Packages/src/Editor/Infrastructure/SkillSetup/ToolSkillSetupService.cs b/Packages/src/Editor/Infrastructure/SkillSetup/ToolSkillSetupService.cs index 3f7e08b779..405a26ff89 100644 --- a/Packages/src/Editor/Infrastructure/SkillSetup/ToolSkillSetupService.cs +++ b/Packages/src/Editor/Infrastructure/SkillSetup/ToolSkillSetupService.cs @@ -86,6 +86,57 @@ await ToolSkillSynchronizer.InstallSkillFilesForTool( _toolSettingsService.GetDisabledTools()); } + public SkillInstallState GetV3MigrationSkillInstallStateAtProjectRoot( + string projectRoot, + SkillSetupTargetInfo target, + bool groupSkillsUnderUnityCliLoop) + { + Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty"); + + return ToolSkillSynchronizer.GetV3MigrationSkillInstallStateAtProjectRoot( + projectRoot, + ToSynchronizerInfo(target), + groupSkillsUnderUnityCliLoop); + } + + public async Task InstallV3MigrationSkillFilesAsync( + string projectRoot, + List 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(); + + List synchronizerTargets = targets + .Select(ToSynchronizerInfo) + .ToList(); + await ToolSkillSynchronizer.InstallV3MigrationSkillFilesAtProjectRoot( + projectRoot, + synchronizerTargets, + groupSkillsUnderUnityCliLoop); + } + + public async Task RemoveV3MigrationSkillFilesAsync( + string projectRoot, + List 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(); + + List synchronizerTargets = targets + .Select(ToSynchronizerInfo) + .ToList(); + await ToolSkillSynchronizer.RemoveV3MigrationSkillFilesAtProjectRoot( + projectRoot, + synchronizerTargets, + groupSkillsUnderUnityCliLoop); + } + private static SkillSetupTargetInfo ToDomainInfo(ToolSkillSynchronizer.SkillTargetInfo target) { return new SkillSetupTargetInfo( diff --git a/Packages/src/Editor/Infrastructure/SkillSetup/ToolSkillSynchronizer.cs b/Packages/src/Editor/Infrastructure/SkillSetup/ToolSkillSynchronizer.cs index 71201fc8eb..c1133bfff1 100644 --- a/Packages/src/Editor/Infrastructure/SkillSetup/ToolSkillSynchronizer.cs +++ b/Packages/src/Editor/Infrastructure/SkillSetup/ToolSkillSynchronizer.cs @@ -104,6 +104,11 @@ public SkillTargetInfo( ".DS_Store", ".gitkeep" }; + private static readonly string V3MigrationSkillSourceDirectory = Path.Combine( + UnityCliLoopConstants.PackageResolvedPath, + CliConstants.TEMPORARY_SKILLS_DIR_NAME, + CliConstants.V3_CLI_INVOCATION_MIGRATION_SKILL_NAME, + "Skill"); internal static readonly string[] SkillTargetDirs = SkillTargets.Select(t => t.DirName).ToArray(); @@ -552,6 +557,127 @@ internal static async Task InstallSkillFilesForToolAtProject }); } + internal static SkillInstallState GetV3MigrationSkillInstallStateAtProjectRoot( + string projectRoot, + SkillTargetInfo target, + bool groupSkillsUnderUnityCliLoop) + { + SkillInstallLayout.SkillSourceInfo skill = GetV3MigrationSkillSourceInfo(); + return GetSkillInstallStateAtProjectRoot( + projectRoot, + target, + skill, + groupSkillsUnderUnityCliLoop); + } + + internal static async Task InstallV3MigrationSkillFilesAtProjectRoot( + string projectRoot, + IEnumerable targets, + bool groupSkillsUnderUnityCliLoop) + { + SkillInstallLayout.SkillSourceInfo skill = GetV3MigrationSkillSourceInfo(); + return await InstallSpecificSkillFilesAtProjectRoot( + projectRoot, + targets, + skill, + groupSkillsUnderUnityCliLoop); + } + + internal static async Task RemoveV3MigrationSkillFilesAtProjectRoot( + string projectRoot, + IEnumerable targets, + bool groupSkillsUnderUnityCliLoop) + { + SkillInstallLayout.SkillSourceInfo skill = GetV3MigrationSkillSourceInfo(); + return await RemoveSpecificSkillFilesAtProjectRoot( + projectRoot, + targets, + skill.Name, + groupSkillsUnderUnityCliLoop); + } + + internal static SkillInstallState GetSkillInstallStateAtProjectRoot( + string projectRoot, + SkillTargetInfo target, + SkillInstallLayout.SkillSourceInfo skill, + bool groupSkillsUnderUnityCliLoop) + { + Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty"); + Debug.Assert(!string.IsNullOrEmpty(target.DirName), "target dir name must not be null or empty"); + + string targetRoot = Path.Combine(projectRoot, target.DirName); + if (!Directory.Exists(targetRoot)) + { + return SkillInstallState.Missing; + } + + return SkillInstallLayout.GetInstalledStateForSkillSource( + targetRoot, + skill, + groupSkillsUnderUnityCliLoop); + } + + internal static async Task InstallSpecificSkillFilesAtProjectRoot( + string projectRoot, + IEnumerable targets, + SkillInstallLayout.SkillSourceInfo skill, + 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(() => + { + int succeeded = 0; + foreach (SkillTargetInfo target in targetArray) + { + InstallSpecificSkillsForTarget( + projectRoot, + target, + Array.Empty(), + new[] { skill }, + groupSkillsUnderUnityCliLoop); + succeeded++; + } + + return new SkillInstallResult(targetArray.Length, succeeded); + }); + } + + internal static async Task RemoveSpecificSkillFilesAtProjectRoot( + string projectRoot, + IEnumerable targets, + string skillName, + bool groupSkillsUnderUnityCliLoop) + { + Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty"); + Debug.Assert(targets != null, "targets must not be null"); + Debug.Assert(!string.IsNullOrEmpty(skillName), "skillName must not be null or empty"); + + SkillTargetInfo[] targetArray = targets.ToArray(); + return await Task.Run(() => + { + int succeeded = 0; + foreach (SkillTargetInfo target in targetArray) + { + string targetRoot = Path.Combine(projectRoot, target.DirName); + DeleteSkillDirectoryIfExists( + targetRoot, + skillName, + groupSkillsUnderUnityCliLoop); + succeeded++; + } + + return new SkillInstallResult(targetArray.Length, succeeded); + }); + } + + private static SkillInstallLayout.SkillSourceInfo GetV3MigrationSkillSourceInfo() + { + return SkillInstallLayout.GetSkillSourceInfoFromDirectory(V3MigrationSkillSourceDirectory); + } + private static void InstallSkillsForTarget( string projectRoot, SkillTargetInfo target, diff --git a/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardText.cs b/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardText.cs index 808980708a..11b167ed92 100644 --- a/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardText.cs +++ b/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardText.cs @@ -17,6 +17,9 @@ internal static class ThirdPartyToolMigrationWizardText private const string MigrationButtonMigratingText = "Migrating..."; private const string MigrationButtonNoTargetsText = "Nothing to migrate"; private const string MigrationButtonCheckRequiredText = "Check required"; + private const string InstallMigrationSkillButtonText = "Install Migration Skill"; + private const string RemoveMigrationSkillButtonText = "Remove Migration Skill"; + private const string UpdatingMigrationSkillButtonText = "Updating..."; internal static string GetMigrationStatusText(int fileCount) { @@ -64,5 +67,19 @@ internal static string GetMigrationButtonText( return hasMigrationTargets ? MigrationButtonReadyText : MigrationButtonNoTargetsText; } + + internal static string GetMigrationSkillButtonText( + bool isUpdating, + SkillInstallState installState) + { + if (isUpdating) + { + return UpdatingMigrationSkillButtonText; + } + + return installState == SkillInstallState.Installed || installState == SkillInstallState.Outdated + ? RemoveMigrationSkillButtonText + : InstallMigrationSkillButtonText; + } } } diff --git a/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardView.cs b/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardView.cs index 93b2323590..c3c4819371 100644 --- a/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardView.cs +++ b/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardView.cs @@ -20,6 +20,8 @@ internal sealed class ThirdPartyToolMigrationWizardView private readonly ProgressBar _migrationProgressBar; private readonly VisualElement _migrationButtonRow; private readonly Button _migrateButton; + private readonly EnumField _migrationSkillTargetField; + private readonly Button _migrationSkillButton; private readonly Button _refreshButton; private readonly Button _closeButton; @@ -29,6 +31,8 @@ private ThirdPartyToolMigrationWizardView( ProgressBar migrationProgressBar, VisualElement migrationButtonRow, Button migrateButton, + EnumField migrationSkillTargetField, + Button migrationSkillButton, Button refreshButton, Button closeButton) { @@ -37,6 +41,8 @@ private ThirdPartyToolMigrationWizardView( Debug.Assert(migrationProgressBar != null, "migrationProgressBar must not be null"); Debug.Assert(migrationButtonRow != null, "migrationButtonRow must not be null"); Debug.Assert(migrateButton != null, "migrateButton must not be null"); + Debug.Assert(migrationSkillTargetField != null, "migrationSkillTargetField must not be null"); + Debug.Assert(migrationSkillButton != null, "migrationSkillButton must not be null"); Debug.Assert(refreshButton != null, "refreshButton must not be null"); Debug.Assert(closeButton != null, "closeButton must not be null"); @@ -45,6 +51,8 @@ private ThirdPartyToolMigrationWizardView( _migrationProgressBar = migrationProgressBar; _migrationButtonRow = migrationButtonRow; _migrateButton = migrateButton; + _migrationSkillTargetField = migrationSkillTargetField; + _migrationSkillButton = migrationSkillButton; _refreshButton = refreshButton; _closeButton = closeButton; } @@ -55,11 +63,15 @@ internal static ThirdPartyToolMigrationWizardView Create( VisualElement root, Action refresh, Action migrate, + Action migrationSkillTargetChanged, + Action toggleMigrationSkill, Action close) { Debug.Assert(root != null, "root must not be null"); Debug.Assert(refresh != null, "refresh must not be null"); Debug.Assert(migrate != null, "migrate must not be null"); + Debug.Assert(migrationSkillTargetChanged != null, "migrationSkillTargetChanged must not be null"); + Debug.Assert(toggleMigrationSkill != null, "toggleMigrationSkill must not be null"); Debug.Assert(close != null, "close must not be null"); string ussPath = $"{UnityCliLoopConstants.PackageAssetPath}/{USS_RELATIVE_PATH}"; @@ -73,7 +85,8 @@ internal static ThirdPartyToolMigrationWizardView Create( ProgressBar migrationProgressBar = CreateProgressBar(content); VisualElement migrationButtonRow = CreateMigrationButtonRow(content); Button migrateButton = CreateMigrateButton(migrationButtonRow); - (Button refreshButton, Button closeButton) = CreateFooter(mainScrollView); + (EnumField migrationSkillTargetField, Button migrationSkillButton, Button refreshButton, Button closeButton) = + CreateFooter(mainScrollView); ThirdPartyToolMigrationWizardView view = new ThirdPartyToolMigrationWizardView( mainScrollView, @@ -81,12 +94,27 @@ internal static ThirdPartyToolMigrationWizardView Create( migrationProgressBar, migrationButtonRow, migrateButton, + migrationSkillTargetField, + migrationSkillButton, refreshButton, closeButton); - view.BindEvents(refresh, migrate, close); + view.BindEvents(refresh, migrate, migrationSkillTargetChanged, toggleMigrationSkill, close); return view; } + internal void SetMigrationSkillState( + SkillsTarget target, + SkillInstallState installState, + bool isUpdating) + { + _migrationSkillTargetField.SetValueWithoutNotify(target); + _migrationSkillButton.text = ThirdPartyToolMigrationWizardText.GetMigrationSkillButtonText( + isUpdating, + installState); + _migrationSkillTargetField.SetEnabled(!isUpdating); + _migrationSkillButton.SetEnabled(!isUpdating); + } + internal void ShowNotCheckedState(bool isMigrating) { _migrationStatusLabel.text = ThirdPartyToolMigrationWizardText.MigrationNotCheckedText; @@ -212,12 +240,28 @@ private static Button CreateMigrateButton(VisualElement migrationButtonRow) return migrateButton; } - private static (Button refreshButton, Button closeButton) CreateFooter(ScrollView mainScrollView) + private static (EnumField migrationSkillTargetField, Button migrationSkillButton, Button refreshButton, Button closeButton) + CreateFooter(ScrollView mainScrollView) { VisualElement footer = new VisualElement(); footer.AddToClassList("setup-footer"); mainScrollView.Add(footer); + VisualElement migrationSkillRow = new VisualElement(); + migrationSkillRow.AddToClassList("setup-footer__button-row"); + footer.Add(migrationSkillRow); + + EnumField migrationSkillTargetField = new EnumField(SkillsTarget.Claude); + migrationSkillTargetField.AddToClassList("setup-dropdown"); + migrationSkillRow.Add(migrationSkillTargetField); + + Button migrationSkillButton = new Button(); + migrationSkillButton.text = ThirdPartyToolMigrationWizardText.GetMigrationSkillButtonText( + false, + SkillInstallState.Missing); + migrationSkillButton.AddToClassList("setup-button"); + migrationSkillRow.Add(migrationSkillButton); + VisualElement footerButtonRow = new VisualElement(); footerButtonRow.AddToClassList("setup-footer__button-row"); footer.Add(footerButtonRow); @@ -238,13 +282,26 @@ private static (Button refreshButton, Button closeButton) CreateFooter(ScrollVie "Window > Unity CLI Loop > Custom Tool Migration."); reopenHintLabel.AddToClassList("setup-footer__hint-label"); footer.Add(reopenHintLabel); - return (refreshButton, closeButton); + return (migrationSkillTargetField, migrationSkillButton, refreshButton, closeButton); } - private void BindEvents(Action refresh, Action migrate, Action close) + private void BindEvents( + Action refresh, + Action migrate, + Action migrationSkillTargetChanged, + Action toggleMigrationSkill, + Action close) { _refreshButton.clicked += refresh; _migrateButton.clicked += migrate; + _migrationSkillButton.clicked += toggleMigrationSkill; + _migrationSkillTargetField.RegisterValueChangedCallback(evt => + { + if (evt.newValue is SkillsTarget target) + { + migrationSkillTargetChanged(target); + } + }); _closeButton.clicked += close; } diff --git a/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardWindow.cs b/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardWindow.cs index 9165228a25..5800862140 100644 --- a/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardWindow.cs +++ b/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardWindow.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; @@ -16,6 +17,7 @@ namespace io.github.hatayama.UnityCliLoop.Presentation public class ThirdPartyToolMigrationWizardWindow : EditorWindow { private const string WindowTitle = "Unity CLI Loop Migration"; + private const bool GroupMigrationSkillUnderUnityCliLoop = false; private static readonly Vector2 InitialWindowSize = ThirdPartyToolMigrationWizardWindowResizer.InitialWindowSize; private static readonly Vector2 MinimumWindowSize = @@ -26,7 +28,11 @@ public class ThirdPartyToolMigrationWizardWindow : EditorWindow private bool _shouldRefreshAfterCreateGui; private bool _isMigrating; + private bool _isUpdatingMigrationSkill; + private SkillsTarget _migrationSkillTarget = SkillsTarget.Claude; + private SkillInstallState _migrationSkillInstallState = SkillInstallState.Missing; private CancellationTokenSource _migrationOperationCts; + private SkillSetupUseCase _skillSetupUseCase; private ThirdPartyToolMigrationUseCase _thirdPartyToolMigrationUseCase; private ThirdPartyToolMigrationWizardView _view; private ThirdPartyToolMigrationWizardWindowResizer _resizer; @@ -196,6 +202,13 @@ internal static string GetMigrationButtonText( hasCheckedMigrationStatus); } + internal static string GetMigrationSkillButtonText( + bool isUpdating, + SkillInstallState installState) + { + return ThirdPartyToolMigrationWizardText.GetMigrationSkillButtonText(isUpdating, installState); + } + internal static bool HasFiniteSize(Vector2 size) { return ThirdPartyToolMigrationWizardWindowResizer.HasFiniteSize(size); @@ -304,18 +317,22 @@ private void CreateGUI() rootVisualElement, RefreshUI, HandleMigrateThirdPartyTools, + HandleMigrationSkillTargetChanged, + HandleToggleMigrationSkill, Close); _resizer = new ThirdPartyToolMigrationWizardWindowResizer(this, _view.MainScrollView); _resizer.BindSizeUpdates(); bool shouldStartInitialRefresh = ConsumeShouldStartInitialRefresh(); ShowInitialState(shouldStartInitialRefresh); + RefreshMigrationSkillState(); ScheduleInitialRefresh(shouldStartInitialRefresh); ScheduleResizeToContent(); } private void InitializeApplicationServices() { + _skillSetupUseCase = SkillSetupUseCaseRegistry.GetRegisteredUseCase(); _thirdPartyToolMigrationUseCase = ThirdPartyToolMigrationUseCaseRegistry.GetRegisteredUseCase(); } @@ -478,6 +495,81 @@ private void ShowCheckingState(ThirdPartyToolMigrationProgress progress) ScheduleResizeToContent(); } + private void RefreshMigrationSkillState() + { + string projectRoot = UnityCliLoopPathResolver.GetProjectRoot(); + SkillSetupTargetInfo target = CreateMigrationSkillTargetInfo(_migrationSkillTarget); + _migrationSkillInstallState = _skillSetupUseCase.GetV3MigrationSkillInstallStateAtProjectRoot( + projectRoot, + target, + GroupMigrationSkillUnderUnityCliLoop); + UpdateMigrationSkillState(); + } + + private void UpdateMigrationSkillState() + { + _view.SetMigrationSkillState( + _migrationSkillTarget, + _migrationSkillInstallState, + _isUpdatingMigrationSkill); + ScheduleResizeToContent(); + } + + private void HandleMigrationSkillTargetChanged(SkillsTarget target) + { + _migrationSkillTarget = target; + RefreshMigrationSkillState(); + } + + private async void HandleToggleMigrationSkill() + { + string projectRoot = UnityCliLoopPathResolver.GetProjectRoot(); + SkillSetupTargetInfo target = CreateMigrationSkillTargetInfo(_migrationSkillTarget); + List targets = new() { target }; + _isUpdatingMigrationSkill = true; + UpdateMigrationSkillState(); + + try + { + if (_migrationSkillInstallState == SkillInstallState.Installed + || _migrationSkillInstallState == SkillInstallState.Outdated) + { + await _skillSetupUseCase.RemoveV3MigrationSkillFilesAsync( + projectRoot, + targets, + GroupMigrationSkillUnderUnityCliLoop, + CancellationToken.None); + return; + } + + await _skillSetupUseCase.InstallV3MigrationSkillFilesAsync( + projectRoot, + targets, + GroupMigrationSkillUnderUnityCliLoop, + CancellationToken.None); + } + finally + { + _isUpdatingMigrationSkill = false; + RefreshMigrationSkillState(); + } + } + + private static SkillSetupTargetInfo CreateMigrationSkillTargetInfo(SkillsTarget target) + { + SkillsTargetSelection selection = SkillsTargetSelectionResolver.Resolve( + target, + GroupMigrationSkillUnderUnityCliLoop); + return new SkillSetupTargetInfo( + selection.DisplayName, + selection.DirectoryName, + selection.InstallFlag, + hasSkillsDirectory: true, + hasExistingSkills: false, + hasDifferentLayoutSkills: false, + SkillInstallState.Missing); + } + private void ScheduleResizeToContent() { _resizer?.ScheduleResizeToContent(); diff --git a/Packages/src/TemporarySkills.meta b/Packages/src/TemporarySkills.meta new file mode 100644 index 0000000000..82074c7acd --- /dev/null +++ b/Packages/src/TemporarySkills.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: ebaa76c7aafd48cc895c972d68829ddf +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/TemporarySkills/v3-cli-invocation-migration.meta b/Packages/src/TemporarySkills/v3-cli-invocation-migration.meta new file mode 100644 index 0000000000..b52a7168c4 --- /dev/null +++ b/Packages/src/TemporarySkills/v3-cli-invocation-migration.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 72a6b872d1684e7691c106b839cdeeb3 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill.meta b/Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill.meta new file mode 100644 index 0000000000..7a3d9fe3bd --- /dev/null +++ b/Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 1375da47d449464d8da8a19f5c65a3c7 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill/SKILL.md b/Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill/SKILL.md new file mode 100644 index 0000000000..203a7d04f2 --- /dev/null +++ b/Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill/SKILL.md @@ -0,0 +1,27 @@ +--- +name: v3-cli-invocation-migration +description: Migrate uloop V2 CLI invocations in agent skills, Markdown, POSIX shell scripts, and PowerShell scripts to V3 syntax. Use when updating third-party or first-party uloop command examples, shell automation, SKILL.md files, or JSON output parsing from V2-style boolean arguments, renamed first-party options, or PascalCase response fields. +--- + +# V3 CLI Invocation Migration + +Use this skill to update V2-era `uloop` CLI invocations to V3 syntax in agent-facing docs and automation. + +## Workflow + +1. Run the detector for the current repository: + - POSIX shell: `sh scripts/detect-v3-cli-invocation-candidates.sh .` + - PowerShell: `pwsh -File scripts/detect-v3-cli-invocation-candidates.ps1 -Root .` +2. Read `references/first-party-v2-to-v3.md` before editing first-party tool calls or JSON output parsing. +3. Inspect each reported line with nearby context. Treat detector output as candidates, not proof. +4. Edit only files that clearly contain V2 `uloop` command syntax or V2 output-field parsing. +5. Run the detector again and report remaining candidates with the reason each one was left unchanged. + +## Editing Rules + +- Convert V2 boolean arguments from `--flag true` or `--flag=false` syntax to V3 flag syntax. +- For third-party tools, check the tool's current schema or documentation before deciding whether `false` means removal, a `--no-*` flag, or a renamed option. +- For first-party tools, use the reference table. `compile` and `run-tests` have special renamed negative flags. +- Convert V2 PascalCase output field reads to V3 camelCase only when the surrounding command is a `uloop` first-party command. +- Do not rewrite generated installed skills under `.agents`, `.claude`, `.codex`, `.cursor`, `.gemini`, or similar generated target folders unless the user explicitly asks to migrate installed copies. +- Do not rely on `rg`, `jq`, Python, or Go being present. The bundled detectors are dependency-light and write no files. diff --git a/Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill/SKILL.md.meta b/Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill/SKILL.md.meta new file mode 100644 index 0000000000..382ab387bb --- /dev/null +++ b/Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill/SKILL.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 55ff422e16f342aabce4d19ae60c6424 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill/references.meta b/Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill/references.meta new file mode 100644 index 0000000000..6f6bb23cdb --- /dev/null +++ b/Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill/references.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 7078ee478d8d4e2bade92e86d552fbd4 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill/references/first-party-v2-to-v3.md b/Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill/references/first-party-v2-to-v3.md new file mode 100644 index 0000000000..3a551b9867 --- /dev/null +++ b/Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill/references/first-party-v2-to-v3.md @@ -0,0 +1,86 @@ +# First-Party V2 to V3 CLI Migration + +Use this reference after the detector finds a candidate. Keep edits local to the command or parser being migrated. + +## Boolean Argument Rules + +| V2 form | V3 form | +| --- | --- | +| `--flag true` | `--flag` when the V3 option is a positive default-false boolean | +| `--flag=false` | remove the option when the V3 default is already false | +| `--flag true` | remove the option when the V3 default is already true | +| `--flag false` | use the V3 negative option when the V3 default is true | + +## Special First-Party Options + +| V2 command | V2 option | V3 replacement | +| --- | --- | --- | +| `uloop compile` | `--force-recompile true` | `--force-recompile` | +| `uloop compile` | `--force-recompile false` | remove | +| `uloop compile` | `--wait-for-domain-reload true` or bare `--wait-for-domain-reload` | remove | +| `uloop compile` | `--wait-for-domain-reload false` | `--no-wait-for-domain-reload` | +| `uloop compile` | `--reload-external-scene-changes true` | remove | +| `uloop compile` | `--reload-external-scene-changes false` | `--stop-on-external-scene-changes` | +| `uloop run-tests` | `--save-before-run true` or bare `--save-before-run` | remove | +| `uloop run-tests` | `--save-before-run false` | `--fail-on-unsaved-changes` | +| `uloop record-input` | `--show-overlay true` | remove | +| `uloop record-input` | `--show-overlay false` | `--no-show-overlay` | +| `uloop replay-input` | `--show-overlay true` | remove | +| `uloop replay-input` | `--show-overlay false` | `--no-show-overlay` | +| `uloop get-hierarchy` | `--include-components true` | remove | +| `uloop get-hierarchy` | `--include-components false` | `--no-include-components` | +| `uloop get-hierarchy` | `--include-inactive true` | remove | +| `uloop get-hierarchy` | `--include-inactive false` | `--no-include-inactive` | +| `uloop execute-dynamic-code` | `--compile-only true` | `--compile-only` | +| `uloop execute-dynamic-code` | `--compile-only false` | remove | + +## Output JSON Field Candidates + +V3 first-party CLI output generally uses camelCase JSON fields. Only update these when the surrounding parser is reading `uloop` output. + +| V2 field | V3 field | +| --- | --- | +| `Success` | `success` | +| `Message` | `message` | +| `ErrorMessage` | `errorMessage` | +| `ErrorCount` | `errorCount` | +| `WarningCount` | `warningCount` | +| `TotalCount` | `totalCount` | +| `DisplayedCount` | `displayedCount` | +| `LogType` | `logType` | +| `StackTrace` | `stackTrace` | +| `XmlPath` | `xmlPath` | +| `TestCount` | `testCount` | +| `PassedCount` | `passedCount` | +| `FailedCount` | `failedCount` | +| `SkippedCount` | `skippedCount` | +| `CompletedAt` | `completedAt` | +| `ScreenshotCount` | `screenshotCount` | +| `Screenshots` | `screenshots` | +| `CompilationErrors` | `compilationErrors` | +| `ErrorCode` | `errorCode` | +| `UpdatedCode` | `updatedCode` | +| `DiagnosticsSummary` | `diagnosticsSummary` | +| `OutputPath` | `outputPath` | +| `InputPath` | `inputPath` | +| `TotalFrames` | `totalFrames` | +| `DurationSeconds` | `durationSeconds` | +| `CurrentFrame` | `currentFrame` | +| `IsReplaying` | `isReplaying` | +| `KeyName` | `keyName` | +| `PositionX` | `positionX` | +| `PositionY` | `positionY` | +| `EndPositionX` | `endPositionX` | +| `EndPositionY` | `endPositionY` | +| `HitGameObjectName` | `hitGameObjectName` | +| `IsPlaying` | `isPlaying` | +| `IsPaused` | `isPaused` | +| `ClearedLogCount` | `clearedLogCount` | +| `ClearedCounts` | `clearedCounts` | + +## Review Guidance + +- `jq '.Success'` usually becomes `jq '.success'`. +- PowerShell `$result.Success` usually becomes `$result.success`. +- If the code handles non-uloop JSON too, narrow the edit or leave it unchanged and report it. +- Removed commands such as `get-project-info` and `get-version` require manual replacement because the correct V3 behavior depends on the caller's intent. diff --git a/Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill/references/first-party-v2-to-v3.md.meta b/Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill/references/first-party-v2-to-v3.md.meta new file mode 100644 index 0000000000..1a311a8cfe --- /dev/null +++ b/Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill/references/first-party-v2-to-v3.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: b315d085dae6446db01b8b798efd1e23 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill/scripts.meta b/Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill/scripts.meta new file mode 100644 index 0000000000..897a7bdfa0 --- /dev/null +++ b/Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill/scripts.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 6a519f6bea7449aab106bb5b519c1a7a +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill/scripts/detect-v3-cli-invocation-candidates.ps1 b/Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill/scripts/detect-v3-cli-invocation-candidates.ps1 new file mode 100644 index 0000000000..a75e0638cc --- /dev/null +++ b/Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill/scripts/detect-v3-cli-invocation-candidates.ps1 @@ -0,0 +1,79 @@ +param( + [string] $Root = "." +) + +$excludedDirectories = @( + ".git", + "Library", + "Temp", + "node_modules", + ".agents", + ".claude", + ".codex", + ".cursor", + ".gemini", + ".windsurf", + ".agent" +) + +$targetExtensions = @(".md", ".sh", ".bash", ".zsh", ".ps1", ".psm1") +$outputFields = "Success|Message|ErrorMessage|ErrorCount|WarningCount|TotalCount|DisplayedCount|LogType|StackTrace|XmlPath|TestCount|PassedCount|FailedCount|SkippedCount|CompletedAt|ScreenshotCount|Screenshots|CompilationErrors|ErrorCode|UpdatedCode|DiagnosticsSummary|OutputPath|InputPath|TotalFrames|DurationSeconds|CurrentFrame|IsReplaying|KeyName|PositionX|PositionY|EndPositionX|EndPositionY|HitGameObjectName|IsPlaying|IsPaused|ClearedLogCount|ClearedCounts" + +function Test-IsExcludedPath { + param([string] $Path) + + $parts = $Path -split [System.IO.Path]::DirectorySeparatorChar + foreach ($part in $parts) { + if ($excludedDirectories -contains $part) { + return $true + } + } + + return $false +} + +function Write-Candidate { + param( + [string] $Kind, + [string] $Path, + [int] $LineNumber, + [string] $Line + ) + + Write-Output "$Kind ${Path}:${LineNumber}: $Line" +} + +Get-ChildItem -LiteralPath $Root -Recurse -File | + Where-Object { + -not (Test-IsExcludedPath -Path $_.FullName) -and + ($_.Name -eq "SKILL.md" -or $targetExtensions -contains $_.Extension) + } | + ForEach-Object { + $path = $_.FullName + $lines = Get-Content -LiteralPath $path + for ($index = 0; $index -lt $lines.Count; $index++) { + $line = $lines[$index] + $lineNumber = $index + 1 + if ($line -match "uloop\s+[^#|;&]*--[A-Za-z0-9][A-Za-z0-9-]*(\s+|=)(true|false)(\s|$|[|;&])") { + Write-Candidate -Kind "ARG_BOOL" -Path $path -LineNumber $lineNumber -Line $line + } + if ($line -match "uloop\s+compile\s+[^#|;&]*--(wait-for-domain-reload|reload-external-scene-changes|force-recompile)(\s|=|$)") { + Write-Candidate -Kind "FIRST_PARTY_OPTION" -Path $path -LineNumber $lineNumber -Line $line + } + if ($line -match "uloop\s+run-tests\s+[^#|;&]*--save-before-run(\s|=|$)") { + Write-Candidate -Kind "FIRST_PARTY_OPTION" -Path $path -LineNumber $lineNumber -Line $line + } + if ($line -match "uloop\s+(record-input|replay-input)\s+[^#|;&]*--show-overlay(\s|=|$)") { + Write-Candidate -Kind "FIRST_PARTY_OPTION" -Path $path -LineNumber $lineNumber -Line $line + } + if ($line -match "uloop\s+get-hierarchy\s+[^#|;&]*--include-(components|inactive)(\s|=|$)") { + Write-Candidate -Kind "FIRST_PARTY_OPTION" -Path $path -LineNumber $lineNumber -Line $line + } + if ($line -match "uloop\s+execute-dynamic-code\s+[^#|;&]*--compile-only(\s|=|$)") { + Write-Candidate -Kind "FIRST_PARTY_OPTION" -Path $path -LineNumber $lineNumber -Line $line + } + if ($line -match "\.($outputFields)([^A-Za-z0-9_]|$)") { + Write-Candidate -Kind "OUTPUT_FIELD" -Path $path -LineNumber $lineNumber -Line $line + } + } + } diff --git a/Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill/scripts/detect-v3-cli-invocation-candidates.ps1.meta b/Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill/scripts/detect-v3-cli-invocation-candidates.ps1.meta new file mode 100644 index 0000000000..666ec1b336 --- /dev/null +++ b/Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill/scripts/detect-v3-cli-invocation-candidates.ps1.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: ed2b2018890040219507f0bbbba108a0 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill/scripts/detect-v3-cli-invocation-candidates.sh b/Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill/scripts/detect-v3-cli-invocation-candidates.sh new file mode 100755 index 0000000000..72077c08ee --- /dev/null +++ b/Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill/scripts/detect-v3-cli-invocation-candidates.sh @@ -0,0 +1,37 @@ +#!/bin/sh +set -eu + +root=${1:-.} + +find "$root" \ + \( -name .git -o -name Library -o -name Temp -o -name node_modules -o -name .agents -o -name .claude -o -name .codex -o -name .cursor -o -name .gemini -o -name .windsurf -o -name .agent \) -prune -o \ + -type f \( -name SKILL.md -o -name '*.md' -o -name '*.sh' -o -name '*.bash' -o -name '*.zsh' -o -name '*.ps1' -o -name '*.psm1' \) -print | +while IFS= read -r file +do + awk ' + function report(kind) { + printf "%s %s:%d: %s\n", kind, FILENAME, FNR, $0 + } + /uloop[[:space:]][^#|;&]*--[[:alnum:]][[:alnum:]-]*([[:space:]]+|=)(true|false)([[:space:]]|$|[|;&])/ { + report("ARG_BOOL") + } + /uloop[[:space:]]+compile[[:space:]][^#|;&]*--(wait-for-domain-reload|reload-external-scene-changes|force-recompile)([[:space:]]|=|$)/ { + report("FIRST_PARTY_OPTION") + } + /uloop[[:space:]]+run-tests[[:space:]][^#|;&]*--save-before-run([[:space:]]|=|$)/ { + report("FIRST_PARTY_OPTION") + } + /uloop[[:space:]]+(record-input|replay-input)[[:space:]][^#|;&]*--show-overlay([[:space:]]|=|$)/ { + report("FIRST_PARTY_OPTION") + } + /uloop[[:space:]]+get-hierarchy[[:space:]][^#|;&]*--include-(components|inactive)([[:space:]]|=|$)/ { + report("FIRST_PARTY_OPTION") + } + /uloop[[:space:]]+execute-dynamic-code[[:space:]][^#|;&]*--compile-only([[:space:]]|=|$)/ { + report("FIRST_PARTY_OPTION") + } + /\.(Success|Message|ErrorMessage|ErrorCount|WarningCount|TotalCount|DisplayedCount|LogType|StackTrace|XmlPath|TestCount|PassedCount|FailedCount|SkippedCount|CompletedAt|ScreenshotCount|Screenshots|CompilationErrors|ErrorCode|UpdatedCode|DiagnosticsSummary|OutputPath|InputPath|TotalFrames|DurationSeconds|CurrentFrame|IsReplaying|KeyName|PositionX|PositionY|EndPositionX|EndPositionY|HitGameObjectName|IsPlaying|IsPaused|ClearedLogCount|ClearedCounts)([^[:alnum:]_]|$)/ { + report("OUTPUT_FIELD") + } + ' "$file" +done diff --git a/Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill/scripts/detect-v3-cli-invocation-candidates.sh.meta b/Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill/scripts/detect-v3-cli-invocation-candidates.sh.meta new file mode 100644 index 0000000000..0753f0771a --- /dev/null +++ b/Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill/scripts/detect-v3-cli-invocation-candidates.sh.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 66729895939a4e81865297618946ad49 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/cli/internal/cli/skills.go b/cli/internal/cli/skills.go index 73497d106e..e972300fd6 100644 --- a/cli/internal/cli/skills.go +++ b/cli/internal/cli/skills.go @@ -13,6 +13,7 @@ const ( skillsCommandName = "skills" managedSkillsDir = "unity-cli-loop" skillFileName = "SKILL.md" + v3MigrationSkillName = "v3-cli-invocation-migration" uloopSettingsDir = ".uloop" toolSettingsFile = "settings.tools.json" manifestFileName = "manifest.json" @@ -123,6 +124,25 @@ func tryHandleSkillsRequest(args []string, startPath string, globalProjectPath s writeClassifiedError(stderr, err, errorContext{command: skillsCommandName}) return true, 1 } + + if isV3MigrationSkillSubcommand(subcommand) { + if len(options.targets) == 0 { + printSkillsTargetGuidance(subcommand, stdout) + return true, 0 + } + skills, err := collectV3MigrationSkillDefinition(projectRoot) + if err != nil { + writeClassifiedError(stderr, err, errorContext{projectRoot: projectRoot, command: skillsCommandName}) + return true, 1 + } + switch subcommand { + case "install-v3-migration": + return true, runV3MigrationSkillInstall(projectRoot, skills, options, stdout, stderr) + case "uninstall-v3-migration": + return true, runV3MigrationSkillUninstall(projectRoot, skills, options, stdout, stderr) + } + } + skills, err := collectSkillDefinitions(projectRoot) if err != nil { writeClassifiedError(stderr, err, errorContext{projectRoot: projectRoot, command: skillsCommandName}) @@ -178,7 +198,16 @@ func parseSkillsOptions(args []string) (skillCommandOptions, error) { func isKnownSkillsSubcommand(subcommand string) bool { switch subcommand { - case "list", "install", "uninstall": + case "list", "install", "uninstall", "install-v3-migration", "uninstall-v3-migration": + return true + default: + return false + } +} + +func isV3MigrationSkillSubcommand(subcommand string) bool { + switch subcommand { + case "install-v3-migration", "uninstall-v3-migration": return true default: return false diff --git a/cli/internal/cli/skills_discovery.go b/cli/internal/cli/skills_discovery.go index cab593e888..a58b02bf8f 100644 --- a/cli/internal/cli/skills_discovery.go +++ b/cli/internal/cli/skills_discovery.go @@ -1,6 +1,7 @@ package cli import ( + "errors" "os" "path/filepath" "sort" @@ -31,6 +32,27 @@ func collectSkillDefinitions(projectRoot string) ([]skillDefinition, error) { return skills, nil } +func collectV3MigrationSkillDefinition(projectRoot string) ([]skillDefinition, error) { + packageRoot := resolvePackageRoot(projectRoot) + if packageRoot == "" { + return nil, errors.New("unity CLI Loop package root was not found") + } + + skillDirectory := filepath.Join( + packageRoot, + "TemporarySkills", + v3MigrationSkillName, + "Skill") + skill, ok, err := readSkillDefinition(skillDirectory) + if err != nil { + return nil, err + } + if !ok { + return nil, errors.New("v3 CLI invocation migration skill source was not found") + } + return []skillDefinition{skill}, nil +} + func collectInternalSkillToolNames(projectRoot string) map[string]bool { toolNames := map[string]bool{} for _, sourceRoot := range enumerateSkillSourceRoots(projectRoot) { diff --git a/cli/internal/cli/skills_display.go b/cli/internal/cli/skills_display.go index 13409bb15a..c269af4d61 100644 --- a/cli/internal/cli/skills_display.go +++ b/cli/internal/cli/skills_display.go @@ -56,6 +56,8 @@ func printSkillsHelp(stdout io.Writer) { writeLine(stdout, " uloop skills list [options]") writeLine(stdout, " uloop skills install [options]") writeLine(stdout, " uloop skills uninstall [options]") + writeLine(stdout, " uloop skills install-v3-migration [options]") + writeLine(stdout, " uloop skills uninstall-v3-migration [options]") writeLine(stdout, "") printGlobalOptionsHelp(stdout) } @@ -80,6 +82,14 @@ func printSkillsSubcommandHelp(command string, stdout io.Writer) { writeLine(stdout, "even when their flag is omitted, so previously installed copies never go stale.") writeLine(stdout, "") } + if command == "install-v3-migration" { + writeLine(stdout, "Installs only the temporary V3 CLI invocation migration skill.") + writeLine(stdout, "") + } + if command == "uninstall-v3-migration" { + writeLine(stdout, "Removes only the temporary V3 CLI invocation migration skill.") + writeLine(stdout, "") + } printGlobalOptionsHelp(stdout) } diff --git a/cli/internal/cli/skills_test.go b/cli/internal/cli/skills_test.go index ec7c46d1a4..f2b342b54d 100644 --- a/cli/internal/cli/skills_test.go +++ b/cli/internal/cli/skills_test.go @@ -3,6 +3,7 @@ package cli import ( "bytes" "os" + "os/exec" "path/filepath" "reflect" "sort" @@ -120,6 +121,49 @@ name: uloop-cached-package } } +// Tests that the temporary V3 migration skill stays out of normal skill discovery. +func TestCollectSkillDefinitionsSkipsV3MigrationSkill(t *testing.T) { + projectRoot := t.TempDir() + writeTestSkill(t, projectRoot, "Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill", `--- +name: v3-cli-invocation-migration +--- + +# temporary migration +`) + + skills, err := collectSkillDefinitions(projectRoot) + if err != nil { + t.Fatalf("collectSkillDefinitions failed: %v", err) + } + + if len(skills) != 0 { + t.Fatalf("temporary migration skill should not be discovered normally: %#v", skills) + } +} + +// Tests that the dedicated V3 migration skill discovery reads only the temporary skill source. +func TestCollectV3MigrationSkillDefinitionReadsTemporarySkill(t *testing.T) { + projectRoot := t.TempDir() + writePackageRootMarker(t, projectRoot) + writeTestSkill(t, projectRoot, "Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill", `--- +name: v3-cli-invocation-migration +--- + +# temporary migration +`) + + skills, err := collectV3MigrationSkillDefinition(projectRoot) + if err != nil { + t.Fatalf("collectV3MigrationSkillDefinition failed: %v", err) + } + + actualNames := skillNames(skills) + expectedNames := []string{"v3-cli-invocation-migration"} + if !reflect.DeepEqual(actualNames, expectedNames) { + t.Fatalf("skill names mismatch:\nactual: %#v\nexpected: %#v", actualNames, expectedNames) + } +} + // Tests that CLI-only and project-local skills win over package-root duplicates. func TestCollectSkillDefinitionsUsesUnitySideSourcePrecedence(t *testing.T) { projectRoot := t.TempDir() @@ -824,6 +868,143 @@ name: uloop-sample } } +// Tests that the dedicated V3 migration install copies only the temporary skill. +func TestRunV3MigrationSkillInstallAndUninstall(t *testing.T) { + projectRoot := t.TempDir() + writePackageRootMarker(t, projectRoot) + writeTestSkill(t, projectRoot, "Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill", `--- +name: v3-cli-invocation-migration +--- + +# temporary migration +`) + if err := os.MkdirAll( + filepath.Join(projectRoot, "Packages", "src", "TemporarySkills", "v3-cli-invocation-migration", "Skill", "scripts"), + 0o755); err != nil { + t.Fatalf("failed to create script dir: %v", err) + } + if err := os.WriteFile( + filepath.Join(projectRoot, "Packages", "src", "TemporarySkills", "v3-cli-invocation-migration", "Skill", "scripts", "detect.sh"), + []byte("#!/bin/sh\n"), + 0o644); err != nil { + t.Fatalf("failed to write script: %v", err) + } + skills, err := collectV3MigrationSkillDefinition(projectRoot) + if err != nil { + t.Fatalf("collectV3MigrationSkillDefinition failed: %v", err) + } + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + options := skillCommandOptions{targets: []skillTarget{targetConfigs["codex"]}} + + installCode := runV3MigrationSkillInstall(projectRoot, skills, options, stdout, stderr) + + if installCode != 0 { + t.Fatalf("install should succeed: code=%d stderr=%s", installCode, stderr.String()) + } + installedDir := filepath.Join(projectRoot, ".codex", "skills", "v3-cli-invocation-migration") + if _, err := os.Stat(filepath.Join(installedDir, "SKILL.md")); err != nil { + t.Fatalf("migration skill should be installed: %v", err) + } + if _, err := os.Stat(filepath.Join(installedDir, "scripts", "detect.sh")); err != nil { + t.Fatalf("migration skill script should be installed: %v", err) + } + unrelatedSkillDir := filepath.Join(projectRoot, ".codex", "skills", "uloop-compile") + writeSkillFile(t, unrelatedSkillDir, "---\nname: uloop-compile\n---\n") + + uninstallCode := runV3MigrationSkillUninstall(projectRoot, skills, options, stdout, stderr) + + if uninstallCode != 0 { + t.Fatalf("uninstall should succeed: code=%d stderr=%s", uninstallCode, stderr.String()) + } + if _, err := os.Stat(installedDir); !os.IsNotExist(err) { + t.Fatalf("migration skill should be removed: %v", err) + } + if _, err := os.Stat(unrelatedSkillDir); err != nil { + t.Fatalf("unrelated skill should remain: %v", err) + } +} + +// Tests that the POSIX detector reports boolean, first-party, and output-field candidates. +func TestV3MigrationSkillPosixDetectorReportsCandidates(t *testing.T) { + fixtureRoot := t.TempDir() + fixtureFile := filepath.Join(fixtureRoot, "docs", "SKILL.md") + if err := os.MkdirAll(filepath.Dir(fixtureFile), 0o755); err != nil { + t.Fatalf("failed to create fixture dir: %v", err) + } + fixtureContent := strings.Join([]string{ + "uloop compile --wait-for-domain-reload true", + "uloop some-tool --enabled false", + "jq '.Success'", + }, "\n") + if err := os.WriteFile(fixtureFile, []byte(fixtureContent), 0o644); err != nil { + t.Fatalf("failed to write fixture: %v", err) + } + scriptPath := filepath.Join( + findRepositoryRootForSkillsTest(t), + "Packages", + "src", + "TemporarySkills", + "v3-cli-invocation-migration", + "Skill", + "scripts", + "detect-v3-cli-invocation-candidates.sh") + + command := exec.Command("sh", scriptPath, fixtureRoot) + output, err := command.CombinedOutput() + if err != nil { + t.Fatalf("detector failed: %v\n%s", err, string(output)) + } + text := string(output) + for _, expected := range []string{"FIRST_PARTY_OPTION", "ARG_BOOL", "OUTPUT_FIELD"} { + if !strings.Contains(text, expected) { + t.Fatalf("detector output missing %s:\n%s", expected, text) + } + } +} + +// Tests that the PowerShell detector reports candidates when PowerShell is available. +func TestV3MigrationSkillPowerShellDetectorReportsCandidates(t *testing.T) { + pwshPath, err := exec.LookPath("pwsh") + if err != nil { + t.Skip("pwsh is not installed") + } + fixtureRoot := t.TempDir() + fixtureFile := filepath.Join(fixtureRoot, "scripts", "migrate.ps1") + if err := os.MkdirAll(filepath.Dir(fixtureFile), 0o755); err != nil { + t.Fatalf("failed to create fixture dir: %v", err) + } + fixtureContent := strings.Join([]string{ + "uloop run-tests --save-before-run false", + "uloop some-tool --enabled true", + "$result.Success", + }, "\n") + if err := os.WriteFile(fixtureFile, []byte(fixtureContent), 0o644); err != nil { + t.Fatalf("failed to write fixture: %v", err) + } + scriptPath := filepath.Join( + findRepositoryRootForSkillsTest(t), + "Packages", + "src", + "TemporarySkills", + "v3-cli-invocation-migration", + "Skill", + "scripts", + "detect-v3-cli-invocation-candidates.ps1") + + command := exec.Command(pwshPath, "-NoProfile", "-File", scriptPath, "-Root", fixtureRoot) + output, err := command.CombinedOutput() + if err != nil { + t.Fatalf("detector failed: %v\n%s", err, string(output)) + } + text := string(output) + for _, expected := range []string{"FIRST_PARTY_OPTION", "ARG_BOOL", "OUTPUT_FIELD"} { + if !strings.Contains(text, expected) { + t.Fatalf("detector output missing %s:\n%s", expected, text) + } + } +} + func writeTestSkill(t *testing.T, projectRoot string, relativeDir string, content string) { t.Helper() writeSkillFile(t, filepath.Join(projectRoot, filepath.FromSlash(relativeDir)), content) @@ -840,6 +1021,14 @@ func writeManifest(t *testing.T, projectRoot string, content string) { } } +func writePackageRootMarker(t *testing.T, projectRoot string) { + t.Helper() + markerPath := filepath.Join(projectRoot, "Packages", "src", "Editor", "FirstPartyTools") + if err := os.MkdirAll(markerPath, 0o755); err != nil { + t.Fatalf("failed to create package root marker: %v", err) + } +} + func writeToolSettings(t *testing.T, projectRoot string, content string) { t.Helper() settingsDir := filepath.Join(projectRoot, ".uloop") @@ -902,3 +1091,21 @@ func assertSkillContentContains(t *testing.T, skills []skillDefinition, skillNam } t.Fatalf("skill not found: %s", skillName) } + +func findRepositoryRootForSkillsTest(t *testing.T) string { + t.Helper() + currentDir, err := os.Getwd() + if err != nil { + t.Fatalf("failed to get working directory: %v", err) + } + for { + if _, err := os.Stat(filepath.Join(currentDir, ".git")); err == nil { + return currentDir + } + parentDir := filepath.Dir(currentDir) + if parentDir == currentDir { + t.Fatal("repository root not found") + } + currentDir = parentDir + } +} diff --git a/cli/internal/cli/skills_v3_migration.go b/cli/internal/cli/skills_v3_migration.go new file mode 100644 index 0000000000..8f87baec63 --- /dev/null +++ b/cli/internal/cli/skills_v3_migration.go @@ -0,0 +1,119 @@ +package cli + +import ( + "io" + "os" + "path/filepath" +) + +func runV3MigrationSkillInstall(projectRoot string, skills []skillDefinition, options skillCommandOptions, stdout io.Writer, stderr io.Writer) int { + writeLine(stdout, "") + writeFormat(stdout, "Installing V3 CLI invocation migration skill (%s)...\n", skillLocationName(options.global)) + writeLine(stdout, "") + for _, target := range options.targets { + result, err := installV3MigrationSkillForTarget(projectRoot, target, skills, options.global, groupManagedSkillsForOptions(options)) + if err != nil { + writeClassifiedError(stderr, err, errorContext{projectRoot: projectRoot, command: skillsCommandName}) + return 1 + } + writeFormat(stdout, "%s:\n", target.displayName) + writeFormat(stdout, " Installed: %d\n", result.installed) + writeFormat(stdout, " Updated: %d\n", result.updated) + writeFormat(stdout, " Skipped: %d\n", result.skipped) + baseDir, err := getSkillsBaseDir(projectRoot, target, options.global) + if err != nil { + writeClassifiedError(stderr, err, errorContext{projectRoot: projectRoot, command: skillsCommandName}) + return 1 + } + writeFormat(stdout, " Location: %s\n\n", baseDir) + } + return 0 +} + +func runV3MigrationSkillUninstall(projectRoot string, skills []skillDefinition, options skillCommandOptions, stdout io.Writer, stderr io.Writer) int { + writeLine(stdout, "") + writeFormat(stdout, "Uninstalling V3 CLI invocation migration skill (%s)...\n", skillLocationName(options.global)) + writeLine(stdout, "") + for _, target := range options.targets { + grouped := groupManagedSkillsForOptions(options) + removed, notFound, err := uninstallV3MigrationSkillForTarget(projectRoot, target, skills, options.global, grouped) + if err != nil { + writeClassifiedError(stderr, err, errorContext{projectRoot: projectRoot, command: skillsCommandName}) + return 1 + } + writeFormat(stdout, "%s:\n", target.displayName) + writeFormat(stdout, " Removed: %d\n", removed) + writeFormat(stdout, " Not found: %d\n", notFound) + baseDir, err := getSkillsBaseDir(projectRoot, target, options.global) + if err != nil { + writeClassifiedError(stderr, err, errorContext{projectRoot: projectRoot, command: skillsCommandName}) + return 1 + } + writeFormat(stdout, " Location: %s\n\n", baseDir) + } + return 0 +} + +func installV3MigrationSkillForTarget(projectRoot string, target skillTarget, skills []skillDefinition, global bool, grouped bool) (skillInstallResult, error) { + result := skillInstallResult{} + baseDir, err := getSkillsBaseDir(projectRoot, target, global) + if err != nil { + return skillInstallResult{}, err + } + for _, skill := range skills { + status, err := getSkillStatus(baseDir, skill, grouped) + if err != nil { + return skillInstallResult{}, err + } + destinationDir := getPreferredSkillDir(baseDir, skill.name, grouped) + if status == "installed" { + result.skipped++ + continue + } + if err := syncSkillDirectory(skill.sourceDirectory, destinationDir); err != nil { + return skillInstallResult{}, err + } + alternateDir := getPreferredSkillDir(baseDir, skill.name, !grouped) + if err := os.RemoveAll(alternateDir); err != nil { + return skillInstallResult{}, err + } + if status == "outdated" { + result.updated++ + continue + } + result.installed++ + } + return result, nil +} + +func uninstallV3MigrationSkillForTarget(projectRoot string, target skillTarget, skills []skillDefinition, global bool, grouped bool) (int, int, error) { + removed := 0 + notFound := 0 + baseDir, err := getSkillsBaseDir(projectRoot, target, global) + if err != nil { + return removed, notFound, err + } + for _, skill := range skills { + destinationDir := getPreferredSkillDir(baseDir, skill.name, grouped) + if _, err := os.Stat(destinationDir); err != nil { + if !os.IsNotExist(err) { + return removed, notFound, err + } + notFound++ + continue + } + if err := os.RemoveAll(destinationDir); err != nil { + return removed, notFound, err + } + removeEmptyMigrationSkillParent(baseDir, grouped) + removed++ + } + return removed, notFound, nil +} + +func removeEmptyMigrationSkillParent(baseDir string, grouped bool) { + if !grouped { + return + } + _ = removeEmptyDir(filepath.Join(baseDir, managedSkillsDir)) +} From c75c6e181ad84cf014dc2d0863a182c43126a978 Mon Sep 17 00:00:00 2001 From: hatayama Date: Sun, 21 Jun 2026 00:38:23 +0900 Subject: [PATCH 02/21] Separate migration wizard sections Clarify the wizard by separating mechanical C# source structure migration from the temporary AI skill used for SKILL.md and script updates. --- ...hirdPartyToolMigrationWizardWindowTests.cs | 8 ++-- .../Presentation/Setup/SetupWizardWindow.uss | 12 +++++ .../ThirdPartyToolMigrationWizardText.cs | 16 +++++-- .../ThirdPartyToolMigrationWizardView.cs | 48 ++++++++++++++----- 4 files changed, 62 insertions(+), 22 deletions(-) diff --git a/Assets/Tests/Editor/ThirdPartyToolMigrationWizardWindowTests.cs b/Assets/Tests/Editor/ThirdPartyToolMigrationWizardWindowTests.cs index dc61b08fe6..95c3190899 100644 --- a/Assets/Tests/Editor/ThirdPartyToolMigrationWizardWindowTests.cs +++ b/Assets/Tests/Editor/ThirdPartyToolMigrationWizardWindowTests.cs @@ -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( @@ -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] @@ -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")] diff --git a/Packages/src/Editor/Presentation/Setup/SetupWizardWindow.uss b/Packages/src/Editor/Presentation/Setup/SetupWizardWindow.uss index 3165efd40a..88060a3b86 100644 --- a/Packages/src/Editor/Presentation/Setup/SetupWizardWindow.uss +++ b/Packages/src/Editor/Presentation/Setup/SetupWizardWindow.uss @@ -111,6 +111,12 @@ white-space: normal; } +.setup-step__description-label { + color: var(--setup-color-muted); + white-space: normal; + margin-bottom: 6px; +} + .setup-progress-bar { height: 14px; margin-top: 6px; @@ -333,6 +339,12 @@ flex-basis: 0; } +.setup-step__button-row .setup-dropdown { + flex-grow: 1; + flex-basis: 0; + margin-right: 6px; +} + /* =========================================== Utility: Hidden =========================================== */ diff --git a/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardText.cs b/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardText.cs index 11b167ed92..f112484067 100644 --- a/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardText.cs +++ b/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardText.cs @@ -9,10 +9,16 @@ namespace io.github.hatayama.UnityCliLoop.Presentation /// internal static class ThirdPartyToolMigrationWizardText { - internal const string MigrationNotCheckedText = "Migration status has not been checked."; - internal const string NoMigrationTargetsText = "No V3 custom tool migration is needed."; - private const string MigrationCheckingText = "Scanning project for V3 custom tool migration..."; - private const string MigrationApplyingText = "Migrating project files for V3 custom tools..."; + internal const string CSharpMigrationSectionTitle = "C# Source Structure Migration"; + internal const string AiMigrationSkillSectionTitle = "AI Skill and Script Migration"; + internal const string AiMigrationSkillDescriptionText = + "Install a temporary AI skill for updating SKILL.md, Markdown, shell scripts, and PowerShell scripts " + + "that call uloop.\nThe skill gives your AI agent a checklist and detection scripts; this window only " + + "installs or removes it."; + internal const string MigrationNotCheckedText = "C# source migration status has not been checked."; + internal const string NoMigrationTargetsText = "No C# source structure migration is needed."; + private const string MigrationCheckingText = "Scanning C# source files for V3 custom tool API migration..."; + private const string MigrationApplyingText = "Migrating C# source files to V3 custom tool APIs..."; private const string MigrationButtonReadyText = "Migrate"; private const string MigrationButtonMigratingText = "Migrating..."; private const string MigrationButtonNoTargetsText = "Nothing to migrate"; @@ -30,7 +36,7 @@ internal static string GetMigrationStatusText(int fileCount) string subject = fileCount == 1 ? "this file still uses" : "these files still use"; string objectPronoun = fileCount == 1 ? "it" : "them"; - return $"{fileCount} {noun} {verb} V3 custom tool migration.\n" + + return $"{fileCount} {noun} {verb} V3 C# source structure migration.\n" + $"The Unity Console is showing errors because {subject} the old custom tool API.\n\n" + $"Click Migrate to update {objectPronoun} automatically. " + "The errors should disappear after migration."; diff --git a/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardView.cs b/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardView.cs index c3c4819371..9245bf3829 100644 --- a/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardView.cs +++ b/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardView.cs @@ -80,13 +80,14 @@ internal static ThirdPartyToolMigrationWizardView Create( root.styleSheets.Add(styleSheet); ScrollView mainScrollView = CreateMainScrollView(root); - VisualElement content = CreateMigrationSection(mainScrollView); + VisualElement content = CreateCSharpMigrationSection(mainScrollView); Label migrationStatusLabel = CreateStatusLabel(content); ProgressBar migrationProgressBar = CreateProgressBar(content); VisualElement migrationButtonRow = CreateMigrationButtonRow(content); Button migrateButton = CreateMigrateButton(migrationButtonRow); - (EnumField migrationSkillTargetField, Button migrationSkillButton, Button refreshButton, Button closeButton) = - CreateFooter(mainScrollView); + (EnumField migrationSkillTargetField, Button migrationSkillButton) = + CreateAiMigrationSkillSection(mainScrollView); + (Button refreshButton, Button closeButton) = CreateFooter(mainScrollView); ThirdPartyToolMigrationWizardView view = new ThirdPartyToolMigrationWizardView( mainScrollView, @@ -186,14 +187,14 @@ private static ScrollView CreateMainScrollView(VisualElement root) return mainScrollView; } - private static VisualElement CreateMigrationSection(ScrollView mainScrollView) + private static VisualElement CreateCSharpMigrationSection(ScrollView mainScrollView) { VisualElement migrationSection = new VisualElement(); migrationSection.AddToClassList("setup-step"); migrationSection.AddToClassList("setup-step--migration-alert"); mainScrollView.Add(migrationSection); - Label titleLabel = new Label("Custom Tool Migration"); + Label titleLabel = new Label(ThirdPartyToolMigrationWizardText.CSharpMigrationSectionTitle); titleLabel.AddToClassList("setup-step__title"); migrationSection.Add(titleLabel); @@ -240,18 +241,31 @@ private static Button CreateMigrateButton(VisualElement migrationButtonRow) return migrateButton; } - private static (EnumField migrationSkillTargetField, Button migrationSkillButton, Button refreshButton, Button closeButton) - CreateFooter(ScrollView mainScrollView) + private static (EnumField migrationSkillTargetField, Button migrationSkillButton) + CreateAiMigrationSkillSection(ScrollView mainScrollView) { - VisualElement footer = new VisualElement(); - footer.AddToClassList("setup-footer"); - mainScrollView.Add(footer); + VisualElement migrationSkillSection = new VisualElement(); + migrationSkillSection.AddToClassList("setup-step"); + mainScrollView.Add(migrationSkillSection); + + Label titleLabel = new Label(ThirdPartyToolMigrationWizardText.AiMigrationSkillSectionTitle); + titleLabel.AddToClassList("setup-step__title"); + migrationSkillSection.Add(titleLabel); + + VisualElement content = new VisualElement(); + content.AddToClassList("setup-step__content"); + migrationSkillSection.Add(content); + + Label descriptionLabel = new Label(ThirdPartyToolMigrationWizardText.AiMigrationSkillDescriptionText); + descriptionLabel.AddToClassList("setup-step__description-label"); + content.Add(descriptionLabel); VisualElement migrationSkillRow = new VisualElement(); - migrationSkillRow.AddToClassList("setup-footer__button-row"); - footer.Add(migrationSkillRow); + migrationSkillRow.AddToClassList("setup-step__button-row"); + content.Add(migrationSkillRow); EnumField migrationSkillTargetField = new EnumField(SkillsTarget.Claude); + migrationSkillTargetField.label = "Install target"; migrationSkillTargetField.AddToClassList("setup-dropdown"); migrationSkillRow.Add(migrationSkillTargetField); @@ -261,6 +275,14 @@ private static (EnumField migrationSkillTargetField, Button migrationSkillButton SkillInstallState.Missing); migrationSkillButton.AddToClassList("setup-button"); migrationSkillRow.Add(migrationSkillButton); + return (migrationSkillTargetField, migrationSkillButton); + } + + private static (Button refreshButton, Button closeButton) CreateFooter(ScrollView mainScrollView) + { + VisualElement footer = new VisualElement(); + footer.AddToClassList("setup-footer"); + mainScrollView.Add(footer); VisualElement footerButtonRow = new VisualElement(); footerButtonRow.AddToClassList("setup-footer__button-row"); @@ -282,7 +304,7 @@ private static (EnumField migrationSkillTargetField, Button migrationSkillButton "Window > Unity CLI Loop > Custom Tool Migration."); reopenHintLabel.AddToClassList("setup-footer__hint-label"); footer.Add(reopenHintLabel); - return (migrationSkillTargetField, migrationSkillButton, refreshButton, closeButton); + return (refreshButton, closeButton); } private void BindEvents( From b48b309cadb7e67af5d6d07889648b77c0f807cd Mon Sep 17 00:00:00 2001 From: hatayama Date: Sun, 21 Jun 2026 00:44:46 +0900 Subject: [PATCH 03/21] Refine migration wizard section actions Keep C# migration controls with the C# section, and move AI skill install controls outside the explanatory panel so the two migration paths read as distinct sections. --- .../Presentation/Setup/SetupWizardWindow.uss | 40 +++++++++-- .../ThirdPartyToolMigrationWizardView.cs | 69 ++++++++++++------- 2 files changed, 78 insertions(+), 31 deletions(-) diff --git a/Packages/src/Editor/Presentation/Setup/SetupWizardWindow.uss b/Packages/src/Editor/Presentation/Setup/SetupWizardWindow.uss index 88060a3b86..230e7ec859 100644 --- a/Packages/src/Editor/Presentation/Setup/SetupWizardWindow.uss +++ b/Packages/src/Editor/Presentation/Setup/SetupWizardWindow.uss @@ -112,7 +112,6 @@ } .setup-step__description-label { - color: var(--setup-color-muted); white-space: normal; margin-bottom: 6px; } @@ -293,6 +292,39 @@ flex-basis: 0; } +.setup-section-actions { + margin-top: -4px; + margin-bottom: 8px; +} + +.setup-section-actions__button-row { + flex-direction: row; +} + +.setup-section-actions__button-row .setup-button { + flex-grow: 1; + flex-basis: 0; +} + +.setup-section-actions__field-row { + flex-direction: row; + align-items: center; + margin-bottom: 2px; +} + +.setup-section-actions__field-label { + width: 76px; + flex-shrink: 0; + margin-right: 6px; + color: var(--setup-color-muted); +} + +.setup-section-actions__field { + flex-grow: 1; + flex-shrink: 1; + min-width: 0; +} + .setup-footer__github-link { flex-direction: row; justify-content: flex-end; @@ -339,12 +371,6 @@ flex-basis: 0; } -.setup-step__button-row .setup-dropdown { - flex-grow: 1; - flex-basis: 0; - margin-right: 6px; -} - /* =========================================== Utility: Hidden =========================================== */ diff --git a/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardView.cs b/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardView.cs index 9245bf3829..fa99b89ee2 100644 --- a/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardView.cs +++ b/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardView.cs @@ -85,9 +85,10 @@ internal static ThirdPartyToolMigrationWizardView Create( ProgressBar migrationProgressBar = CreateProgressBar(content); VisualElement migrationButtonRow = CreateMigrationButtonRow(content); Button migrateButton = CreateMigrateButton(migrationButtonRow); + (Button refreshButton, Button closeButton) = CreateCSharpMigrationActionSection(mainScrollView); (EnumField migrationSkillTargetField, Button migrationSkillButton) = CreateAiMigrationSkillSection(mainScrollView); - (Button refreshButton, Button closeButton) = CreateFooter(mainScrollView); + CreateFooter(mainScrollView); ThirdPartyToolMigrationWizardView view = new ThirdPartyToolMigrationWizardView( mainScrollView, @@ -241,6 +242,30 @@ private static Button CreateMigrateButton(VisualElement migrationButtonRow) return migrateButton; } + private static (Button refreshButton, Button closeButton) CreateCSharpMigrationActionSection( + ScrollView mainScrollView) + { + VisualElement actionSection = new VisualElement(); + actionSection.AddToClassList("setup-section-actions"); + mainScrollView.Add(actionSection); + + VisualElement buttonRow = new VisualElement(); + buttonRow.AddToClassList("setup-section-actions__button-row"); + actionSection.Add(buttonRow); + + Button refreshButton = new Button(); + refreshButton.text = "Check"; + refreshButton.AddToClassList("setup-button"); + refreshButton.AddToClassList("setup-button--primary"); + buttonRow.Add(refreshButton); + + Button closeButton = new Button(); + closeButton.text = "Close"; + closeButton.AddToClassList("setup-button"); + buttonRow.Add(closeButton); + return (refreshButton, closeButton); + } + private static (EnumField migrationSkillTargetField, Button migrationSkillButton) CreateAiMigrationSkillSection(ScrollView mainScrollView) { @@ -260,51 +285,47 @@ private static (EnumField migrationSkillTargetField, Button migrationSkillButton descriptionLabel.AddToClassList("setup-step__description-label"); content.Add(descriptionLabel); - VisualElement migrationSkillRow = new VisualElement(); - migrationSkillRow.AddToClassList("setup-step__button-row"); - content.Add(migrationSkillRow); + VisualElement actionSection = new VisualElement(); + actionSection.AddToClassList("setup-section-actions"); + mainScrollView.Add(actionSection); + + VisualElement targetRow = new VisualElement(); + targetRow.AddToClassList("setup-section-actions__field-row"); + actionSection.Add(targetRow); + + Label targetLabel = new Label("Install target"); + targetLabel.AddToClassList("setup-section-actions__field-label"); + targetRow.Add(targetLabel); EnumField migrationSkillTargetField = new EnumField(SkillsTarget.Claude); - migrationSkillTargetField.label = "Install target"; migrationSkillTargetField.AddToClassList("setup-dropdown"); - migrationSkillRow.Add(migrationSkillTargetField); + migrationSkillTargetField.AddToClassList("setup-section-actions__field"); + targetRow.Add(migrationSkillTargetField); + + VisualElement buttonRow = new VisualElement(); + buttonRow.AddToClassList("setup-section-actions__button-row"); + actionSection.Add(buttonRow); Button migrationSkillButton = new Button(); migrationSkillButton.text = ThirdPartyToolMigrationWizardText.GetMigrationSkillButtonText( false, SkillInstallState.Missing); migrationSkillButton.AddToClassList("setup-button"); - migrationSkillRow.Add(migrationSkillButton); + buttonRow.Add(migrationSkillButton); return (migrationSkillTargetField, migrationSkillButton); } - private static (Button refreshButton, Button closeButton) CreateFooter(ScrollView mainScrollView) + private static void CreateFooter(ScrollView mainScrollView) { VisualElement footer = new VisualElement(); footer.AddToClassList("setup-footer"); mainScrollView.Add(footer); - VisualElement footerButtonRow = new VisualElement(); - footerButtonRow.AddToClassList("setup-footer__button-row"); - footer.Add(footerButtonRow); - - Button refreshButton = new Button(); - refreshButton.text = "Check"; - refreshButton.AddToClassList("setup-button"); - refreshButton.AddToClassList("setup-button--primary"); - footerButtonRow.Add(refreshButton); - - Button closeButton = new Button(); - closeButton.text = "Close"; - closeButton.AddToClassList("setup-button"); - footerButtonRow.Add(closeButton); - Label reopenHintLabel = new Label( "You can close this wizard and reopen it later from\n" + "Window > Unity CLI Loop > Custom Tool Migration."); reopenHintLabel.AddToClassList("setup-footer__hint-label"); footer.Add(reopenHintLabel); - return (refreshButton, closeButton); } private void BindEvents( From 0f6b3a75cab9e2141df8b7caf4ed5707eb038fef Mon Sep 17 00:00:00 2001 From: hatayama Date: Sun, 21 Jun 2026 00:48:00 +0900 Subject: [PATCH 04/21] Clarify migration wizard section separation Align the C# and AI migration sections visually, add C# migration guidance, and separate the two paths with a divider so the wizard reads as two distinct workflows. --- .../Presentation/Setup/SetupWizardWindow.uss | 26 ++++++++----------- .../ThirdPartyToolMigrationWizardText.cs | 3 +++ .../ThirdPartyToolMigrationWizardView.cs | 14 +++++++++- 3 files changed, 27 insertions(+), 16 deletions(-) diff --git a/Packages/src/Editor/Presentation/Setup/SetupWizardWindow.uss b/Packages/src/Editor/Presentation/Setup/SetupWizardWindow.uss index 230e7ec859..91d22601f0 100644 --- a/Packages/src/Editor/Presentation/Setup/SetupWizardWindow.uss +++ b/Packages/src/Editor/Presentation/Setup/SetupWizardWindow.uss @@ -78,24 +78,13 @@ .setup-step__title { -unity-font-style: bold; font-size: 13px; - margin-bottom: 6px; + margin-bottom: 10px; } .setup-step__content { padding-left: 4px; } -.setup-step--migration-alert { - background-color: #5c4a00; - border-color: #7a6400; - border-width: 2px; -} - -.setup-step--migration-alert .setup-step__title, -.setup-step--migration-alert .setup-step__status-label { - color: var(--setup-color-warning); -} - .setup-step__status-row { flex-direction: row; align-items: center; @@ -113,7 +102,7 @@ .setup-step__description-label { white-space: normal; - margin-bottom: 6px; + margin-bottom: 10px; } .setup-progress-bar { @@ -293,8 +282,8 @@ } .setup-section-actions { - margin-top: -4px; - margin-bottom: 8px; + margin-top: -2px; + margin-bottom: 10px; } .setup-section-actions__button-row { @@ -325,6 +314,13 @@ min-width: 0; } +.setup-section-divider { + border-top-width: 1px; + border-top-color: rgba(255, 255, 255, 0.18); + margin-top: 4px; + margin-bottom: 14px; +} + .setup-footer__github-link { flex-direction: row; justify-content: flex-end; diff --git a/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardText.cs b/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardText.cs index f112484067..e9b5f43d23 100644 --- a/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardText.cs +++ b/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardText.cs @@ -10,6 +10,9 @@ namespace io.github.hatayama.UnityCliLoop.Presentation internal static class ThirdPartyToolMigrationWizardText { internal const string CSharpMigrationSectionTitle = "C# Source Structure Migration"; + internal const string CSharpMigrationDescriptionText = + "Use this for C# custom tool source files that still use the V2 API. This section scans the Unity " + + "project and can rewrite matching C# files automatically."; internal const string AiMigrationSkillSectionTitle = "AI Skill and Script Migration"; internal const string AiMigrationSkillDescriptionText = "Install a temporary AI skill for updating SKILL.md, Markdown, shell scripts, and PowerShell scripts " + diff --git a/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardView.cs b/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardView.cs index fa99b89ee2..0855d32588 100644 --- a/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardView.cs +++ b/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardView.cs @@ -86,6 +86,7 @@ internal static ThirdPartyToolMigrationWizardView Create( VisualElement migrationButtonRow = CreateMigrationButtonRow(content); Button migrateButton = CreateMigrateButton(migrationButtonRow); (Button refreshButton, Button closeButton) = CreateCSharpMigrationActionSection(mainScrollView); + CreateSectionDivider(mainScrollView); (EnumField migrationSkillTargetField, Button migrationSkillButton) = CreateAiMigrationSkillSection(mainScrollView); CreateFooter(mainScrollView); @@ -192,7 +193,6 @@ private static VisualElement CreateCSharpMigrationSection(ScrollView mainScrollV { VisualElement migrationSection = new VisualElement(); migrationSection.AddToClassList("setup-step"); - migrationSection.AddToClassList("setup-step--migration-alert"); mainScrollView.Add(migrationSection); Label titleLabel = new Label(ThirdPartyToolMigrationWizardText.CSharpMigrationSectionTitle); @@ -202,6 +202,10 @@ private static VisualElement CreateCSharpMigrationSection(ScrollView mainScrollV VisualElement content = new VisualElement(); content.AddToClassList("setup-step__content"); migrationSection.Add(content); + + Label descriptionLabel = new Label(ThirdPartyToolMigrationWizardText.CSharpMigrationDescriptionText); + descriptionLabel.AddToClassList("setup-step__description-label"); + content.Add(descriptionLabel); return content; } @@ -266,6 +270,13 @@ private static (Button refreshButton, Button closeButton) CreateCSharpMigrationA return (refreshButton, closeButton); } + private static void CreateSectionDivider(ScrollView mainScrollView) + { + VisualElement divider = new VisualElement(); + divider.AddToClassList("setup-section-divider"); + mainScrollView.Add(divider); + } + private static (EnumField migrationSkillTargetField, Button migrationSkillButton) CreateAiMigrationSkillSection(ScrollView mainScrollView) { @@ -311,6 +322,7 @@ private static (EnumField migrationSkillTargetField, Button migrationSkillButton false, SkillInstallState.Missing); migrationSkillButton.AddToClassList("setup-button"); + migrationSkillButton.AddToClassList("setup-button--primary"); buttonRow.Add(migrationSkillButton); return (migrationSkillTargetField, migrationSkillButton); } From c4f4b7709e14a3be2f7dbccb159c9449b07d3057 Mon Sep 17 00:00:00 2001 From: hatayama Date: Sun, 21 Jun 2026 00:52:18 +0900 Subject: [PATCH 05/21] Make migration wizard text selectable Use read-only selectable text fields for wizard copy while preserving the existing label-style presentation. --- .../Presentation/Setup/SetupWizardWindow.uss | 26 ++++++ .../ThirdPartyToolMigrationWizardView.cs | 93 +++++++++++-------- 2 files changed, 82 insertions(+), 37 deletions(-) diff --git a/Packages/src/Editor/Presentation/Setup/SetupWizardWindow.uss b/Packages/src/Editor/Presentation/Setup/SetupWizardWindow.uss index 91d22601f0..a43e388ddc 100644 --- a/Packages/src/Editor/Presentation/Setup/SetupWizardWindow.uss +++ b/Packages/src/Editor/Presentation/Setup/SetupWizardWindow.uss @@ -85,6 +85,32 @@ padding-left: 4px; } +.setup-selectable-text { + background-color: rgba(0, 0, 0, 0); + border-top-width: 0; + border-right-width: 0; + border-bottom-width: 0; + border-left-width: 0; + padding-top: 0; + padding-right: 0; + padding-bottom: 0; + padding-left: 0; +} + +.setup-selectable-text .unity-base-text-field__input, +.setup-selectable-text .unity-text-field__input { + background-color: rgba(0, 0, 0, 0); + border-top-width: 0; + border-right-width: 0; + border-bottom-width: 0; + border-left-width: 0; + padding-top: 0; + padding-right: 0; + padding-bottom: 0; + padding-left: 0; + white-space: normal; +} + .setup-step__status-row { flex-direction: row; align-items: center; diff --git a/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardView.cs b/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardView.cs index 0855d32588..8bc5215907 100644 --- a/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardView.cs +++ b/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardView.cs @@ -16,7 +16,7 @@ internal sealed class ThirdPartyToolMigrationWizardView { private const string USS_RELATIVE_PATH = "Editor/Presentation/Setup/SetupWizardWindow.uss"; - private readonly Label _migrationStatusLabel; + private readonly TextField _migrationStatusTextField; private readonly ProgressBar _migrationProgressBar; private readonly VisualElement _migrationButtonRow; private readonly Button _migrateButton; @@ -27,7 +27,7 @@ internal sealed class ThirdPartyToolMigrationWizardView private ThirdPartyToolMigrationWizardView( ScrollView mainScrollView, - Label migrationStatusLabel, + TextField migrationStatusTextField, ProgressBar migrationProgressBar, VisualElement migrationButtonRow, Button migrateButton, @@ -37,7 +37,7 @@ private ThirdPartyToolMigrationWizardView( Button closeButton) { Debug.Assert(mainScrollView != null, "mainScrollView must not be null"); - Debug.Assert(migrationStatusLabel != null, "migrationStatusLabel must not be null"); + Debug.Assert(migrationStatusTextField != null, "migrationStatusTextField must not be null"); Debug.Assert(migrationProgressBar != null, "migrationProgressBar must not be null"); Debug.Assert(migrationButtonRow != null, "migrationButtonRow must not be null"); Debug.Assert(migrateButton != null, "migrateButton must not be null"); @@ -47,7 +47,7 @@ private ThirdPartyToolMigrationWizardView( Debug.Assert(closeButton != null, "closeButton must not be null"); MainScrollView = mainScrollView; - _migrationStatusLabel = migrationStatusLabel; + _migrationStatusTextField = migrationStatusTextField; _migrationProgressBar = migrationProgressBar; _migrationButtonRow = migrationButtonRow; _migrateButton = migrateButton; @@ -81,7 +81,7 @@ internal static ThirdPartyToolMigrationWizardView Create( ScrollView mainScrollView = CreateMainScrollView(root); VisualElement content = CreateCSharpMigrationSection(mainScrollView); - Label migrationStatusLabel = CreateStatusLabel(content); + TextField migrationStatusTextField = CreateStatusTextField(content); ProgressBar migrationProgressBar = CreateProgressBar(content); VisualElement migrationButtonRow = CreateMigrationButtonRow(content); Button migrateButton = CreateMigrateButton(migrationButtonRow); @@ -93,7 +93,7 @@ internal static ThirdPartyToolMigrationWizardView Create( ThirdPartyToolMigrationWizardView view = new ThirdPartyToolMigrationWizardView( mainScrollView, - migrationStatusLabel, + migrationStatusTextField, migrationProgressBar, migrationButtonRow, migrateButton, @@ -120,7 +120,7 @@ internal void SetMigrationSkillState( internal void ShowNotCheckedState(bool isMigrating) { - _migrationStatusLabel.text = ThirdPartyToolMigrationWizardText.MigrationNotCheckedText; + _migrationStatusTextField.SetValueWithoutNotify(ThirdPartyToolMigrationWizardText.MigrationNotCheckedText); ViewDataBinder.SetVisible(_migrationProgressBar, false); ViewDataBinder.SetVisible(_migrationButtonRow, false); _migrateButton.SetEnabled(false); @@ -135,7 +135,8 @@ internal void ShowNotCheckedState(bool isMigrating) internal void ShowMigrationTargetsState(int fileCount, bool isMigrating) { - _migrationStatusLabel.text = ThirdPartyToolMigrationWizardText.GetMigrationStatusText(fileCount); + _migrationStatusTextField.SetValueWithoutNotify( + ThirdPartyToolMigrationWizardText.GetMigrationStatusText(fileCount)); ViewDataBinder.SetVisible(_migrationProgressBar, false); ViewDataBinder.SetVisible(_migrationButtonRow, true); _migrateButton.SetEnabled(!isMigrating); @@ -150,7 +151,7 @@ internal void ShowMigrationTargetsState(int fileCount, bool isMigrating) internal void ShowNoMigrationTargetsState(bool isMigrating) { - _migrationStatusLabel.text = ThirdPartyToolMigrationWizardText.NoMigrationTargetsText; + _migrationStatusTextField.SetValueWithoutNotify(ThirdPartyToolMigrationWizardText.NoMigrationTargetsText); ViewDataBinder.SetVisible(_migrationProgressBar, false); ViewDataBinder.SetVisible(_migrationButtonRow, true); _migrateButton.SetEnabled(false); @@ -165,9 +166,8 @@ internal void ShowNoMigrationTargetsState(bool isMigrating) internal void ShowCheckingState(ThirdPartyToolMigrationProgress progress, bool isMigrating) { - _migrationStatusLabel.text = ThirdPartyToolMigrationWizardText.GetMigrationProgressText( - progress, - isMigrating); + _migrationStatusTextField.SetValueWithoutNotify( + ThirdPartyToolMigrationWizardText.GetMigrationProgressText(progress, isMigrating)); ViewDataBinder.SetVisible(_migrationProgressBar, true); ViewDataBinder.SetVisible(_migrationButtonRow, true); UpdateMigrationProgressBar(progress); @@ -195,27 +195,30 @@ private static VisualElement CreateCSharpMigrationSection(ScrollView mainScrollV migrationSection.AddToClassList("setup-step"); mainScrollView.Add(migrationSection); - Label titleLabel = new Label(ThirdPartyToolMigrationWizardText.CSharpMigrationSectionTitle); - titleLabel.AddToClassList("setup-step__title"); - migrationSection.Add(titleLabel); + TextField titleTextField = CreateSelectableText( + ThirdPartyToolMigrationWizardText.CSharpMigrationSectionTitle, + "setup-step__title"); + migrationSection.Add(titleTextField); VisualElement content = new VisualElement(); content.AddToClassList("setup-step__content"); migrationSection.Add(content); - Label descriptionLabel = new Label(ThirdPartyToolMigrationWizardText.CSharpMigrationDescriptionText); - descriptionLabel.AddToClassList("setup-step__description-label"); - content.Add(descriptionLabel); + TextField descriptionTextField = CreateSelectableText( + ThirdPartyToolMigrationWizardText.CSharpMigrationDescriptionText, + "setup-step__description-label"); + content.Add(descriptionTextField); return content; } - private static Label CreateStatusLabel(VisualElement content) + private static TextField CreateStatusTextField(VisualElement content) { - Label migrationStatusLabel = new Label(); - migrationStatusLabel.AddToClassList("setup-step__status-label"); - migrationStatusLabel.AddToClassList("setup-step__status-label--standalone"); - content.Add(migrationStatusLabel); - return migrationStatusLabel; + TextField migrationStatusTextField = CreateSelectableText( + string.Empty, + "setup-step__status-label"); + migrationStatusTextField.AddToClassList("setup-step__status-label--standalone"); + content.Add(migrationStatusTextField); + return migrationStatusTextField; } private static ProgressBar CreateProgressBar(VisualElement content) @@ -284,17 +287,19 @@ private static (EnumField migrationSkillTargetField, Button migrationSkillButton migrationSkillSection.AddToClassList("setup-step"); mainScrollView.Add(migrationSkillSection); - Label titleLabel = new Label(ThirdPartyToolMigrationWizardText.AiMigrationSkillSectionTitle); - titleLabel.AddToClassList("setup-step__title"); - migrationSkillSection.Add(titleLabel); + TextField titleTextField = CreateSelectableText( + ThirdPartyToolMigrationWizardText.AiMigrationSkillSectionTitle, + "setup-step__title"); + migrationSkillSection.Add(titleTextField); VisualElement content = new VisualElement(); content.AddToClassList("setup-step__content"); migrationSkillSection.Add(content); - Label descriptionLabel = new Label(ThirdPartyToolMigrationWizardText.AiMigrationSkillDescriptionText); - descriptionLabel.AddToClassList("setup-step__description-label"); - content.Add(descriptionLabel); + TextField descriptionTextField = CreateSelectableText( + ThirdPartyToolMigrationWizardText.AiMigrationSkillDescriptionText, + "setup-step__description-label"); + content.Add(descriptionTextField); VisualElement actionSection = new VisualElement(); actionSection.AddToClassList("setup-section-actions"); @@ -304,9 +309,10 @@ private static (EnumField migrationSkillTargetField, Button migrationSkillButton targetRow.AddToClassList("setup-section-actions__field-row"); actionSection.Add(targetRow); - Label targetLabel = new Label("Install target"); - targetLabel.AddToClassList("setup-section-actions__field-label"); - targetRow.Add(targetLabel); + TextField targetLabelTextField = CreateSelectableText( + "Install target", + "setup-section-actions__field-label"); + targetRow.Add(targetLabelTextField); EnumField migrationSkillTargetField = new EnumField(SkillsTarget.Claude); migrationSkillTargetField.AddToClassList("setup-dropdown"); @@ -333,11 +339,24 @@ private static void CreateFooter(ScrollView mainScrollView) footer.AddToClassList("setup-footer"); mainScrollView.Add(footer); - Label reopenHintLabel = new Label( + TextField reopenHintTextField = CreateSelectableText( "You can close this wizard and reopen it later from\n" + - "Window > Unity CLI Loop > Custom Tool Migration."); - reopenHintLabel.AddToClassList("setup-footer__hint-label"); - footer.Add(reopenHintLabel); + "Window > Unity CLI Loop > Custom Tool Migration.", + "setup-footer__hint-label"); + footer.Add(reopenHintTextField); + } + + private static TextField CreateSelectableText(string text, string className) + { + TextField textField = new TextField(); + textField.multiline = true; + textField.isReadOnly = true; + textField.selectAllOnFocus = false; + textField.selectAllOnMouseUp = false; + textField.SetValueWithoutNotify(text); + textField.AddToClassList("setup-selectable-text"); + textField.AddToClassList(className); + return textField; } private void BindEvents( From b7de65dee8d49defb04c64e3b0a78aa13a9a0b3c Mon Sep 17 00:00:00 2001 From: hatayama Date: Sun, 21 Jun 2026 00:58:55 +0900 Subject: [PATCH 06/21] Add migration skill usage foldout Show an optional prompt example after installing the migration skill so users know how to ask their AI agent to update SKILL.md and script invocations. --- .../Presentation/Setup/SetupWizardWindow.uss | 10 ++++++++++ .../Setup/ThirdPartyToolMigrationWizardText.cs | 7 +++++++ .../Setup/ThirdPartyToolMigrationWizardView.cs | 15 +++++++++++++++ 3 files changed, 32 insertions(+) diff --git a/Packages/src/Editor/Presentation/Setup/SetupWizardWindow.uss b/Packages/src/Editor/Presentation/Setup/SetupWizardWindow.uss index a43e388ddc..0c8119dbb1 100644 --- a/Packages/src/Editor/Presentation/Setup/SetupWizardWindow.uss +++ b/Packages/src/Editor/Presentation/Setup/SetupWizardWindow.uss @@ -347,6 +347,16 @@ margin-bottom: 14px; } +.setup-skill-usage-foldout { + margin-top: 2px; + margin-bottom: 2px; +} + +.setup-skill-usage-text { + margin-top: 6px; + margin-left: 14px; +} + .setup-footer__github-link { flex-direction: row; justify-content: flex-end; diff --git a/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardText.cs b/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardText.cs index e9b5f43d23..02595c77e3 100644 --- a/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardText.cs +++ b/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardText.cs @@ -18,6 +18,13 @@ internal static class ThirdPartyToolMigrationWizardText "Install a temporary AI skill for updating SKILL.md, Markdown, shell scripts, and PowerShell scripts " + "that call uloop.\nThe skill gives your AI agent a checklist and detection scripts; this window only " + "installs or removes it."; + internal const string AiMigrationSkillUsageFoldoutTitle = "How to use this skill"; + internal const string AiMigrationSkillUsageExampleText = + "After installing the skill, open your AI agent in this project and ask it to use the installed " + + "migration skill.\n\nExample prompt:\nUse the v3-cli-invocation-migration skill to scan this project " + + "for Unity CLI Loop V2 CLI invocation patterns in SKILL.md, Markdown, shell scripts, and PowerShell " + + "scripts. Update them for V3 CLI usage, then summarize the changed files and any commands I should " + + "verify manually."; internal const string MigrationNotCheckedText = "C# source migration status has not been checked."; internal const string NoMigrationTargetsText = "No C# source structure migration is needed."; private const string MigrationCheckingText = "Scanning C# source files for V3 custom tool API migration..."; diff --git a/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardView.cs b/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardView.cs index 8bc5215907..3fe98e5bc8 100644 --- a/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardView.cs +++ b/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardView.cs @@ -300,6 +300,7 @@ private static (EnumField migrationSkillTargetField, Button migrationSkillButton ThirdPartyToolMigrationWizardText.AiMigrationSkillDescriptionText, "setup-step__description-label"); content.Add(descriptionTextField); + CreateMigrationSkillUsageFoldout(content); VisualElement actionSection = new VisualElement(); actionSection.AddToClassList("setup-section-actions"); @@ -333,6 +334,20 @@ private static (EnumField migrationSkillTargetField, Button migrationSkillButton return (migrationSkillTargetField, migrationSkillButton); } + private static void CreateMigrationSkillUsageFoldout(VisualElement content) + { + Foldout usageFoldout = new Foldout(); + usageFoldout.text = ThirdPartyToolMigrationWizardText.AiMigrationSkillUsageFoldoutTitle; + usageFoldout.AddToClassList("setup-skill-usage-foldout"); + content.Add(usageFoldout); + + TextField usageTextField = CreateSelectableText( + ThirdPartyToolMigrationWizardText.AiMigrationSkillUsageExampleText, + "setup-skill-usage-text"); + usageFoldout.Add(usageTextField); + usageFoldout.SetValueWithoutNotify(false); + } + private static void CreateFooter(ScrollView mainScrollView) { VisualElement footer = new VisualElement(); From 325a0176d01f50d93d22a788a99d2eeae09a0c59 Mon Sep 17 00:00:00 2001 From: hatayama Date: Sun, 21 Jun 2026 01:08:37 +0900 Subject: [PATCH 07/21] Align migrate button styling Apply the shared primary button style to the migration action so it matches the check and migration skill install actions. --- .../Presentation/Setup/ThirdPartyToolMigrationWizardView.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardView.cs b/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardView.cs index 3fe98e5bc8..d625cd54b3 100644 --- a/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardView.cs +++ b/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardView.cs @@ -244,6 +244,7 @@ private static Button CreateMigrateButton(VisualElement migrationButtonRow) Button migrateButton = new Button(); migrateButton.text = ThirdPartyToolMigrationWizardText.GetMigrationButtonText(false, false, false); migrateButton.AddToClassList("setup-button"); + migrateButton.AddToClassList("setup-button--primary"); migrateButton.AddToClassList("setup-button--migration-action"); migrationButtonRow.Add(migrateButton); return migrateButton; From 2fb8c3ccf5367c1f2b75e4104ea974638fc2ca14 Mon Sep 17 00:00:00 2001 From: hatayama Date: Sun, 21 Jun 2026 01:25:49 +0900 Subject: [PATCH 08/21] Resolve migration skill from file packages Allow the V3 migration skill installer to locate Unity CLI Loop when the package is referenced through a manifest file dependency outside the project. --- cli/internal/cli/skills_packages.go | 5 +++++ cli/internal/cli/skills_test.go | 32 +++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/cli/internal/cli/skills_packages.go b/cli/internal/cli/skills_packages.go index 52da05ff31..45c351e9f3 100644 --- a/cli/internal/cli/skills_packages.go +++ b/cli/internal/cli/skills_packages.go @@ -113,6 +113,11 @@ func resolvePackageRoot(projectRoot string) string { return resolvedRoot } } + for _, candidate := range resolveManifestLocalPackageRoots(projectRoot) { + if resolvedRoot := resolvePackageRootCandidate(candidate); resolvedRoot != "" { + return resolvedRoot + } + } return resolvePackageCacheRoot(projectRoot) } diff --git a/cli/internal/cli/skills_test.go b/cli/internal/cli/skills_test.go index f2b342b54d..7ca9d47649 100644 --- a/cli/internal/cli/skills_test.go +++ b/cli/internal/cli/skills_test.go @@ -164,6 +164,38 @@ name: v3-cli-invocation-migration } } +// Tests that the dedicated V3 migration skill can be read from a manifest file dependency. +func TestCollectV3MigrationSkillDefinitionReadsManifestFilePackage(t *testing.T) { + projectRoot := t.TempDir() + packageRoot := filepath.Join(t.TempDir(), "Packages", "src") + markerPath := filepath.Join(packageRoot, "Editor", "FirstPartyTools") + if err := os.MkdirAll(markerPath, 0o755); err != nil { + t.Fatalf("failed to create marker path: %v", err) + } + writeManifest(t, projectRoot, `{"dependencies":{"io.github.hatayama.uloopmcp":"file:`+filepath.ToSlash(packageRoot)+`"}}`) + writeSkillFile(t, filepath.Join( + packageRoot, + "TemporarySkills", + "v3-cli-invocation-migration", + "Skill"), `--- +name: v3-cli-invocation-migration +--- + +# temporary migration +`) + + skills, err := collectV3MigrationSkillDefinition(projectRoot) + if err != nil { + t.Fatalf("collectV3MigrationSkillDefinition failed: %v", err) + } + + actualNames := skillNames(skills) + expectedNames := []string{"v3-cli-invocation-migration"} + if !reflect.DeepEqual(actualNames, expectedNames) { + t.Fatalf("skill names mismatch:\nactual: %#v\nexpected: %#v", actualNames, expectedNames) + } +} + // Tests that CLI-only and project-local skills win over package-root duplicates. func TestCollectSkillDefinitionsUsesUnitySideSourcePrecedence(t *testing.T) { projectRoot := t.TempDir() From 7f58dd6ac213e4fa0f2f7973d8aa2a448e7c3d52 Mon Sep 17 00:00:00 2001 From: hatayama Date: Sun, 21 Jun 2026 01:33:30 +0900 Subject: [PATCH 09/21] Fix migration skill remove state handling Recheck the installed migration skill files when the button is clicked so stale UI state cannot choose the wrong action. Remove the temporary V3 migration skill from both supported install layouts because older or alternate layouts should still be treated as installed. --- ...hirdPartyToolMigrationWizardWindowTests.cs | 14 ++++ .../Editor/ToolSkillSynchronizerTests.cs | 67 +++++++++++++++++++ .../SkillSetup/ToolSkillSynchronizer.cs | 40 +++++++++-- .../ThirdPartyToolMigrationWizardWindow.cs | 18 ++++- 4 files changed, 130 insertions(+), 9 deletions(-) diff --git a/Assets/Tests/Editor/ThirdPartyToolMigrationWizardWindowTests.cs b/Assets/Tests/Editor/ThirdPartyToolMigrationWizardWindowTests.cs index 95c3190899..cd97a7b318 100644 --- a/Assets/Tests/Editor/ThirdPartyToolMigrationWizardWindowTests.cs +++ b/Assets/Tests/Editor/ThirdPartyToolMigrationWizardWindowTests.cs @@ -180,6 +180,20 @@ public void GetMigrationSkillButtonText_ReturnsExpectedLabel( 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)] diff --git a/Assets/Tests/Editor/ToolSkillSynchronizerTests.cs b/Assets/Tests/Editor/ToolSkillSynchronizerTests.cs index 19400e4891..acbb2383b0 100644 --- a/Assets/Tests/Editor/ToolSkillSynchronizerTests.cs +++ b/Assets/Tests/Editor/ToolSkillSynchronizerTests.cs @@ -1685,6 +1685,33 @@ await ToolSkillSynchronizer.InstallSpecificSkillFilesAtProjectRoot( 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() { @@ -1720,6 +1747,46 @@ await ToolSkillSynchronizer.RemoveSpecificSkillFilesAtProjectRoot( 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( diff --git a/Packages/src/Editor/Infrastructure/SkillSetup/ToolSkillSynchronizer.cs b/Packages/src/Editor/Infrastructure/SkillSetup/ToolSkillSynchronizer.cs index c1133bfff1..f90ebdaecd 100644 --- a/Packages/src/Editor/Infrastructure/SkillSetup/ToolSkillSynchronizer.cs +++ b/Packages/src/Editor/Infrastructure/SkillSetup/ToolSkillSynchronizer.cs @@ -563,11 +563,21 @@ internal static SkillInstallState GetV3MigrationSkillInstallStateAtProjectRoot( bool groupSkillsUnderUnityCliLoop) { SkillInstallLayout.SkillSourceInfo skill = GetV3MigrationSkillSourceInfo(); - return GetSkillInstallStateAtProjectRoot( + SkillInstallState preferredLayoutState = GetSkillInstallStateAtProjectRoot( projectRoot, target, skill, groupSkillsUnderUnityCliLoop); + if (preferredLayoutState != SkillInstallState.Missing) + { + return preferredLayoutState; + } + + return GetSkillInstallStateAtProjectRoot( + projectRoot, + target, + skill, + !groupSkillsUnderUnityCliLoop); } internal static async Task InstallV3MigrationSkillFilesAtProjectRoot( @@ -588,12 +598,30 @@ internal static async Task RemoveV3MigrationSkillFilesAtProj IEnumerable targets, bool groupSkillsUnderUnityCliLoop) { + Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty"); + Debug.Assert(targets != null, "targets must not be null"); + SkillInstallLayout.SkillSourceInfo skill = GetV3MigrationSkillSourceInfo(); - return await RemoveSpecificSkillFilesAtProjectRoot( - projectRoot, - targets, - skill.Name, - groupSkillsUnderUnityCliLoop); + SkillTargetInfo[] targetArray = targets.ToArray(); + return await Task.Run(() => + { + int succeeded = 0; + foreach (SkillTargetInfo target in targetArray) + { + string targetRoot = Path.Combine(projectRoot, target.DirName); + DeleteSkillDirectoryIfExists( + targetRoot, + skill.Name, + groupSkillsUnderUnityCliLoop); + DeleteSkillDirectoryIfExists( + targetRoot, + skill.Name, + !groupSkillsUnderUnityCliLoop); + succeeded++; + } + + return new SkillInstallResult(targetArray.Length, succeeded); + }); } internal static SkillInstallState GetSkillInstallStateAtProjectRoot( diff --git a/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardWindow.cs b/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardWindow.cs index 5800862140..cec864bdf5 100644 --- a/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardWindow.cs +++ b/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardWindow.cs @@ -209,6 +209,12 @@ internal static string GetMigrationSkillButtonText( return ThirdPartyToolMigrationWizardText.GetMigrationSkillButtonText(isUpdating, installState); } + internal static bool ShouldRemoveMigrationSkill(SkillInstallState installState) + { + return installState == SkillInstallState.Installed + || installState == SkillInstallState.Outdated; + } + internal static bool HasFiniteSize(Vector2 size) { return ThirdPartyToolMigrationWizardWindowResizer.HasFiniteSize(size); @@ -525,14 +531,20 @@ private async void HandleToggleMigrationSkill() { string projectRoot = UnityCliLoopPathResolver.GetProjectRoot(); SkillSetupTargetInfo target = CreateMigrationSkillTargetInfo(_migrationSkillTarget); - List targets = new() { target }; + SkillInstallState currentInstallState = + _skillSetupUseCase.GetV3MigrationSkillInstallStateAtProjectRoot( + projectRoot, + target, + GroupMigrationSkillUnderUnityCliLoop); + bool shouldRemoveMigrationSkill = ShouldRemoveMigrationSkill(currentInstallState); + List targets = new List { target }; + _migrationSkillInstallState = currentInstallState; _isUpdatingMigrationSkill = true; UpdateMigrationSkillState(); try { - if (_migrationSkillInstallState == SkillInstallState.Installed - || _migrationSkillInstallState == SkillInstallState.Outdated) + if (shouldRemoveMigrationSkill) { await _skillSetupUseCase.RemoveV3MigrationSkillFilesAsync( projectRoot, From 31b5ea792ed39f9d0b08e2e0b7077e890559d2fd Mon Sep 17 00:00:00 2001 From: hatayama Date: Sun, 21 Jun 2026 02:02:55 +0900 Subject: [PATCH 10/21] Fix migration skill detector and package root lookup Prune excluded directories before PowerShell detector recursion and read one-line files correctly. Restrict manifest-local package root resolution to the Unity CLI Loop package so unrelated local dependencies cannot shadow the temporary migration skill source. --- .../detect-v3-cli-invocation-candidates.ps1 | 41 ++++++++++++------- cli/internal/cli/skills_packages.go | 11 ++++- cli/internal/cli/skills_test.go | 22 ++++++++++ 3 files changed, 59 insertions(+), 15 deletions(-) diff --git a/Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill/scripts/detect-v3-cli-invocation-candidates.ps1 b/Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill/scripts/detect-v3-cli-invocation-candidates.ps1 index a75e0638cc..93cfacd5b0 100644 --- a/Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill/scripts/detect-v3-cli-invocation-candidates.ps1 +++ b/Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill/scripts/detect-v3-cli-invocation-candidates.ps1 @@ -19,17 +19,34 @@ $excludedDirectories = @( $targetExtensions = @(".md", ".sh", ".bash", ".zsh", ".ps1", ".psm1") $outputFields = "Success|Message|ErrorMessage|ErrorCount|WarningCount|TotalCount|DisplayedCount|LogType|StackTrace|XmlPath|TestCount|PassedCount|FailedCount|SkippedCount|CompletedAt|ScreenshotCount|Screenshots|CompilationErrors|ErrorCode|UpdatedCode|DiagnosticsSummary|OutputPath|InputPath|TotalFrames|DurationSeconds|CurrentFrame|IsReplaying|KeyName|PositionX|PositionY|EndPositionX|EndPositionY|HitGameObjectName|IsPlaying|IsPaused|ClearedLogCount|ClearedCounts" -function Test-IsExcludedPath { - param([string] $Path) +function Test-IsExcludedDirectoryName { + param([string] $Name) - $parts = $Path -split [System.IO.Path]::DirectorySeparatorChar - foreach ($part in $parts) { - if ($excludedDirectories -contains $part) { - return $true - } + return $excludedDirectories -contains $Name +} + +function Test-IsTargetFile { + param([System.IO.FileInfo] $File) + + return $File.Name -eq "SKILL.md" -or $targetExtensions -contains $File.Extension +} + +function Get-CandidateFile { + param([string] $Directory) + + $directoryName = Split-Path -Leaf $Directory + if (Test-IsExcludedDirectoryName -Name $directoryName) { + return } - return $false + Get-ChildItem -LiteralPath $Directory -File | + Where-Object { Test-IsTargetFile -File $_ } + + Get-ChildItem -LiteralPath $Directory -Directory | + Where-Object { -not (Test-IsExcludedDirectoryName -Name $_.Name) } | + ForEach-Object { + Get-CandidateFile -Directory $_.FullName + } } function Write-Candidate { @@ -43,14 +60,10 @@ function Write-Candidate { Write-Output "$Kind ${Path}:${LineNumber}: $Line" } -Get-ChildItem -LiteralPath $Root -Recurse -File | - Where-Object { - -not (Test-IsExcludedPath -Path $_.FullName) -and - ($_.Name -eq "SKILL.md" -or $targetExtensions -contains $_.Extension) - } | +Get-CandidateFile -Directory $Root | ForEach-Object { $path = $_.FullName - $lines = Get-Content -LiteralPath $path + $lines = @(Get-Content -LiteralPath $path) for ($index = 0; $index -lt $lines.Count; $index++) { $line = $lines[$index] $lineNumber = $index + 1 diff --git a/cli/internal/cli/skills_packages.go b/cli/internal/cli/skills_packages.go index 45c351e9f3..02568e8d17 100644 --- a/cli/internal/cli/skills_packages.go +++ b/cli/internal/cli/skills_packages.go @@ -59,7 +59,10 @@ func resolveManifestLocalPackageRoots(projectRoot string) []string { return []string{} } packageRoots := []string{} - for _, dependencyValue := range dependencies { + for dependencyName, dependencyValue := range dependencies { + if !isTargetManifestPackageName(dependencyName) { + continue + } localPath := resolveLocalDependencyPath(dependencyValue, projectRoot) if localPath == "" { continue @@ -205,3 +208,9 @@ func isTargetPackageCacheDir(dirName string) bool { return strings.HasPrefix(normalizedName, strings.ToLower(packageName)+"@") || strings.HasPrefix(normalizedName, strings.ToLower(packageNameAlias)+"@") } + +func isTargetManifestPackageName(dependencyName string) bool { + normalizedName := strings.ToLower(dependencyName) + return normalizedName == strings.ToLower(packageName) || + normalizedName == strings.ToLower(packageNameAlias) +} diff --git a/cli/internal/cli/skills_test.go b/cli/internal/cli/skills_test.go index 7ca9d47649..0e0ba3b790 100644 --- a/cli/internal/cli/skills_test.go +++ b/cli/internal/cli/skills_test.go @@ -196,6 +196,28 @@ name: v3-cli-invocation-migration } } +// Tests that local manifest packages from other dependencies cannot replace the Unity CLI Loop package root. +func TestResolvePackageRootIgnoresOtherManifestFilePackages(t *testing.T) { + projectRoot := t.TempDir() + otherPackageRoot := filepath.Join(t.TempDir(), "AOtherPackage") + packageRoot := filepath.Join(t.TempDir(), "ZUnityCliLoopPackage") + for _, candidateRoot := range []string{otherPackageRoot, packageRoot} { + markerPath := filepath.Join(candidateRoot, "Editor", "FirstPartyTools") + if err := os.MkdirAll(markerPath, 0o755); err != nil { + t.Fatalf("failed to create marker path: %v", err) + } + } + writeManifest( + t, + projectRoot, + `{"dependencies":{"com.example.other":"file:`+filepath.ToSlash(otherPackageRoot)+`","io.github.hatayama.uloopmcp":"file:`+filepath.ToSlash(packageRoot)+`"}}`) + + actualRoot := resolvePackageRoot(projectRoot) + if actualRoot != packageRoot { + t.Fatalf("package root mismatch: actual=%s expected=%s", actualRoot, packageRoot) + } +} + // Tests that CLI-only and project-local skills win over package-root duplicates. func TestCollectSkillDefinitionsUsesUnitySideSourcePrecedence(t *testing.T) { projectRoot := t.TempDir() From fb5f745d999fd2fdf52a4e01f4245b9c30ad4e1d Mon Sep 17 00:00:00 2001 From: hatayama Date: Sun, 21 Jun 2026 02:08:11 +0900 Subject: [PATCH 11/21] Preserve manifest local skill discovery Keep third-party local package skills discoverable through the general manifest path while using a filtered path only for resolving the Unity CLI Loop package root. --- cli/internal/cli/skills_packages.go | 12 ++++++++++-- cli/internal/cli/skills_test.go | 13 ++++++++++++- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/cli/internal/cli/skills_packages.go b/cli/internal/cli/skills_packages.go index 02568e8d17..ab739ab441 100644 --- a/cli/internal/cli/skills_packages.go +++ b/cli/internal/cli/skills_packages.go @@ -54,13 +54,21 @@ func enumerateDirectProjectPackageRoots(projectRoot string) []string { } func resolveManifestLocalPackageRoots(projectRoot string) []string { + return resolveManifestLocalPackageRootsMatching(projectRoot, nil) +} + +func resolveTargetManifestLocalPackageRoots(projectRoot string) []string { + return resolveManifestLocalPackageRootsMatching(projectRoot, isTargetManifestPackageName) +} + +func resolveManifestLocalPackageRootsMatching(projectRoot string, includeDependency func(string) bool) []string { dependencies := readManifestDependencies(projectRoot) if len(dependencies) == 0 { return []string{} } packageRoots := []string{} for dependencyName, dependencyValue := range dependencies { - if !isTargetManifestPackageName(dependencyName) { + if includeDependency != nil && !includeDependency(dependencyName) { continue } localPath := resolveLocalDependencyPath(dependencyValue, projectRoot) @@ -116,7 +124,7 @@ func resolvePackageRoot(projectRoot string) string { return resolvedRoot } } - for _, candidate := range resolveManifestLocalPackageRoots(projectRoot) { + for _, candidate := range resolveTargetManifestLocalPackageRoots(projectRoot) { if resolvedRoot := resolvePackageRootCandidate(candidate); resolvedRoot != "" { return resolvedRoot } diff --git a/cli/internal/cli/skills_test.go b/cli/internal/cli/skills_test.go index 0e0ba3b790..d0ddaa2a31 100644 --- a/cli/internal/cli/skills_test.go +++ b/cli/internal/cli/skills_test.go @@ -94,7 +94,17 @@ name: uloop-local-package # local package `) - writeManifest(t, projectRoot, `{"dependencies":{"com.example.cached":"1.0.0"}}`) + manifestLocalPackageRoot := filepath.Join(t.TempDir(), "manifest-local-package") + writeSkillFile(t, filepath.Join(manifestLocalPackageRoot, "Editor", "ManifestLocalTool", "Skill"), `--- +name: uloop-manifest-local-package +--- + +# manifest local package +`) + writeManifest( + t, + projectRoot, + `{"dependencies":{"com.example.cached":"1.0.0","com.example.manifest-local":"file:`+filepath.ToSlash(manifestLocalPackageRoot)+`"}}`) writeTestSkill(t, projectRoot, "Library/PackageCache/com.example.cached@1.0.0/Editor/CachedTool/Skill", `--- name: uloop-cached-package --- @@ -115,6 +125,7 @@ name: uloop-cached-package "uloop-hello", "uloop-launch", "uloop-local-package", + "uloop-manifest-local-package", } if !reflect.DeepEqual(actualNames, expectedNames) { t.Fatalf("skill names mismatch:\nactual: %#v\nexpected: %#v", actualNames, expectedNames) From fcccb042587e6de7b07a7598ca626a40a3a2642c Mon Sep 17 00:00:00 2001 From: hatayama Date: Sun, 21 Jun 2026 02:21:44 +0900 Subject: [PATCH 12/21] Make migration detectors converge after updates Skip the bundled temporary migration skill source when scanning and make the PowerShell output-field check case-sensitive so already migrated camelCase field reads are not reported again. --- .../detect-v3-cli-invocation-candidates.ps1 | 29 +++++++++++++++++-- .../detect-v3-cli-invocation-candidates.sh | 2 +- cli/internal/cli/skills_test.go | 25 ++++++++++++++++ 3 files changed, 52 insertions(+), 4 deletions(-) diff --git a/Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill/scripts/detect-v3-cli-invocation-candidates.ps1 b/Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill/scripts/detect-v3-cli-invocation-candidates.ps1 index 93cfacd5b0..e681a21c3c 100644 --- a/Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill/scripts/detect-v3-cli-invocation-candidates.ps1 +++ b/Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill/scripts/detect-v3-cli-invocation-candidates.ps1 @@ -18,6 +18,12 @@ $excludedDirectories = @( $targetExtensions = @(".md", ".sh", ".bash", ".zsh", ".ps1", ".psm1") $outputFields = "Success|Message|ErrorMessage|ErrorCount|WarningCount|TotalCount|DisplayedCount|LogType|StackTrace|XmlPath|TestCount|PassedCount|FailedCount|SkippedCount|CompletedAt|ScreenshotCount|Screenshots|CompilationErrors|ErrorCode|UpdatedCode|DiagnosticsSummary|OutputPath|InputPath|TotalFrames|DurationSeconds|CurrentFrame|IsReplaying|KeyName|PositionX|PositionY|EndPositionX|EndPositionY|HitGameObjectName|IsPlaying|IsPaused|ClearedLogCount|ClearedCounts" +$bundledMigrationSkillPath = [System.IO.Path]::Combine( + "Packages", + "src", + "TemporarySkills", + "v3-cli-invocation-migration" +) function Test-IsExcludedDirectoryName { param([string] $Name) @@ -31,11 +37,25 @@ function Test-IsTargetFile { return $File.Name -eq "SKILL.md" -or $targetExtensions -contains $File.Extension } +function Test-IsBundledMigrationSkillPath { + param([string] $Path) + + $normalizedPath = $Path.Replace( + [string] [System.IO.Path]::AltDirectorySeparatorChar, + [string] [System.IO.Path]::DirectorySeparatorChar + ) + return $normalizedPath.IndexOf( + $bundledMigrationSkillPath, + [System.StringComparison]::OrdinalIgnoreCase + ) -ge 0 +} + function Get-CandidateFile { param([string] $Directory) $directoryName = Split-Path -Leaf $Directory - if (Test-IsExcludedDirectoryName -Name $directoryName) { + if ((Test-IsExcludedDirectoryName -Name $directoryName) -or + (Test-IsBundledMigrationSkillPath -Path $Directory)) { return } @@ -43,7 +63,10 @@ function Get-CandidateFile { Where-Object { Test-IsTargetFile -File $_ } Get-ChildItem -LiteralPath $Directory -Directory | - Where-Object { -not (Test-IsExcludedDirectoryName -Name $_.Name) } | + Where-Object { + -not (Test-IsExcludedDirectoryName -Name $_.Name) -and + -not (Test-IsBundledMigrationSkillPath -Path $_.FullName) + } | ForEach-Object { Get-CandidateFile -Directory $_.FullName } @@ -85,7 +108,7 @@ Get-CandidateFile -Directory $Root | if ($line -match "uloop\s+execute-dynamic-code\s+[^#|;&]*--compile-only(\s|=|$)") { Write-Candidate -Kind "FIRST_PARTY_OPTION" -Path $path -LineNumber $lineNumber -Line $line } - if ($line -match "\.($outputFields)([^A-Za-z0-9_]|$)") { + if ($line -cmatch "\.($outputFields)([^A-Za-z0-9_]|$)") { Write-Candidate -Kind "OUTPUT_FIELD" -Path $path -LineNumber $lineNumber -Line $line } } diff --git a/Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill/scripts/detect-v3-cli-invocation-candidates.sh b/Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill/scripts/detect-v3-cli-invocation-candidates.sh index 72077c08ee..c8f7b351d9 100755 --- a/Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill/scripts/detect-v3-cli-invocation-candidates.sh +++ b/Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill/scripts/detect-v3-cli-invocation-candidates.sh @@ -4,7 +4,7 @@ set -eu root=${1:-.} find "$root" \ - \( -name .git -o -name Library -o -name Temp -o -name node_modules -o -name .agents -o -name .claude -o -name .codex -o -name .cursor -o -name .gemini -o -name .windsurf -o -name .agent \) -prune -o \ + \( -name .git -o -name Library -o -name Temp -o -name node_modules -o -name .agents -o -name .claude -o -name .codex -o -name .cursor -o -name .gemini -o -name .windsurf -o -name .agent -o -path '*/Packages/src/TemporarySkills/v3-cli-invocation-migration' -o -path 'Packages/src/TemporarySkills/v3-cli-invocation-migration' \) -prune -o \ -type f \( -name SKILL.md -o -name '*.md' -o -name '*.sh' -o -name '*.bash' -o -name '*.zsh' -o -name '*.ps1' -o -name '*.psm1' \) -print | while IFS= read -r file do diff --git a/cli/internal/cli/skills_test.go b/cli/internal/cli/skills_test.go index d0ddaa2a31..bd784579d3 100644 --- a/cli/internal/cli/skills_test.go +++ b/cli/internal/cli/skills_test.go @@ -1005,6 +1005,24 @@ func TestV3MigrationSkillPosixDetectorReportsCandidates(t *testing.T) { if err := os.WriteFile(fixtureFile, []byte(fixtureContent), 0o644); err != nil { t.Fatalf("failed to write fixture: %v", err) } + bundledReferenceFile := filepath.Join( + fixtureRoot, + "Packages", + "src", + "TemporarySkills", + "v3-cli-invocation-migration", + "Skill", + "references", + "first-party-v2-to-v3.md") + if err := os.MkdirAll(filepath.Dir(bundledReferenceFile), 0o755); err != nil { + t.Fatalf("failed to create bundled reference dir: %v", err) + } + if err := os.WriteFile( + bundledReferenceFile, + []byte("uloop bundled-tool --self-noise false\n"), + 0o644); err != nil { + t.Fatalf("failed to write bundled reference: %v", err) + } scriptPath := filepath.Join( findRepositoryRootForSkillsTest(t), "Packages", @@ -1026,6 +1044,9 @@ func TestV3MigrationSkillPosixDetectorReportsCandidates(t *testing.T) { t.Fatalf("detector output missing %s:\n%s", expected, text) } } + if strings.Contains(text, "self-noise") { + t.Fatalf("detector should skip bundled migration skill sources:\n%s", text) + } } // Tests that the PowerShell detector reports candidates when PowerShell is available. @@ -1043,6 +1064,7 @@ func TestV3MigrationSkillPowerShellDetectorReportsCandidates(t *testing.T) { "uloop run-tests --save-before-run false", "uloop some-tool --enabled true", "$result.Success", + "$result.success", }, "\n") if err := os.WriteFile(fixtureFile, []byte(fixtureContent), 0o644); err != nil { t.Fatalf("failed to write fixture: %v", err) @@ -1068,6 +1090,9 @@ func TestV3MigrationSkillPowerShellDetectorReportsCandidates(t *testing.T) { t.Fatalf("detector output missing %s:\n%s", expected, text) } } + if strings.Contains(text, "$result.success") { + t.Fatalf("detector should not report already migrated camelCase fields:\n%s", text) + } } func writeTestSkill(t *testing.T, projectRoot string, relativeDir string, content string) { From aea0e38424ddc42fb7d96577cbdade861b76ec4a Mon Sep 17 00:00:00 2001 From: hatayama Date: Sun, 21 Jun 2026 02:30:35 +0900 Subject: [PATCH 13/21] Broaden migration detector inline Markdown matches Treat punctuation and Markdown backticks as command terminators in both detectors so SKILL.md and Markdown examples are reported consistently. --- .../detect-v3-cli-invocation-candidates.ps1 | 12 +++++------ .../detect-v3-cli-invocation-candidates.sh | 12 +++++------ cli/internal/cli/skills_test.go | 20 +++++++++++++++++++ 3 files changed, 32 insertions(+), 12 deletions(-) diff --git a/Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill/scripts/detect-v3-cli-invocation-candidates.ps1 b/Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill/scripts/detect-v3-cli-invocation-candidates.ps1 index e681a21c3c..88d0847032 100644 --- a/Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill/scripts/detect-v3-cli-invocation-candidates.ps1 +++ b/Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill/scripts/detect-v3-cli-invocation-candidates.ps1 @@ -90,22 +90,22 @@ Get-CandidateFile -Directory $Root | for ($index = 0; $index -lt $lines.Count; $index++) { $line = $lines[$index] $lineNumber = $index + 1 - if ($line -match "uloop\s+[^#|;&]*--[A-Za-z0-9][A-Za-z0-9-]*(\s+|=)(true|false)(\s|$|[|;&])") { + if ($line -match "uloop\s+[^#|;&]*--[A-Za-z0-9][A-Za-z0-9-]*(\s+|=)(true|false)([^A-Za-z0-9_-]|$)") { Write-Candidate -Kind "ARG_BOOL" -Path $path -LineNumber $lineNumber -Line $line } - if ($line -match "uloop\s+compile\s+[^#|;&]*--(wait-for-domain-reload|reload-external-scene-changes|force-recompile)(\s|=|$)") { + if ($line -match "uloop\s+compile\s+[^#|;&]*--(wait-for-domain-reload|reload-external-scene-changes|force-recompile)([^A-Za-z0-9_-]|$)") { Write-Candidate -Kind "FIRST_PARTY_OPTION" -Path $path -LineNumber $lineNumber -Line $line } - if ($line -match "uloop\s+run-tests\s+[^#|;&]*--save-before-run(\s|=|$)") { + if ($line -match "uloop\s+run-tests\s+[^#|;&]*--save-before-run([^A-Za-z0-9_-]|$)") { Write-Candidate -Kind "FIRST_PARTY_OPTION" -Path $path -LineNumber $lineNumber -Line $line } - if ($line -match "uloop\s+(record-input|replay-input)\s+[^#|;&]*--show-overlay(\s|=|$)") { + if ($line -match "uloop\s+(record-input|replay-input)\s+[^#|;&]*--show-overlay([^A-Za-z0-9_-]|$)") { Write-Candidate -Kind "FIRST_PARTY_OPTION" -Path $path -LineNumber $lineNumber -Line $line } - if ($line -match "uloop\s+get-hierarchy\s+[^#|;&]*--include-(components|inactive)(\s|=|$)") { + if ($line -match "uloop\s+get-hierarchy\s+[^#|;&]*--include-(components|inactive)([^A-Za-z0-9_-]|$)") { Write-Candidate -Kind "FIRST_PARTY_OPTION" -Path $path -LineNumber $lineNumber -Line $line } - if ($line -match "uloop\s+execute-dynamic-code\s+[^#|;&]*--compile-only(\s|=|$)") { + if ($line -match "uloop\s+execute-dynamic-code\s+[^#|;&]*--compile-only([^A-Za-z0-9_-]|$)") { Write-Candidate -Kind "FIRST_PARTY_OPTION" -Path $path -LineNumber $lineNumber -Line $line } if ($line -cmatch "\.($outputFields)([^A-Za-z0-9_]|$)") { diff --git a/Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill/scripts/detect-v3-cli-invocation-candidates.sh b/Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill/scripts/detect-v3-cli-invocation-candidates.sh index c8f7b351d9..cfda506e14 100755 --- a/Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill/scripts/detect-v3-cli-invocation-candidates.sh +++ b/Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill/scripts/detect-v3-cli-invocation-candidates.sh @@ -12,22 +12,22 @@ do function report(kind) { printf "%s %s:%d: %s\n", kind, FILENAME, FNR, $0 } - /uloop[[:space:]][^#|;&]*--[[:alnum:]][[:alnum:]-]*([[:space:]]+|=)(true|false)([[:space:]]|$|[|;&])/ { + /uloop[[:space:]][^#|;&]*--[[:alnum:]][[:alnum:]-]*([[:space:]]+|=)(true|false)([^[:alnum:]_-]|$)/ { report("ARG_BOOL") } - /uloop[[:space:]]+compile[[:space:]][^#|;&]*--(wait-for-domain-reload|reload-external-scene-changes|force-recompile)([[:space:]]|=|$)/ { + /uloop[[:space:]]+compile[[:space:]][^#|;&]*--(wait-for-domain-reload|reload-external-scene-changes|force-recompile)([^[:alnum:]_-]|$)/ { report("FIRST_PARTY_OPTION") } - /uloop[[:space:]]+run-tests[[:space:]][^#|;&]*--save-before-run([[:space:]]|=|$)/ { + /uloop[[:space:]]+run-tests[[:space:]][^#|;&]*--save-before-run([^[:alnum:]_-]|$)/ { report("FIRST_PARTY_OPTION") } - /uloop[[:space:]]+(record-input|replay-input)[[:space:]][^#|;&]*--show-overlay([[:space:]]|=|$)/ { + /uloop[[:space:]]+(record-input|replay-input)[[:space:]][^#|;&]*--show-overlay([^[:alnum:]_-]|$)/ { report("FIRST_PARTY_OPTION") } - /uloop[[:space:]]+get-hierarchy[[:space:]][^#|;&]*--include-(components|inactive)([[:space:]]|=|$)/ { + /uloop[[:space:]]+get-hierarchy[[:space:]][^#|;&]*--include-(components|inactive)([^[:alnum:]_-]|$)/ { report("FIRST_PARTY_OPTION") } - /uloop[[:space:]]+execute-dynamic-code[[:space:]][^#|;&]*--compile-only([[:space:]]|=|$)/ { + /uloop[[:space:]]+execute-dynamic-code[[:space:]][^#|;&]*--compile-only([^[:alnum:]_-]|$)/ { report("FIRST_PARTY_OPTION") } /\.(Success|Message|ErrorMessage|ErrorCount|WarningCount|TotalCount|DisplayedCount|LogType|StackTrace|XmlPath|TestCount|PassedCount|FailedCount|SkippedCount|CompletedAt|ScreenshotCount|Screenshots|CompilationErrors|ErrorCode|UpdatedCode|DiagnosticsSummary|OutputPath|InputPath|TotalFrames|DurationSeconds|CurrentFrame|IsReplaying|KeyName|PositionX|PositionY|EndPositionX|EndPositionY|HitGameObjectName|IsPlaying|IsPaused|ClearedLogCount|ClearedCounts)([^[:alnum:]_]|$)/ { diff --git a/cli/internal/cli/skills_test.go b/cli/internal/cli/skills_test.go index bd784579d3..4ea47933e0 100644 --- a/cli/internal/cli/skills_test.go +++ b/cli/internal/cli/skills_test.go @@ -1000,6 +1000,8 @@ func TestV3MigrationSkillPosixDetectorReportsCandidates(t *testing.T) { fixtureContent := strings.Join([]string{ "uloop compile --wait-for-domain-reload true", "uloop some-tool --enabled false", + "inline `uloop compile --wait-for-domain-reload`", + "inline `uloop some-tool --enabled false`", "jq '.Success'", }, "\n") if err := os.WriteFile(fixtureFile, []byte(fixtureContent), 0o644); err != nil { @@ -1044,6 +1046,14 @@ func TestV3MigrationSkillPosixDetectorReportsCandidates(t *testing.T) { t.Fatalf("detector output missing %s:\n%s", expected, text) } } + for _, expected := range []string{ + "inline `uloop compile --wait-for-domain-reload`", + "inline `uloop some-tool --enabled false`", + } { + if !strings.Contains(text, expected) { + t.Fatalf("detector output missing inline Markdown candidate %q:\n%s", expected, text) + } + } if strings.Contains(text, "self-noise") { t.Fatalf("detector should skip bundled migration skill sources:\n%s", text) } @@ -1063,6 +1073,8 @@ func TestV3MigrationSkillPowerShellDetectorReportsCandidates(t *testing.T) { fixtureContent := strings.Join([]string{ "uloop run-tests --save-before-run false", "uloop some-tool --enabled true", + "inline `uloop run-tests --save-before-run`", + "inline `uloop some-tool --enabled true`", "$result.Success", "$result.success", }, "\n") @@ -1090,6 +1102,14 @@ func TestV3MigrationSkillPowerShellDetectorReportsCandidates(t *testing.T) { t.Fatalf("detector output missing %s:\n%s", expected, text) } } + for _, expected := range []string{ + "inline `uloop run-tests --save-before-run`", + "inline `uloop some-tool --enabled true`", + } { + if !strings.Contains(text, expected) { + t.Fatalf("detector output missing inline Markdown candidate %q:\n%s", expected, text) + } + } if strings.Contains(text, "$result.success") { t.Fatalf("detector should not report already migrated camelCase fields:\n%s", text) } From abb5d569d525ba506f2475d74ca97da0c792d65d Mon Sep 17 00:00:00 2001 From: hatayama Date: Sun, 21 Jun 2026 02:41:06 +0900 Subject: [PATCH 14/21] Prune bundled migration skill sources broadly Exclude TemporarySkills/v3-cli-invocation-migration regardless of the package parent layout so detector reruns do not report the bundled reference examples as self-noise. --- .../detect-v3-cli-invocation-candidates.ps1 | 9 +++-- .../detect-v3-cli-invocation-candidates.sh | 2 +- cli/internal/cli/skills_test.go | 39 +++++++++++++++++++ 3 files changed, 45 insertions(+), 5 deletions(-) diff --git a/Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill/scripts/detect-v3-cli-invocation-candidates.ps1 b/Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill/scripts/detect-v3-cli-invocation-candidates.ps1 index 88d0847032..fceec29521 100644 --- a/Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill/scripts/detect-v3-cli-invocation-candidates.ps1 +++ b/Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill/scripts/detect-v3-cli-invocation-candidates.ps1 @@ -19,8 +19,6 @@ $excludedDirectories = @( $targetExtensions = @(".md", ".sh", ".bash", ".zsh", ".ps1", ".psm1") $outputFields = "Success|Message|ErrorMessage|ErrorCount|WarningCount|TotalCount|DisplayedCount|LogType|StackTrace|XmlPath|TestCount|PassedCount|FailedCount|SkippedCount|CompletedAt|ScreenshotCount|Screenshots|CompilationErrors|ErrorCode|UpdatedCode|DiagnosticsSummary|OutputPath|InputPath|TotalFrames|DurationSeconds|CurrentFrame|IsReplaying|KeyName|PositionX|PositionY|EndPositionX|EndPositionY|HitGameObjectName|IsPlaying|IsPaused|ClearedLogCount|ClearedCounts" $bundledMigrationSkillPath = [System.IO.Path]::Combine( - "Packages", - "src", "TemporarySkills", "v3-cli-invocation-migration" ) @@ -44,8 +42,11 @@ function Test-IsBundledMigrationSkillPath { [string] [System.IO.Path]::AltDirectorySeparatorChar, [string] [System.IO.Path]::DirectorySeparatorChar ) - return $normalizedPath.IndexOf( - $bundledMigrationSkillPath, + $separator = [string] [System.IO.Path]::DirectorySeparatorChar + $wrappedPath = "${separator}${normalizedPath}${separator}" + $wrappedBundledPath = "${separator}${bundledMigrationSkillPath}${separator}" + return $wrappedPath.IndexOf( + $wrappedBundledPath, [System.StringComparison]::OrdinalIgnoreCase ) -ge 0 } diff --git a/Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill/scripts/detect-v3-cli-invocation-candidates.sh b/Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill/scripts/detect-v3-cli-invocation-candidates.sh index cfda506e14..6031d4483b 100755 --- a/Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill/scripts/detect-v3-cli-invocation-candidates.sh +++ b/Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill/scripts/detect-v3-cli-invocation-candidates.sh @@ -4,7 +4,7 @@ set -eu root=${1:-.} find "$root" \ - \( -name .git -o -name Library -o -name Temp -o -name node_modules -o -name .agents -o -name .claude -o -name .codex -o -name .cursor -o -name .gemini -o -name .windsurf -o -name .agent -o -path '*/Packages/src/TemporarySkills/v3-cli-invocation-migration' -o -path 'Packages/src/TemporarySkills/v3-cli-invocation-migration' \) -prune -o \ + \( -name .git -o -name Library -o -name Temp -o -name node_modules -o -name .agents -o -name .claude -o -name .codex -o -name .cursor -o -name .gemini -o -name .windsurf -o -name .agent -o -path '*/TemporarySkills/v3-cli-invocation-migration' -o -path 'TemporarySkills/v3-cli-invocation-migration' \) -prune -o \ -type f \( -name SKILL.md -o -name '*.md' -o -name '*.sh' -o -name '*.bash' -o -name '*.zsh' -o -name '*.ps1' -o -name '*.psm1' \) -print | while IFS= read -r file do diff --git a/cli/internal/cli/skills_test.go b/cli/internal/cli/skills_test.go index 4ea47933e0..0d1552d1fa 100644 --- a/cli/internal/cli/skills_test.go +++ b/cli/internal/cli/skills_test.go @@ -1025,6 +1025,24 @@ func TestV3MigrationSkillPosixDetectorReportsCandidates(t *testing.T) { 0o644); err != nil { t.Fatalf("failed to write bundled reference: %v", err) } + embeddedBundledReferenceFile := filepath.Join( + fixtureRoot, + "Packages", + "io.github.hatayama.uloopmcp", + "TemporarySkills", + "v3-cli-invocation-migration", + "Skill", + "references", + "first-party-v2-to-v3.md") + if err := os.MkdirAll(filepath.Dir(embeddedBundledReferenceFile), 0o755); err != nil { + t.Fatalf("failed to create embedded bundled reference dir: %v", err) + } + if err := os.WriteFile( + embeddedBundledReferenceFile, + []byte("uloop embedded-bundled-tool --self-noise false\n"), + 0o644); err != nil { + t.Fatalf("failed to write embedded bundled reference: %v", err) + } scriptPath := filepath.Join( findRepositoryRootForSkillsTest(t), "Packages", @@ -1081,6 +1099,24 @@ func TestV3MigrationSkillPowerShellDetectorReportsCandidates(t *testing.T) { if err := os.WriteFile(fixtureFile, []byte(fixtureContent), 0o644); err != nil { t.Fatalf("failed to write fixture: %v", err) } + embeddedBundledReferenceFile := filepath.Join( + fixtureRoot, + "Packages", + "io.github.hatayama.uloopmcp", + "TemporarySkills", + "v3-cli-invocation-migration", + "Skill", + "references", + "first-party-v2-to-v3.md") + if err := os.MkdirAll(filepath.Dir(embeddedBundledReferenceFile), 0o755); err != nil { + t.Fatalf("failed to create embedded bundled reference dir: %v", err) + } + if err := os.WriteFile( + embeddedBundledReferenceFile, + []byte("uloop embedded-bundled-tool --self-noise true\n"), + 0o644); err != nil { + t.Fatalf("failed to write embedded bundled reference: %v", err) + } scriptPath := filepath.Join( findRepositoryRootForSkillsTest(t), "Packages", @@ -1113,6 +1149,9 @@ func TestV3MigrationSkillPowerShellDetectorReportsCandidates(t *testing.T) { if strings.Contains(text, "$result.success") { t.Fatalf("detector should not report already migrated camelCase fields:\n%s", text) } + if strings.Contains(text, "self-noise") { + t.Fatalf("detector should skip bundled migration skill sources:\n%s", text) + } } func writeTestSkill(t *testing.T, projectRoot string, relativeDir string, content string) { From f5e007f01de3fe13fa588ea6100798df8cb85482 Mon Sep 17 00:00:00 2001 From: hatayama Date: Sun, 21 Jun 2026 02:49:52 +0900 Subject: [PATCH 15/21] Decouple migration skill uninstall from source lookup Allow uninstall-v3-migration to remove the known temporary skill name even when the Unity CLI Loop package source cannot be resolved. --- cli/internal/cli/skills.go | 12 ++++----- cli/internal/cli/skills_test.go | 34 ++++++++++++++++++++++++- cli/internal/cli/skills_v3_migration.go | 28 ++++++++++---------- 3 files changed, 52 insertions(+), 22 deletions(-) diff --git a/cli/internal/cli/skills.go b/cli/internal/cli/skills.go index e972300fd6..33dcf26531 100644 --- a/cli/internal/cli/skills.go +++ b/cli/internal/cli/skills.go @@ -130,16 +130,16 @@ func tryHandleSkillsRequest(args []string, startPath string, globalProjectPath s printSkillsTargetGuidance(subcommand, stdout) return true, 0 } - skills, err := collectV3MigrationSkillDefinition(projectRoot) - if err != nil { - writeClassifiedError(stderr, err, errorContext{projectRoot: projectRoot, command: skillsCommandName}) - return true, 1 - } switch subcommand { case "install-v3-migration": + skills, err := collectV3MigrationSkillDefinition(projectRoot) + if err != nil { + writeClassifiedError(stderr, err, errorContext{projectRoot: projectRoot, command: skillsCommandName}) + return true, 1 + } return true, runV3MigrationSkillInstall(projectRoot, skills, options, stdout, stderr) case "uninstall-v3-migration": - return true, runV3MigrationSkillUninstall(projectRoot, skills, options, stdout, stderr) + return true, runV3MigrationSkillUninstall(projectRoot, options, stdout, stderr) } } diff --git a/cli/internal/cli/skills_test.go b/cli/internal/cli/skills_test.go index 0d1552d1fa..63391796f4 100644 --- a/cli/internal/cli/skills_test.go +++ b/cli/internal/cli/skills_test.go @@ -977,7 +977,7 @@ name: v3-cli-invocation-migration unrelatedSkillDir := filepath.Join(projectRoot, ".codex", "skills", "uloop-compile") writeSkillFile(t, unrelatedSkillDir, "---\nname: uloop-compile\n---\n") - uninstallCode := runV3MigrationSkillUninstall(projectRoot, skills, options, stdout, stderr) + uninstallCode := runV3MigrationSkillUninstall(projectRoot, options, stdout, stderr) if uninstallCode != 0 { t.Fatalf("uninstall should succeed: code=%d stderr=%s", uninstallCode, stderr.String()) @@ -990,6 +990,38 @@ name: v3-cli-invocation-migration } } +// Tests that uninstalling the temporary migration skill does not require package source discovery. +func TestTryHandleSkillsRequestUninstallV3MigrationWithoutPackageSource(t *testing.T) { + originalUserHomeDir := userHomeDir + homeDir := t.TempDir() + userHomeDir = func() (string, error) { + return homeDir, nil + } + t.Cleanup(func() { + userHomeDir = originalUserHomeDir + }) + + installedDir := filepath.Join(homeDir, ".codex", "skills", v3MigrationSkillName) + writeSkillFile(t, installedDir, "---\nname: v3-cli-invocation-migration\n---\n") + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + + handled, code := tryHandleSkillsRequest( + []string{"skills", "uninstall-v3-migration", "--global", "--codex"}, + t.TempDir(), + "", + stdout, + stderr, + ) + + if !handled || code != 0 { + t.Fatalf("uninstall should succeed without package source: handled=%v code=%d stderr=%s", handled, code, stderr.String()) + } + if _, err := os.Stat(installedDir); !os.IsNotExist(err) { + t.Fatalf("migration skill should be removed: %v", err) + } +} + // Tests that the POSIX detector reports boolean, first-party, and output-field candidates. func TestV3MigrationSkillPosixDetectorReportsCandidates(t *testing.T) { fixtureRoot := t.TempDir() diff --git a/cli/internal/cli/skills_v3_migration.go b/cli/internal/cli/skills_v3_migration.go index 8f87baec63..a90f97698d 100644 --- a/cli/internal/cli/skills_v3_migration.go +++ b/cli/internal/cli/skills_v3_migration.go @@ -30,13 +30,13 @@ func runV3MigrationSkillInstall(projectRoot string, skills []skillDefinition, op return 0 } -func runV3MigrationSkillUninstall(projectRoot string, skills []skillDefinition, options skillCommandOptions, stdout io.Writer, stderr io.Writer) int { +func runV3MigrationSkillUninstall(projectRoot string, options skillCommandOptions, stdout io.Writer, stderr io.Writer) int { writeLine(stdout, "") writeFormat(stdout, "Uninstalling V3 CLI invocation migration skill (%s)...\n", skillLocationName(options.global)) writeLine(stdout, "") for _, target := range options.targets { grouped := groupManagedSkillsForOptions(options) - removed, notFound, err := uninstallV3MigrationSkillForTarget(projectRoot, target, skills, options.global, grouped) + removed, notFound, err := uninstallV3MigrationSkillForTarget(projectRoot, target, options.global, grouped) if err != nil { writeClassifiedError(stderr, err, errorContext{projectRoot: projectRoot, command: skillsCommandName}) return 1 @@ -86,28 +86,26 @@ func installV3MigrationSkillForTarget(projectRoot string, target skillTarget, sk return result, nil } -func uninstallV3MigrationSkillForTarget(projectRoot string, target skillTarget, skills []skillDefinition, global bool, grouped bool) (int, int, error) { +func uninstallV3MigrationSkillForTarget(projectRoot string, target skillTarget, global bool, grouped bool) (int, int, error) { removed := 0 notFound := 0 baseDir, err := getSkillsBaseDir(projectRoot, target, global) if err != nil { return removed, notFound, err } - for _, skill := range skills { - destinationDir := getPreferredSkillDir(baseDir, skill.name, grouped) - if _, err := os.Stat(destinationDir); err != nil { - if !os.IsNotExist(err) { - return removed, notFound, err - } - notFound++ - continue - } - if err := os.RemoveAll(destinationDir); err != nil { + destinationDir := getPreferredSkillDir(baseDir, v3MigrationSkillName, grouped) + if _, err := os.Stat(destinationDir); err != nil { + if !os.IsNotExist(err) { return removed, notFound, err } - removeEmptyMigrationSkillParent(baseDir, grouped) - removed++ + notFound++ + return removed, notFound, nil + } + if err := os.RemoveAll(destinationDir); err != nil { + return removed, notFound, err } + removeEmptyMigrationSkillParent(baseDir, grouped) + removed++ return removed, notFound, nil } From fe84ae53df173dfe3c74692cf6153a3046e57aaa Mon Sep 17 00:00:00 2001 From: hatayama Date: Sun, 21 Jun 2026 02:57:51 +0900 Subject: [PATCH 16/21] Remove migration skill from alternate layouts Make uninstall-v3-migration clear both flat and grouped install layouts so temporary migration skills do not remain after cleanup. --- cli/internal/cli/skills_test.go | 32 +++++++++++++++++++++++++ cli/internal/cli/skills_v3_migration.go | 20 ++++++++++------ 2 files changed, 45 insertions(+), 7 deletions(-) diff --git a/cli/internal/cli/skills_test.go b/cli/internal/cli/skills_test.go index 63391796f4..df416939c9 100644 --- a/cli/internal/cli/skills_test.go +++ b/cli/internal/cli/skills_test.go @@ -1022,6 +1022,38 @@ func TestTryHandleSkillsRequestUninstallV3MigrationWithoutPackageSource(t *testi } } +// Tests that uninstalling the temporary migration skill removes both supported layouts. +func TestRunV3MigrationSkillUninstallRemovesAlternateLayout(t *testing.T) { + projectRoot := t.TempDir() + target := targetConfigs["codex"] + baseDir, err := getSkillsBaseDir(projectRoot, target, false) + if err != nil { + t.Fatalf("getSkillsBaseDir failed: %v", err) + } + flatDir := getPreferredSkillDir(baseDir, v3MigrationSkillName, false) + groupedDir := getPreferredSkillDir(baseDir, v3MigrationSkillName, true) + writeSkillFile(t, flatDir, "---\nname: v3-cli-invocation-migration\n---\n") + writeSkillFile(t, groupedDir, "---\nname: v3-cli-invocation-migration\n---\n") + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + options := skillCommandOptions{targets: []skillTarget{target}} + + code := runV3MigrationSkillUninstall(projectRoot, options, stdout, stderr) + + if code != 0 { + t.Fatalf("uninstall should succeed: code=%d stderr=%s", code, stderr.String()) + } + if _, err := os.Stat(flatDir); !os.IsNotExist(err) { + t.Fatalf("flat migration skill should be removed: %v", err) + } + if _, err := os.Stat(groupedDir); !os.IsNotExist(err) { + t.Fatalf("grouped migration skill should be removed: %v", err) + } + if !strings.Contains(stdout.String(), "Removed: 1") { + t.Fatalf("removed count should report one migration skill: %s", stdout.String()) + } +} + // Tests that the POSIX detector reports boolean, first-party, and output-field candidates. func TestV3MigrationSkillPosixDetectorReportsCandidates(t *testing.T) { fixtureRoot := t.TempDir() diff --git a/cli/internal/cli/skills_v3_migration.go b/cli/internal/cli/skills_v3_migration.go index a90f97698d..5e7b646e61 100644 --- a/cli/internal/cli/skills_v3_migration.go +++ b/cli/internal/cli/skills_v3_migration.go @@ -93,18 +93,24 @@ func uninstallV3MigrationSkillForTarget(projectRoot string, target skillTarget, if err != nil { return removed, notFound, err } - destinationDir := getPreferredSkillDir(baseDir, v3MigrationSkillName, grouped) - if _, err := os.Stat(destinationDir); err != nil { - if !os.IsNotExist(err) { + removedAny := false + for _, layoutGrouped := range []bool{grouped, !grouped} { + destinationDir := getPreferredSkillDir(baseDir, v3MigrationSkillName, layoutGrouped) + exists, err := removeDirIfExists(destinationDir) + if err != nil { return removed, notFound, err } + if !exists { + continue + } + removeEmptyMigrationSkillParent(baseDir, layoutGrouped) + removedAny = true + } + + if !removedAny { notFound++ return removed, notFound, nil } - if err := os.RemoveAll(destinationDir); err != nil { - return removed, notFound, err - } - removeEmptyMigrationSkillParent(baseDir, grouped) removed++ return removed, notFound, nil } From 18cba3492d058780359b2163b24fc5dde1d8c153 Mon Sep 17 00:00:00 2001 From: hatayama Date: Sun, 21 Jun 2026 10:11:46 +0900 Subject: [PATCH 17/21] Hide temporary migration skill from Unity import Move the bundled migration skill source under a tilde-suffixed folder so Unity does not import it as package content. Update the CLI lookup path and detector self-exclusion to match the ignored folder name, and drop Unity meta files that are no longer needed for the ignored source tree. --- Packages/src/Editor/Domain/CliConstants.cs | 2 +- Packages/src/TemporarySkills.meta | 8 ------- .../v3-cli-invocation-migration.meta | 8 ------- .../v3-cli-invocation-migration/Skill.meta | 8 ------- .../Skill/SKILL.md.meta | 7 ------ .../Skill/references.meta | 8 ------- .../references/first-party-v2-to-v3.md.meta | 7 ------ .../Skill/scripts.meta | 8 ------- ...tect-v3-cli-invocation-candidates.ps1.meta | 7 ------ ...etect-v3-cli-invocation-candidates.sh.meta | 7 ------ .../Skill/SKILL.md | 0 .../Skill/references/first-party-v2-to-v3.md | 0 .../detect-v3-cli-invocation-candidates.ps1 | 2 +- .../detect-v3-cli-invocation-candidates.sh | 2 +- cli/internal/cli/skills_discovery.go | 2 +- cli/internal/cli/skills_test.go | 22 +++++++++---------- 16 files changed, 15 insertions(+), 83 deletions(-) delete mode 100644 Packages/src/TemporarySkills.meta delete mode 100644 Packages/src/TemporarySkills/v3-cli-invocation-migration.meta delete mode 100644 Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill.meta delete mode 100644 Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill/SKILL.md.meta delete mode 100644 Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill/references.meta delete mode 100644 Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill/references/first-party-v2-to-v3.md.meta delete mode 100644 Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill/scripts.meta delete mode 100644 Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill/scripts/detect-v3-cli-invocation-candidates.ps1.meta delete mode 100644 Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill/scripts/detect-v3-cli-invocation-candidates.sh.meta rename Packages/src/{TemporarySkills => TemporarySkills~}/v3-cli-invocation-migration/Skill/SKILL.md (100%) rename Packages/src/{TemporarySkills => TemporarySkills~}/v3-cli-invocation-migration/Skill/references/first-party-v2-to-v3.md (100%) rename Packages/src/{TemporarySkills => TemporarySkills~}/v3-cli-invocation-migration/Skill/scripts/detect-v3-cli-invocation-candidates.ps1 (99%) rename Packages/src/{TemporarySkills => TemporarySkills~}/v3-cli-invocation-migration/Skill/scripts/detect-v3-cli-invocation-candidates.sh (93%) diff --git a/Packages/src/Editor/Domain/CliConstants.cs b/Packages/src/Editor/Domain/CliConstants.cs index 531806c9b1..531fdf0884 100644 --- a/Packages/src/Editor/Domain/CliConstants.cs +++ b/Packages/src/Editor/Domain/CliConstants.cs @@ -38,7 +38,7 @@ 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 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"; diff --git a/Packages/src/TemporarySkills.meta b/Packages/src/TemporarySkills.meta deleted file mode 100644 index 82074c7acd..0000000000 --- a/Packages/src/TemporarySkills.meta +++ /dev/null @@ -1,8 +0,0 @@ -fileFormatVersion: 2 -guid: ebaa76c7aafd48cc895c972d68829ddf -folderAsset: yes -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Packages/src/TemporarySkills/v3-cli-invocation-migration.meta b/Packages/src/TemporarySkills/v3-cli-invocation-migration.meta deleted file mode 100644 index b52a7168c4..0000000000 --- a/Packages/src/TemporarySkills/v3-cli-invocation-migration.meta +++ /dev/null @@ -1,8 +0,0 @@ -fileFormatVersion: 2 -guid: 72a6b872d1684e7691c106b839cdeeb3 -folderAsset: yes -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill.meta b/Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill.meta deleted file mode 100644 index 7a3d9fe3bd..0000000000 --- a/Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill.meta +++ /dev/null @@ -1,8 +0,0 @@ -fileFormatVersion: 2 -guid: 1375da47d449464d8da8a19f5c65a3c7 -folderAsset: yes -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill/SKILL.md.meta b/Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill/SKILL.md.meta deleted file mode 100644 index 382ab387bb..0000000000 --- a/Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill/SKILL.md.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: 55ff422e16f342aabce4d19ae60c6424 -TextScriptImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill/references.meta b/Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill/references.meta deleted file mode 100644 index 6f6bb23cdb..0000000000 --- a/Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill/references.meta +++ /dev/null @@ -1,8 +0,0 @@ -fileFormatVersion: 2 -guid: 7078ee478d8d4e2bade92e86d552fbd4 -folderAsset: yes -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill/references/first-party-v2-to-v3.md.meta b/Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill/references/first-party-v2-to-v3.md.meta deleted file mode 100644 index 1a311a8cfe..0000000000 --- a/Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill/references/first-party-v2-to-v3.md.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: b315d085dae6446db01b8b798efd1e23 -TextScriptImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill/scripts.meta b/Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill/scripts.meta deleted file mode 100644 index 897a7bdfa0..0000000000 --- a/Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill/scripts.meta +++ /dev/null @@ -1,8 +0,0 @@ -fileFormatVersion: 2 -guid: 6a519f6bea7449aab106bb5b519c1a7a -folderAsset: yes -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill/scripts/detect-v3-cli-invocation-candidates.ps1.meta b/Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill/scripts/detect-v3-cli-invocation-candidates.ps1.meta deleted file mode 100644 index 666ec1b336..0000000000 --- a/Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill/scripts/detect-v3-cli-invocation-candidates.ps1.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: ed2b2018890040219507f0bbbba108a0 -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill/scripts/detect-v3-cli-invocation-candidates.sh.meta b/Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill/scripts/detect-v3-cli-invocation-candidates.sh.meta deleted file mode 100644 index 0753f0771a..0000000000 --- a/Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill/scripts/detect-v3-cli-invocation-candidates.sh.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: 66729895939a4e81865297618946ad49 -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill/SKILL.md b/Packages/src/TemporarySkills~/v3-cli-invocation-migration/Skill/SKILL.md similarity index 100% rename from Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill/SKILL.md rename to Packages/src/TemporarySkills~/v3-cli-invocation-migration/Skill/SKILL.md diff --git a/Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill/references/first-party-v2-to-v3.md b/Packages/src/TemporarySkills~/v3-cli-invocation-migration/Skill/references/first-party-v2-to-v3.md similarity index 100% rename from Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill/references/first-party-v2-to-v3.md rename to Packages/src/TemporarySkills~/v3-cli-invocation-migration/Skill/references/first-party-v2-to-v3.md diff --git a/Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill/scripts/detect-v3-cli-invocation-candidates.ps1 b/Packages/src/TemporarySkills~/v3-cli-invocation-migration/Skill/scripts/detect-v3-cli-invocation-candidates.ps1 similarity index 99% rename from Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill/scripts/detect-v3-cli-invocation-candidates.ps1 rename to Packages/src/TemporarySkills~/v3-cli-invocation-migration/Skill/scripts/detect-v3-cli-invocation-candidates.ps1 index fceec29521..798f1f4c5c 100644 --- a/Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill/scripts/detect-v3-cli-invocation-candidates.ps1 +++ b/Packages/src/TemporarySkills~/v3-cli-invocation-migration/Skill/scripts/detect-v3-cli-invocation-candidates.ps1 @@ -19,7 +19,7 @@ $excludedDirectories = @( $targetExtensions = @(".md", ".sh", ".bash", ".zsh", ".ps1", ".psm1") $outputFields = "Success|Message|ErrorMessage|ErrorCount|WarningCount|TotalCount|DisplayedCount|LogType|StackTrace|XmlPath|TestCount|PassedCount|FailedCount|SkippedCount|CompletedAt|ScreenshotCount|Screenshots|CompilationErrors|ErrorCode|UpdatedCode|DiagnosticsSummary|OutputPath|InputPath|TotalFrames|DurationSeconds|CurrentFrame|IsReplaying|KeyName|PositionX|PositionY|EndPositionX|EndPositionY|HitGameObjectName|IsPlaying|IsPaused|ClearedLogCount|ClearedCounts" $bundledMigrationSkillPath = [System.IO.Path]::Combine( - "TemporarySkills", + "TemporarySkills~", "v3-cli-invocation-migration" ) diff --git a/Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill/scripts/detect-v3-cli-invocation-candidates.sh b/Packages/src/TemporarySkills~/v3-cli-invocation-migration/Skill/scripts/detect-v3-cli-invocation-candidates.sh similarity index 93% rename from Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill/scripts/detect-v3-cli-invocation-candidates.sh rename to Packages/src/TemporarySkills~/v3-cli-invocation-migration/Skill/scripts/detect-v3-cli-invocation-candidates.sh index 6031d4483b..53dd1e5b5a 100755 --- a/Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill/scripts/detect-v3-cli-invocation-candidates.sh +++ b/Packages/src/TemporarySkills~/v3-cli-invocation-migration/Skill/scripts/detect-v3-cli-invocation-candidates.sh @@ -4,7 +4,7 @@ set -eu root=${1:-.} find "$root" \ - \( -name .git -o -name Library -o -name Temp -o -name node_modules -o -name .agents -o -name .claude -o -name .codex -o -name .cursor -o -name .gemini -o -name .windsurf -o -name .agent -o -path '*/TemporarySkills/v3-cli-invocation-migration' -o -path 'TemporarySkills/v3-cli-invocation-migration' \) -prune -o \ + \( -name .git -o -name Library -o -name Temp -o -name node_modules -o -name .agents -o -name .claude -o -name .codex -o -name .cursor -o -name .gemini -o -name .windsurf -o -name .agent -o -path '*/TemporarySkills~/v3-cli-invocation-migration' -o -path 'TemporarySkills~/v3-cli-invocation-migration' \) -prune -o \ -type f \( -name SKILL.md -o -name '*.md' -o -name '*.sh' -o -name '*.bash' -o -name '*.zsh' -o -name '*.ps1' -o -name '*.psm1' \) -print | while IFS= read -r file do diff --git a/cli/internal/cli/skills_discovery.go b/cli/internal/cli/skills_discovery.go index a58b02bf8f..4cc7b8c04f 100644 --- a/cli/internal/cli/skills_discovery.go +++ b/cli/internal/cli/skills_discovery.go @@ -40,7 +40,7 @@ func collectV3MigrationSkillDefinition(projectRoot string) ([]skillDefinition, e skillDirectory := filepath.Join( packageRoot, - "TemporarySkills", + "TemporarySkills~", v3MigrationSkillName, "Skill") skill, ok, err := readSkillDefinition(skillDirectory) diff --git a/cli/internal/cli/skills_test.go b/cli/internal/cli/skills_test.go index df416939c9..382af94095 100644 --- a/cli/internal/cli/skills_test.go +++ b/cli/internal/cli/skills_test.go @@ -135,7 +135,7 @@ name: uloop-cached-package // Tests that the temporary V3 migration skill stays out of normal skill discovery. func TestCollectSkillDefinitionsSkipsV3MigrationSkill(t *testing.T) { projectRoot := t.TempDir() - writeTestSkill(t, projectRoot, "Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill", `--- + writeTestSkill(t, projectRoot, "Packages/src/TemporarySkills~/v3-cli-invocation-migration/Skill", `--- name: v3-cli-invocation-migration --- @@ -156,7 +156,7 @@ name: v3-cli-invocation-migration func TestCollectV3MigrationSkillDefinitionReadsTemporarySkill(t *testing.T) { projectRoot := t.TempDir() writePackageRootMarker(t, projectRoot) - writeTestSkill(t, projectRoot, "Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill", `--- + writeTestSkill(t, projectRoot, "Packages/src/TemporarySkills~/v3-cli-invocation-migration/Skill", `--- name: v3-cli-invocation-migration --- @@ -186,7 +186,7 @@ func TestCollectV3MigrationSkillDefinitionReadsManifestFilePackage(t *testing.T) writeManifest(t, projectRoot, `{"dependencies":{"io.github.hatayama.uloopmcp":"file:`+filepath.ToSlash(packageRoot)+`"}}`) writeSkillFile(t, filepath.Join( packageRoot, - "TemporarySkills", + "TemporarySkills~", "v3-cli-invocation-migration", "Skill"), `--- name: v3-cli-invocation-migration @@ -937,19 +937,19 @@ name: uloop-sample func TestRunV3MigrationSkillInstallAndUninstall(t *testing.T) { projectRoot := t.TempDir() writePackageRootMarker(t, projectRoot) - writeTestSkill(t, projectRoot, "Packages/src/TemporarySkills/v3-cli-invocation-migration/Skill", `--- + writeTestSkill(t, projectRoot, "Packages/src/TemporarySkills~/v3-cli-invocation-migration/Skill", `--- name: v3-cli-invocation-migration --- # temporary migration `) if err := os.MkdirAll( - filepath.Join(projectRoot, "Packages", "src", "TemporarySkills", "v3-cli-invocation-migration", "Skill", "scripts"), + filepath.Join(projectRoot, "Packages", "src", "TemporarySkills~", "v3-cli-invocation-migration", "Skill", "scripts"), 0o755); err != nil { t.Fatalf("failed to create script dir: %v", err) } if err := os.WriteFile( - filepath.Join(projectRoot, "Packages", "src", "TemporarySkills", "v3-cli-invocation-migration", "Skill", "scripts", "detect.sh"), + filepath.Join(projectRoot, "Packages", "src", "TemporarySkills~", "v3-cli-invocation-migration", "Skill", "scripts", "detect.sh"), []byte("#!/bin/sh\n"), 0o644); err != nil { t.Fatalf("failed to write script: %v", err) @@ -1075,7 +1075,7 @@ func TestV3MigrationSkillPosixDetectorReportsCandidates(t *testing.T) { fixtureRoot, "Packages", "src", - "TemporarySkills", + "TemporarySkills~", "v3-cli-invocation-migration", "Skill", "references", @@ -1093,7 +1093,7 @@ func TestV3MigrationSkillPosixDetectorReportsCandidates(t *testing.T) { fixtureRoot, "Packages", "io.github.hatayama.uloopmcp", - "TemporarySkills", + "TemporarySkills~", "v3-cli-invocation-migration", "Skill", "references", @@ -1111,7 +1111,7 @@ func TestV3MigrationSkillPosixDetectorReportsCandidates(t *testing.T) { findRepositoryRootForSkillsTest(t), "Packages", "src", - "TemporarySkills", + "TemporarySkills~", "v3-cli-invocation-migration", "Skill", "scripts", @@ -1167,7 +1167,7 @@ func TestV3MigrationSkillPowerShellDetectorReportsCandidates(t *testing.T) { fixtureRoot, "Packages", "io.github.hatayama.uloopmcp", - "TemporarySkills", + "TemporarySkills~", "v3-cli-invocation-migration", "Skill", "references", @@ -1185,7 +1185,7 @@ func TestV3MigrationSkillPowerShellDetectorReportsCandidates(t *testing.T) { findRepositoryRootForSkillsTest(t), "Packages", "src", - "TemporarySkills", + "TemporarySkills~", "v3-cli-invocation-migration", "Skill", "scripts", From 804c6f821c30413de4f365b75aaf881cf64a1b26 Mon Sep 17 00:00:00 2001 From: hatayama Date: Sun, 21 Jun 2026 10:49:43 +0900 Subject: [PATCH 18/21] Harden V3 migration skill detection Validate that the dedicated migration skill source declares the expected name before installation, avoid following PowerShell reparse point directories during scans, simplify the POSIX self-exclusion pattern, and make the POSIX detector test skip when sh is unavailable. --- .../detect-v3-cli-invocation-candidates.ps1 | 7 +++++ .../detect-v3-cli-invocation-candidates.sh | 2 +- cli/internal/cli/skills_discovery.go | 3 +++ cli/internal/cli/skills_test.go | 27 ++++++++++++++++++- 4 files changed, 37 insertions(+), 2 deletions(-) diff --git a/Packages/src/TemporarySkills~/v3-cli-invocation-migration/Skill/scripts/detect-v3-cli-invocation-candidates.ps1 b/Packages/src/TemporarySkills~/v3-cli-invocation-migration/Skill/scripts/detect-v3-cli-invocation-candidates.ps1 index 798f1f4c5c..35fda63919 100644 --- a/Packages/src/TemporarySkills~/v3-cli-invocation-migration/Skill/scripts/detect-v3-cli-invocation-candidates.ps1 +++ b/Packages/src/TemporarySkills~/v3-cli-invocation-migration/Skill/scripts/detect-v3-cli-invocation-candidates.ps1 @@ -35,6 +35,12 @@ function Test-IsTargetFile { return $File.Name -eq "SKILL.md" -or $targetExtensions -contains $File.Extension } +function Test-IsReparsePointDirectory { + param([System.IO.DirectoryInfo] $Directory) + + return ($Directory.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0 +} + function Test-IsBundledMigrationSkillPath { param([string] $Path) @@ -66,6 +72,7 @@ function Get-CandidateFile { Get-ChildItem -LiteralPath $Directory -Directory | Where-Object { -not (Test-IsExcludedDirectoryName -Name $_.Name) -and + -not (Test-IsReparsePointDirectory -Directory $_) -and -not (Test-IsBundledMigrationSkillPath -Path $_.FullName) } | ForEach-Object { diff --git a/Packages/src/TemporarySkills~/v3-cli-invocation-migration/Skill/scripts/detect-v3-cli-invocation-candidates.sh b/Packages/src/TemporarySkills~/v3-cli-invocation-migration/Skill/scripts/detect-v3-cli-invocation-candidates.sh index 53dd1e5b5a..9e82b804ee 100755 --- a/Packages/src/TemporarySkills~/v3-cli-invocation-migration/Skill/scripts/detect-v3-cli-invocation-candidates.sh +++ b/Packages/src/TemporarySkills~/v3-cli-invocation-migration/Skill/scripts/detect-v3-cli-invocation-candidates.sh @@ -4,7 +4,7 @@ set -eu root=${1:-.} find "$root" \ - \( -name .git -o -name Library -o -name Temp -o -name node_modules -o -name .agents -o -name .claude -o -name .codex -o -name .cursor -o -name .gemini -o -name .windsurf -o -name .agent -o -path '*/TemporarySkills~/v3-cli-invocation-migration' -o -path 'TemporarySkills~/v3-cli-invocation-migration' \) -prune -o \ + \( -name .git -o -name Library -o -name Temp -o -name node_modules -o -name .agents -o -name .claude -o -name .codex -o -name .cursor -o -name .gemini -o -name .windsurf -o -name .agent -o -path '*/TemporarySkills~/v3-cli-invocation-migration' \) -prune -o \ -type f \( -name SKILL.md -o -name '*.md' -o -name '*.sh' -o -name '*.bash' -o -name '*.zsh' -o -name '*.ps1' -o -name '*.psm1' \) -print | while IFS= read -r file do diff --git a/cli/internal/cli/skills_discovery.go b/cli/internal/cli/skills_discovery.go index 4cc7b8c04f..46c3b659a2 100644 --- a/cli/internal/cli/skills_discovery.go +++ b/cli/internal/cli/skills_discovery.go @@ -50,6 +50,9 @@ func collectV3MigrationSkillDefinition(projectRoot string) ([]skillDefinition, e if !ok { return nil, errors.New("v3 CLI invocation migration skill source was not found") } + if skill.name != v3MigrationSkillName { + return nil, errors.New("v3 CLI invocation migration skill source has an unexpected name") + } return []skillDefinition{skill}, nil } diff --git a/cli/internal/cli/skills_test.go b/cli/internal/cli/skills_test.go index 382af94095..a10d591100 100644 --- a/cli/internal/cli/skills_test.go +++ b/cli/internal/cli/skills_test.go @@ -175,6 +175,27 @@ name: v3-cli-invocation-migration } } +// Tests that the dedicated V3 migration source must declare the expected skill name. +func TestCollectV3MigrationSkillDefinitionRejectsUnexpectedName(t *testing.T) { + projectRoot := t.TempDir() + writePackageRootMarker(t, projectRoot) + writeTestSkill(t, projectRoot, "Packages/src/TemporarySkills~/v3-cli-invocation-migration/Skill", `--- +name: other-migration-skill +--- + +# temporary migration +`) + + _, err := collectV3MigrationSkillDefinition(projectRoot) + + if err == nil { + t.Fatal("collectV3MigrationSkillDefinition should fail for an unexpected skill name") + } + if !strings.Contains(err.Error(), "unexpected name") { + t.Fatalf("error should explain the unexpected skill name: %v", err) + } +} + // Tests that the dedicated V3 migration skill can be read from a manifest file dependency. func TestCollectV3MigrationSkillDefinitionReadsManifestFilePackage(t *testing.T) { projectRoot := t.TempDir() @@ -1117,7 +1138,11 @@ func TestV3MigrationSkillPosixDetectorReportsCandidates(t *testing.T) { "scripts", "detect-v3-cli-invocation-candidates.sh") - command := exec.Command("sh", scriptPath, fixtureRoot) + shPath, err := exec.LookPath("sh") + if err != nil { + t.Skip("sh is not installed") + } + command := exec.Command(shPath, scriptPath, fixtureRoot) output, err := command.CombinedOutput() if err != nil { t.Fatalf("detector failed: %v\n%s", err, string(output)) From dfae61784822a1413a6e53b95f0c572a31b9f233 Mon Sep 17 00:00:00 2001 From: hatayama Date: Sun, 21 Jun 2026 11:23:49 +0900 Subject: [PATCH 19/21] Fix Windows migration skill verification Read PowerShell detector inputs as UTF-8 so Windows PowerShell reports physical line numbers for UTF-8 no-BOM files. Normalize single-skill source paths before syncing so nested migration skill files are not deleted on Windows. --- .../SkillSetup/SkillInstallLayout.cs | 14 +++++- .../detect-v3-cli-invocation-candidates.ps1 | 2 +- cli/internal/cli/skills_test.go | 44 +++++++++++++++++++ 3 files changed, 58 insertions(+), 2 deletions(-) diff --git a/Packages/src/Editor/Infrastructure/SkillSetup/SkillInstallLayout.cs b/Packages/src/Editor/Infrastructure/SkillSetup/SkillInstallLayout.cs index cace82a645..949a448dea 100644 --- a/Packages/src/Editor/Infrastructure/SkillSetup/SkillInstallLayout.cs +++ b/Packages/src/Editor/Infrastructure/SkillSetup/SkillInstallLayout.cs @@ -81,10 +81,22 @@ public SkillSourceInfo( { Name = name; ToolName = toolName; - SkillFiles = skillFiles; + SkillFiles = skillFiles.ToDictionary( + pair => NormalizeSkillRelativePath(pair.Key), + pair => pair.Value, + StringComparer.Ordinal); } } + private static string NormalizeSkillRelativePath(string relativePath) + { + Debug.Assert(!string.IsNullOrEmpty(relativePath), "relativePath must not be null or empty"); + + return relativePath.Replace( + Path.AltDirectorySeparatorChar, + Path.DirectorySeparatorChar); + } + internal static string GetSkillsRoot(string targetRoot) { return Path.Combine(targetRoot, SkillsDirName); diff --git a/Packages/src/TemporarySkills~/v3-cli-invocation-migration/Skill/scripts/detect-v3-cli-invocation-candidates.ps1 b/Packages/src/TemporarySkills~/v3-cli-invocation-migration/Skill/scripts/detect-v3-cli-invocation-candidates.ps1 index 35fda63919..51d6750899 100644 --- a/Packages/src/TemporarySkills~/v3-cli-invocation-migration/Skill/scripts/detect-v3-cli-invocation-candidates.ps1 +++ b/Packages/src/TemporarySkills~/v3-cli-invocation-migration/Skill/scripts/detect-v3-cli-invocation-candidates.ps1 @@ -94,7 +94,7 @@ function Write-Candidate { Get-CandidateFile -Directory $Root | ForEach-Object { $path = $_.FullName - $lines = @(Get-Content -LiteralPath $path) + $lines = @(Get-Content -LiteralPath $path -Encoding UTF8) for ($index = 0; $index -lt $lines.Count; $index++) { $line = $lines[$index] $lineNumber = $index + 1 diff --git a/cli/internal/cli/skills_test.go b/cli/internal/cli/skills_test.go index a10d591100..d6d2b1b080 100644 --- a/cli/internal/cli/skills_test.go +++ b/cli/internal/cli/skills_test.go @@ -1243,6 +1243,50 @@ func TestV3MigrationSkillPowerShellDetectorReportsCandidates(t *testing.T) { } } +// Tests that Windows PowerShell reports UTF-8 no-BOM file line numbers correctly. +func TestV3MigrationSkillWindowsPowerShellDetectorReportsUtf8LineNumbers(t *testing.T) { + powerShellPath, err := exec.LookPath("powershell") + if err != nil { + t.Skip("powershell is not installed") + } + fixtureRoot := t.TempDir() + fixtureFile := filepath.Join(fixtureRoot, "sample.md") + fixtureContent := strings.Join([]string{ + "# Plan", + "", + "## 検証", + "", + "日本語の説明文です。UTF-8 BOMなしの行番号を検証します。", + "", + "もう一つの日本語行です。PowerShellの既定読み込みとの差を確認します。", + "", + "uloop compile --wait-for-domain-reload", + }, "\n") + if err := os.WriteFile(fixtureFile, []byte(fixtureContent), 0o644); err != nil { + t.Fatalf("failed to write UTF-8 fixture: %v", err) + } + scriptPath := filepath.Join( + findRepositoryRootForSkillsTest(t), + "Packages", + "src", + "TemporarySkills~", + "v3-cli-invocation-migration", + "Skill", + "scripts", + "detect-v3-cli-invocation-candidates.ps1") + + command := exec.Command(powerShellPath, "-NoProfile", "-File", scriptPath, "-Root", fixtureRoot) + output, err := command.CombinedOutput() + if err != nil { + t.Fatalf("detector failed: %v\n%s", err, string(output)) + } + text := string(output) + expected := "sample.md:9: uloop compile --wait-for-domain-reload" + if !strings.Contains(text, expected) { + t.Fatalf("detector output missing UTF-8 physical line number %q:\n%s", expected, text) + } +} + func writeTestSkill(t *testing.T, projectRoot string, relativeDir string, content string) { t.Helper() writeSkillFile(t, filepath.Join(projectRoot, filepath.FromSlash(relativeDir)), content) From 9f97ad6d3efb47b2c4cde79d869f9c56aaaa171b Mon Sep 17 00:00:00 2001 From: hatayama Date: Sun, 21 Jun 2026 11:31:29 +0900 Subject: [PATCH 20/21] Add Windows compatibility guardrails Document encoding, line ending, path separator, and shell validation rules so macOS-first changes are checked against Windows failure modes before they regress again. --- AGENTS.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index dbb03bd2a9..8be7967a04 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. From 2a9231e673255b6336384d20b39df8aab980dfb2 Mon Sep 17 00:00:00 2001 From: hatayama Date: Sun, 21 Jun 2026 19:36:45 +0900 Subject: [PATCH 21/21] Fix Windows path comparison in skill test Normalize the manifest package-root assertion before comparing paths so the Windows test accepts equivalent slash and backslash forms. --- cli/internal/cli/skills_test.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/cli/internal/cli/skills_test.go b/cli/internal/cli/skills_test.go index d6d2b1b080..a86d20f537 100644 --- a/cli/internal/cli/skills_test.go +++ b/cli/internal/cli/skills_test.go @@ -244,9 +244,10 @@ func TestResolvePackageRootIgnoresOtherManifestFilePackages(t *testing.T) { projectRoot, `{"dependencies":{"com.example.other":"file:`+filepath.ToSlash(otherPackageRoot)+`","io.github.hatayama.uloopmcp":"file:`+filepath.ToSlash(packageRoot)+`"}}`) - actualRoot := resolvePackageRoot(projectRoot) - if actualRoot != packageRoot { - t.Fatalf("package root mismatch: actual=%s expected=%s", actualRoot, packageRoot) + actualRoot := filepath.Clean(resolvePackageRoot(projectRoot)) + expectedRoot := filepath.Clean(packageRoot) + if actualRoot != expectedRoot { + t.Fatalf("package root mismatch: actual=%s expected=%s", actualRoot, expectedRoot) } }