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. diff --git a/Assets/Tests/Editor/ThirdPartyToolMigrationWizardWindowTests.cs b/Assets/Tests/Editor/ThirdPartyToolMigrationWizardWindowTests.cs index 812f93285d..cd97a7b318 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")] @@ -162,6 +162,38 @@ public void GetMigrationButtonText_ReturnsExpectedLabel( Assert.That(label, Is.EqualTo(expectedLabel)); } + [TestCase(false, SkillInstallState.Missing, "Install Migration Skill")] + [TestCase(false, SkillInstallState.Checking, "Install Migration Skill")] + [TestCase(false, SkillInstallState.Installed, "Remove Migration Skill")] + [TestCase(false, SkillInstallState.Outdated, "Remove Migration Skill")] + [TestCase(true, SkillInstallState.Installed, "Updating...")] + public void GetMigrationSkillButtonText_ReturnsExpectedLabel( + bool isUpdating, + SkillInstallState installState, + string expectedLabel) + { + // Verifies that the migration skill action reflects install, remove, and busy states. + string label = ThirdPartyToolMigrationWizardWindow.GetMigrationSkillButtonText( + isUpdating, + installState); + + Assert.That(label, Is.EqualTo(expectedLabel)); + } + + [TestCase(SkillInstallState.Installed, true)] + [TestCase(SkillInstallState.Outdated, true)] + [TestCase(SkillInstallState.Missing, false)] + [TestCase(SkillInstallState.Checking, false)] + public void ShouldRemoveMigrationSkill_ReturnsExpectedValue( + SkillInstallState installState, + bool expected) + { + // Verifies that the migration skill action removes only when files are detected. + bool shouldRemove = ThirdPartyToolMigrationWizardWindow.ShouldRemoveMigrationSkill(installState); + + Assert.That(shouldRemove, Is.EqualTo(expected)); + } + [TestCase(0, 1, 1, 4, 1000, 100, true)] [TestCase(10, 50, 1, 4, 1000, 100, false)] [TestCase(10, 110, 1, 4, 1000, 100, true)] diff --git a/Assets/Tests/Editor/ToolSkillSynchronizerTests.cs b/Assets/Tests/Editor/ToolSkillSynchronizerTests.cs index 21166184e3..acbb2383b0 100644 --- a/Assets/Tests/Editor/ToolSkillSynchronizerTests.cs +++ b/Assets/Tests/Editor/ToolSkillSynchronizerTests.cs @@ -1640,6 +1640,153 @@ public void DetectTargets_WhenSourceSkillNameContainsUnsafePathCharacters_Ignore Assert.That(detectedTargets[0].InstallState, Is.EqualTo(SkillInstallState.Missing)); } + [Test] + public async Task InstallSpecificSkillFilesAtProjectRoot_WhenSingleSkillSourceIsProvided_InstallsOnlyThatSkill() + { + // Tests that temporary migration skill installation can reuse the synchronizer without installing normal skills. + string temporaryRoot = CreateTemporaryProjectRoot(); + ToolSkillSynchronizer.SkillTargetInfo target = new( + "Codex CLI", + ".codex", + "--codex", + hasSkillsDirectory: true, + hasExistingSkills: false); + SkillInstallLayout.SkillSourceInfo skill = new( + CliConstants.V3_CLI_INVOCATION_MIGRATION_SKILL_NAME, + string.Empty, + new Dictionary + { + [SkillInstallLayout.SkillFileName] = Encoding.UTF8.GetBytes( + "---\nname: v3-cli-invocation-migration\n---\n"), + ["scripts/detect.sh"] = Encoding.UTF8.GetBytes("#!/bin/sh\n") + }); + + ToolSkillSynchronizer.SkillInstallResult result = + await ToolSkillSynchronizer.InstallSpecificSkillFilesAtProjectRoot( + temporaryRoot, + new[] { target }, + skill, + groupSkillsUnderUnityCliLoop: false); + + string installedSkillDir = Path.Combine( + temporaryRoot, + ".codex", + SkillInstallLayout.SkillsDirName, + CliConstants.V3_CLI_INVOCATION_MIGRATION_SKILL_NAME); + SkillInstallState installState = ToolSkillSynchronizer.GetSkillInstallStateAtProjectRoot( + temporaryRoot, + target, + skill, + groupSkillsUnderUnityCliLoop: false); + + Assert.That(result.IsSuccessful, Is.True); + Assert.That(File.Exists(Path.Combine(installedSkillDir, SkillInstallLayout.SkillFileName)), Is.True); + Assert.That(File.Exists(Path.Combine(installedSkillDir, "scripts", "detect.sh")), Is.True); + Assert.That(installState, Is.EqualTo(SkillInstallState.Installed)); + } + + [Test] + public async Task GetV3MigrationSkillInstallStateAtProjectRoot_WhenSkillExistsInAlternateLayout_ReturnsInstalled() + { + // Tests that the temporary migration skill is detected even when it exists in the alternate layout. + string temporaryRoot = CreateTemporaryProjectRoot(); + ToolSkillSynchronizer.SkillTargetInfo target = new( + "Claude Code", + ".claude", + "--claude", + hasSkillsDirectory: true, + hasExistingSkills: false); + + ToolSkillSynchronizer.SkillInstallResult result = + await ToolSkillSynchronizer.InstallV3MigrationSkillFilesAtProjectRoot( + temporaryRoot, + new[] { target }, + groupSkillsUnderUnityCliLoop: true); + + SkillInstallState installState = ToolSkillSynchronizer.GetV3MigrationSkillInstallStateAtProjectRoot( + temporaryRoot, + target, + groupSkillsUnderUnityCliLoop: false); + + Assert.That(result.IsSuccessful, Is.True); + Assert.That(installState, Is.EqualTo(SkillInstallState.Installed)); + } + + [Test] + public async Task RemoveSpecificSkillFilesAtProjectRoot_WhenSkillExists_RemovesOnlyThatSkill() + { + // Tests that temporary migration skill removal leaves unrelated installed skills in place. + string temporaryRoot = CreateTemporaryProjectRoot(); + ToolSkillSynchronizer.SkillTargetInfo target = new( + "Codex CLI", + ".codex", + "--codex", + hasSkillsDirectory: true, + hasExistingSkills: false); + string targetRoot = Path.Combine(temporaryRoot, ".codex"); + string migrationSkillDir = Path.Combine( + targetRoot, + SkillInstallLayout.SkillsDirName, + CliConstants.V3_CLI_INVOCATION_MIGRATION_SKILL_NAME); + string unrelatedSkillDir = Path.Combine( + targetRoot, + SkillInstallLayout.SkillsDirName, + "uloop-compile"); + WriteSkillFile(migrationSkillDir, "---\nname: v3-cli-invocation-migration\n---\n"); + WriteSkillFile(unrelatedSkillDir, "---\nname: uloop-compile\n---\n"); + + ToolSkillSynchronizer.SkillInstallResult result = + await ToolSkillSynchronizer.RemoveSpecificSkillFilesAtProjectRoot( + temporaryRoot, + new[] { target }, + CliConstants.V3_CLI_INVOCATION_MIGRATION_SKILL_NAME, + groupSkillsUnderUnityCliLoop: false); + + Assert.That(result.IsSuccessful, Is.True); + Assert.That(Directory.Exists(migrationSkillDir), Is.False); + Assert.That(Directory.Exists(unrelatedSkillDir), Is.True); + } + + [Test] + public async Task RemoveV3MigrationSkillFilesAtProjectRoot_WhenSkillExistsInBothLayouts_RemovesBothLayouts() + { + // Tests that temporary migration skill removal cleans up both supported install layouts. + string temporaryRoot = CreateTemporaryProjectRoot(); + ToolSkillSynchronizer.SkillTargetInfo target = new( + "Claude Code", + ".claude", + "--claude", + hasSkillsDirectory: true, + hasExistingSkills: false); + string targetRoot = Path.Combine(temporaryRoot, ".claude"); + string flatMigrationSkillDir = SkillInstallLayout.GetInstalledSkillDirectoryPathForLayout( + targetRoot, + CliConstants.V3_CLI_INVOCATION_MIGRATION_SKILL_NAME, + groupSkillsUnderUnityCliLoop: false); + string groupedMigrationSkillDir = SkillInstallLayout.GetInstalledSkillDirectoryPathForLayout( + targetRoot, + CliConstants.V3_CLI_INVOCATION_MIGRATION_SKILL_NAME, + groupSkillsUnderUnityCliLoop: true); + string unrelatedSkillDir = Path.Combine( + targetRoot, + SkillInstallLayout.SkillsDirName, + "uloop-compile"); + WriteSkillFile(flatMigrationSkillDir, "---\nname: v3-cli-invocation-migration\n---\n"); + WriteSkillFile(groupedMigrationSkillDir, "---\nname: v3-cli-invocation-migration\n---\n"); + WriteSkillFile(unrelatedSkillDir, "---\nname: uloop-compile\n---\n"); + + ToolSkillSynchronizer.SkillInstallResult result = + await ToolSkillSynchronizer.RemoveV3MigrationSkillFilesAtProjectRoot( + temporaryRoot, + new[] { target }, + groupSkillsUnderUnityCliLoop: false); + + Assert.That(result.IsSuccessful, Is.True); + Assert.That(Directory.Exists(flatMigrationSkillDir), Is.False); + Assert.That(Directory.Exists(groupedMigrationSkillDir), Is.False); + Assert.That(Directory.Exists(unrelatedSkillDir), Is.True); + } + private string CreateTemporaryProjectRoot() { string temporaryRoot = Path.Combine( 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..531fdf0884 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..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); @@ -260,6 +272,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..f90ebdaecd 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,155 @@ internal static async Task InstallSkillFilesForToolAtProject }); } + internal static SkillInstallState GetV3MigrationSkillInstallStateAtProjectRoot( + string projectRoot, + SkillTargetInfo target, + bool groupSkillsUnderUnityCliLoop) + { + SkillInstallLayout.SkillSourceInfo skill = GetV3MigrationSkillSourceInfo(); + SkillInstallState preferredLayoutState = GetSkillInstallStateAtProjectRoot( + projectRoot, + target, + skill, + groupSkillsUnderUnityCliLoop); + if (preferredLayoutState != SkillInstallState.Missing) + { + return preferredLayoutState; + } + + 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) + { + 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(); + 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( + 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/SetupWizardWindow.uss b/Packages/src/Editor/Presentation/Setup/SetupWizardWindow.uss index 3165efd40a..0c8119dbb1 100644 --- a/Packages/src/Editor/Presentation/Setup/SetupWizardWindow.uss +++ b/Packages/src/Editor/Presentation/Setup/SetupWizardWindow.uss @@ -78,22 +78,37 @@ .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-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 { @@ -111,6 +126,11 @@ white-space: normal; } +.setup-step__description-label { + white-space: normal; + margin-bottom: 10px; +} + .setup-progress-bar { height: 14px; margin-top: 6px; @@ -287,6 +307,56 @@ flex-basis: 0; } +.setup-section-actions { + margin-top: -2px; + margin-bottom: 10px; +} + +.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-section-divider { + border-top-width: 1px; + border-top-color: rgba(255, 255, 255, 0.18); + margin-top: 4px; + 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 808980708a..02595c77e3 100644 --- a/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardText.cs +++ b/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardText.cs @@ -9,14 +9,33 @@ 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 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 " + + "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..."; + 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"; 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) { @@ -27,7 +46,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."; @@ -64,5 +83,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..d625cd54b3 100644 --- a/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardView.cs +++ b/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardView.cs @@ -16,35 +16,43 @@ 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; + private readonly EnumField _migrationSkillTargetField; + private readonly Button _migrationSkillButton; private readonly Button _refreshButton; private readonly Button _closeButton; private ThirdPartyToolMigrationWizardView( ScrollView mainScrollView, - Label migrationStatusLabel, + TextField migrationStatusTextField, ProgressBar migrationProgressBar, VisualElement migrationButtonRow, Button migrateButton, + EnumField migrationSkillTargetField, + Button migrationSkillButton, Button refreshButton, 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"); + 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"); MainScrollView = mainScrollView; - _migrationStatusLabel = migrationStatusLabel; + _migrationStatusTextField = migrationStatusTextField; _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}"; @@ -68,28 +80,47 @@ internal static ThirdPartyToolMigrationWizardView Create( root.styleSheets.Add(styleSheet); ScrollView mainScrollView = CreateMainScrollView(root); - VisualElement content = CreateMigrationSection(mainScrollView); - Label migrationStatusLabel = CreateStatusLabel(content); + VisualElement content = CreateCSharpMigrationSection(mainScrollView); + TextField migrationStatusTextField = CreateStatusTextField(content); ProgressBar migrationProgressBar = CreateProgressBar(content); VisualElement migrationButtonRow = CreateMigrationButtonRow(content); Button migrateButton = CreateMigrateButton(migrationButtonRow); - (Button refreshButton, Button closeButton) = CreateFooter(mainScrollView); + (Button refreshButton, Button closeButton) = CreateCSharpMigrationActionSection(mainScrollView); + CreateSectionDivider(mainScrollView); + (EnumField migrationSkillTargetField, Button migrationSkillButton) = + CreateAiMigrationSkillSection(mainScrollView); + CreateFooter(mainScrollView); ThirdPartyToolMigrationWizardView view = new ThirdPartyToolMigrationWizardView( mainScrollView, - migrationStatusLabel, + migrationStatusTextField, 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; + _migrationStatusTextField.SetValueWithoutNotify(ThirdPartyToolMigrationWizardText.MigrationNotCheckedText); ViewDataBinder.SetVisible(_migrationProgressBar, false); ViewDataBinder.SetVisible(_migrationButtonRow, false); _migrateButton.SetEnabled(false); @@ -104,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); @@ -119,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); @@ -134,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); @@ -158,30 +189,36 @@ 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"); - 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); + + 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) @@ -207,44 +244,154 @@ 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; } - private static (Button refreshButton, Button closeButton) CreateFooter(ScrollView mainScrollView) + private static (Button refreshButton, Button closeButton) CreateCSharpMigrationActionSection( + ScrollView mainScrollView) { - VisualElement footer = new VisualElement(); - footer.AddToClassList("setup-footer"); - mainScrollView.Add(footer); + VisualElement actionSection = new VisualElement(); + actionSection.AddToClassList("setup-section-actions"); + mainScrollView.Add(actionSection); - VisualElement footerButtonRow = new VisualElement(); - footerButtonRow.AddToClassList("setup-footer__button-row"); - footer.Add(footerButtonRow); + 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"); - footerButtonRow.Add(refreshButton); + buttonRow.Add(refreshButton); Button closeButton = new Button(); closeButton.text = "Close"; closeButton.AddToClassList("setup-button"); - footerButtonRow.Add(closeButton); + buttonRow.Add(closeButton); + 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) + { + VisualElement migrationSkillSection = new VisualElement(); + migrationSkillSection.AddToClassList("setup-step"); + mainScrollView.Add(migrationSkillSection); + + TextField titleTextField = CreateSelectableText( + ThirdPartyToolMigrationWizardText.AiMigrationSkillSectionTitle, + "setup-step__title"); + migrationSkillSection.Add(titleTextField); + + VisualElement content = new VisualElement(); + content.AddToClassList("setup-step__content"); + migrationSkillSection.Add(content); + + TextField descriptionTextField = CreateSelectableText( + ThirdPartyToolMigrationWizardText.AiMigrationSkillDescriptionText, + "setup-step__description-label"); + content.Add(descriptionTextField); + CreateMigrationSkillUsageFoldout(content); + + 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); + + TextField targetLabelTextField = CreateSelectableText( + "Install target", + "setup-section-actions__field-label"); + targetRow.Add(targetLabelTextField); - Label reopenHintLabel = new Label( + EnumField migrationSkillTargetField = new EnumField(SkillsTarget.Claude); + migrationSkillTargetField.AddToClassList("setup-dropdown"); + 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"); + migrationSkillButton.AddToClassList("setup-button--primary"); + buttonRow.Add(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(); + footer.AddToClassList("setup-footer"); + mainScrollView.Add(footer); + + 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); - return (refreshButton, closeButton); + "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(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..cec864bdf5 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,19 @@ internal static string GetMigrationButtonText( hasCheckedMigrationStatus); } + internal static string GetMigrationSkillButtonText( + bool isUpdating, + SkillInstallState installState) + { + 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); @@ -304,18 +323,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 +501,87 @@ 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); + SkillInstallState currentInstallState = + _skillSetupUseCase.GetV3MigrationSkillInstallStateAtProjectRoot( + projectRoot, + target, + GroupMigrationSkillUnderUnityCliLoop); + bool shouldRemoveMigrationSkill = ShouldRemoveMigrationSkill(currentInstallState); + List targets = new List { target }; + _migrationSkillInstallState = currentInstallState; + _isUpdatingMigrationSkill = true; + UpdateMigrationSkillState(); + + try + { + if (shouldRemoveMigrationSkill) + { + 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~/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/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/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..51d6750899 --- /dev/null +++ b/Packages/src/TemporarySkills~/v3-cli-invocation-migration/Skill/scripts/detect-v3-cli-invocation-candidates.ps1 @@ -0,0 +1,123 @@ +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" +$bundledMigrationSkillPath = [System.IO.Path]::Combine( + "TemporarySkills~", + "v3-cli-invocation-migration" +) + +function Test-IsExcludedDirectoryName { + param([string] $Name) + + return $excludedDirectories -contains $Name +} + +function Test-IsTargetFile { + param([System.IO.FileInfo] $File) + + 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) + + $normalizedPath = $Path.Replace( + [string] [System.IO.Path]::AltDirectorySeparatorChar, + [string] [System.IO.Path]::DirectorySeparatorChar + ) + $separator = [string] [System.IO.Path]::DirectorySeparatorChar + $wrappedPath = "${separator}${normalizedPath}${separator}" + $wrappedBundledPath = "${separator}${bundledMigrationSkillPath}${separator}" + return $wrappedPath.IndexOf( + $wrappedBundledPath, + [System.StringComparison]::OrdinalIgnoreCase + ) -ge 0 +} + +function Get-CandidateFile { + param([string] $Directory) + + $directoryName = Split-Path -Leaf $Directory + if ((Test-IsExcludedDirectoryName -Name $directoryName) -or + (Test-IsBundledMigrationSkillPath -Path $Directory)) { + return + } + + Get-ChildItem -LiteralPath $Directory -File | + Where-Object { Test-IsTargetFile -File $_ } + + 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 { + Get-CandidateFile -Directory $_.FullName + } +} + +function Write-Candidate { + param( + [string] $Kind, + [string] $Path, + [int] $LineNumber, + [string] $Line + ) + + Write-Output "$Kind ${Path}:${LineNumber}: $Line" +} + +Get-CandidateFile -Directory $Root | + ForEach-Object { + $path = $_.FullName + $lines = @(Get-Content -LiteralPath $path -Encoding UTF8) + 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)([^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)([^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([^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([^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)([^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([^A-Za-z0-9_-]|$)") { + Write-Candidate -Kind "FIRST_PARTY_OPTION" -Path $path -LineNumber $lineNumber -Line $line + } + 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 new file mode 100755 index 0000000000..9e82b804ee --- /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 -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 + awk ' + function report(kind) { + printf "%s %s:%d: %s\n", kind, FILENAME, FNR, $0 + } + /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)([^[:alnum:]_-]|$)/ { + report("FIRST_PARTY_OPTION") + } + /uloop[[:space:]]+run-tests[[:space:]][^#|;&]*--save-before-run([^[:alnum:]_-]|$)/ { + report("FIRST_PARTY_OPTION") + } + /uloop[[:space:]]+(record-input|replay-input)[[:space:]][^#|;&]*--show-overlay([^[:alnum:]_-]|$)/ { + report("FIRST_PARTY_OPTION") + } + /uloop[[:space:]]+get-hierarchy[[:space:]][^#|;&]*--include-(components|inactive)([^[:alnum:]_-]|$)/ { + report("FIRST_PARTY_OPTION") + } + /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:]_]|$)/ { + report("OUTPUT_FIELD") + } + ' "$file" +done diff --git a/cli/internal/cli/skills.go b/cli/internal/cli/skills.go index 73497d106e..33dcf26531 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 + } + 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, 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..46c3b659a2 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,30 @@ 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") + } + if skill.name != v3MigrationSkillName { + return nil, errors.New("v3 CLI invocation migration skill source has an unexpected name") + } + 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_packages.go b/cli/internal/cli/skills_packages.go index 52da05ff31..ab739ab441 100644 --- a/cli/internal/cli/skills_packages.go +++ b/cli/internal/cli/skills_packages.go @@ -54,12 +54,23 @@ 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 _, dependencyValue := range dependencies { + for dependencyName, dependencyValue := range dependencies { + if includeDependency != nil && !includeDependency(dependencyName) { + continue + } localPath := resolveLocalDependencyPath(dependencyValue, projectRoot) if localPath == "" { continue @@ -113,6 +124,11 @@ func resolvePackageRoot(projectRoot string) string { return resolvedRoot } } + for _, candidate := range resolveTargetManifestLocalPackageRoots(projectRoot) { + if resolvedRoot := resolvePackageRootCandidate(candidate); resolvedRoot != "" { + return resolvedRoot + } + } return resolvePackageCacheRoot(projectRoot) } @@ -200,3 +216,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 ec7c46d1a4..a86d20f537 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" @@ -93,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 --- @@ -114,12 +125,132 @@ 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) + } +} + +// 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 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() + 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 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 := filepath.Clean(resolvePackageRoot(projectRoot)) + expectedRoot := filepath.Clean(packageRoot) + if actualRoot != expectedRoot { + t.Fatalf("package root mismatch: actual=%s expected=%s", actualRoot, expectedRoot) + } +} + // Tests that CLI-only and project-local skills win over package-root duplicates. func TestCollectSkillDefinitionsUsesUnitySideSourcePrecedence(t *testing.T) { projectRoot := t.TempDir() @@ -824,6 +955,339 @@ 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, 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 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 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() + 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", + "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 { + 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) + } + 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", + "src", + "TemporarySkills~", + "v3-cli-invocation-migration", + "Skill", + "scripts", + "detect-v3-cli-invocation-candidates.sh") + + 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)) + } + 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) + } + } + 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) + } +} + +// 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", + "inline `uloop run-tests --save-before-run`", + "inline `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) + } + 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", + "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) + } + } + 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) + } + if strings.Contains(text, "self-noise") { + t.Fatalf("detector should skip bundled migration skill sources:\n%s", text) + } +} + +// 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) @@ -840,6 +1304,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 +1374,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..5e7b646e61 --- /dev/null +++ b/cli/internal/cli/skills_v3_migration.go @@ -0,0 +1,123 @@ +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, 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, 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, global bool, grouped bool) (int, int, error) { + removed := 0 + notFound := 0 + baseDir, err := getSkillsBaseDir(projectRoot, target, global) + if err != nil { + return removed, notFound, 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 + } + removed++ + return removed, notFound, nil +} + +func removeEmptyMigrationSkillParent(baseDir string, grouped bool) { + if !grouped { + return + } + _ = removeEmptyDir(filepath.Join(baseDir, managedSkillsDir)) +}