From 88a3519830517b736ff5e6fe027a2c4264e49d7a Mon Sep 17 00:00:00 2001 From: hatayama Date: Wed, 13 May 2026 00:42:08 +0900 Subject: [PATCH 01/68] Add V3 custom tool migration to setup wizard Detect legacy third-party custom tool API usage during setup so suppressed update popups still surface when migration work is required. Add migration rules and file-service coverage to rewrite legacy tool contracts and asmdef references to the V3 ToolContracts assembly. --- Assets/Tests/Editor/SetupWizardWindowTests.cs | 45 +++- ...ThirdPartyToolMigrationFileServiceTests.cs | 103 +++++++ ...PartyToolMigrationFileServiceTests.cs.meta | 11 + .../ThirdPartyToolMigrationRulesTests.cs | 181 +++++++++++++ .../ThirdPartyToolMigrationRulesTests.cs.meta | 11 + .../ThirdPartyToolMigrationUseCase.cs | 36 +++ .../ThirdPartyToolMigrationUseCase.cs.meta | 11 + .../ThirdPartyToolMigrationUseCaseRegistry.cs | 30 +++ ...dPartyToolMigrationUseCaseRegistry.cs.meta | 11 + .../UnityCliLoopApplicationRegistration.cs | 2 + .../Domain/ThirdPartyToolMigrationData.cs | 55 ++++ .../ThirdPartyToolMigrationData.cs.meta | 11 + .../ThirdPartyToolMigration.meta | 8 + .../ThirdPartyToolMigrationFileService.cs | 252 ++++++++++++++++++ ...ThirdPartyToolMigrationFileService.cs.meta | 11 + .../ThirdPartyToolMigrationRules.cs | 221 +++++++++++++++ .../ThirdPartyToolMigrationRules.cs.meta | 11 + .../Presentation/Setup/SetupWizardWindow.cs | 111 +++++++- .../Presentation/Setup/SetupWizardWindow.uss | 5 + .../Presentation/Setup/SetupWizardWindow.uxml | 14 + 20 files changed, 1128 insertions(+), 12 deletions(-) create mode 100644 Assets/Tests/Editor/ThirdPartyToolMigrationFileServiceTests.cs create mode 100644 Assets/Tests/Editor/ThirdPartyToolMigrationFileServiceTests.cs.meta create mode 100644 Assets/Tests/Editor/ThirdPartyToolMigrationRulesTests.cs create mode 100644 Assets/Tests/Editor/ThirdPartyToolMigrationRulesTests.cs.meta create mode 100644 Packages/src/Editor/Application/UseCases/ThirdPartyToolMigrationUseCase.cs create mode 100644 Packages/src/Editor/Application/UseCases/ThirdPartyToolMigrationUseCase.cs.meta create mode 100644 Packages/src/Editor/Application/UseCases/ThirdPartyToolMigrationUseCaseRegistry.cs create mode 100644 Packages/src/Editor/Application/UseCases/ThirdPartyToolMigrationUseCaseRegistry.cs.meta create mode 100644 Packages/src/Editor/Domain/ThirdPartyToolMigrationData.cs create mode 100644 Packages/src/Editor/Domain/ThirdPartyToolMigrationData.cs.meta create mode 100644 Packages/src/Editor/Infrastructure/ThirdPartyToolMigration.meta create mode 100644 Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationFileService.cs create mode 100644 Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationFileService.cs.meta create mode 100644 Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationRules.cs create mode 100644 Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationRules.cs.meta diff --git a/Assets/Tests/Editor/SetupWizardWindowTests.cs b/Assets/Tests/Editor/SetupWizardWindowTests.cs index 68a5eaa98..925566c65 100644 --- a/Assets/Tests/Editor/SetupWizardWindowTests.cs +++ b/Assets/Tests/Editor/SetupWizardWindowTests.cs @@ -52,24 +52,55 @@ public void TearDown() _editorSettingsRepository.InvalidateCache(); } - [TestCase("", "1.7.3", false, true)] - [TestCase("1.7.2", "1.7.3", false, true)] - [TestCase("1.7.4", "1.7.3", false, true)] - [TestCase("1.7.3", "1.7.3", false, false)] - [TestCase("", "1.7.3", true, false)] - [TestCase("1.7.2", "1.7.3", true, false)] + [TestCase("", "1.7.3", false, false, true)] + [TestCase("1.7.2", "1.7.3", false, false, true)] + [TestCase("1.7.4", "1.7.3", false, false, true)] + [TestCase("1.7.3", "1.7.3", false, false, false)] + [TestCase("", "1.7.3", true, false, false)] + [TestCase("1.7.2", "1.7.3", true, false, false)] + [TestCase("1.7.2", "1.7.3", true, true, true)] + [TestCase("1.7.3", "1.7.3", true, true, false)] public void ShouldAutoShowForVersion_ReturnsExpectedValue( string lastSeenVersion, string currentVersion, bool suppressAutoShow, + bool hasThirdPartyToolMigrationTargets, bool expected) { bool shouldAutoShow = - SetupWizardWindow.ShouldAutoShowForVersion(currentVersion, lastSeenVersion, suppressAutoShow); + SetupWizardWindow.ShouldAutoShowForVersion( + currentVersion, + lastSeenVersion, + suppressAutoShow, + hasThirdPartyToolMigrationTargets); Assert.That(shouldAutoShow, Is.EqualTo(expected)); } + [TestCase(1, "1 file needs V3 custom tool migration.")] + [TestCase(3, "3 files need V3 custom tool migration.")] + public void GetThirdPartyToolMigrationStatusText_WhenTargetsExist_ReturnsFileCount( + int fileCount, + string expectedText) + { + // Verifies that the setup wizard summarizes detected V2 custom tool files. + string text = SetupWizardWindow.GetThirdPartyToolMigrationStatusText(fileCount); + + Assert.That(text, Is.EqualTo(expectedText)); + } + + [TestCase(false, "Migrate")] + [TestCase(true, "Migrating...")] + public void GetThirdPartyToolMigrationButtonText_ReturnsExpectedLabel( + bool isMigrating, + string expectedLabel) + { + // Verifies that the migration action communicates its current state. + string label = SetupWizardWindow.GetThirdPartyToolMigrationButtonText(isMigrating); + + Assert.That(label, Is.EqualTo(expectedLabel)); + } + [Test] public void MaybeRecordLastSeenVersion_WhenAutoShow_UpdatesStoredVersion() { diff --git a/Assets/Tests/Editor/ThirdPartyToolMigrationFileServiceTests.cs b/Assets/Tests/Editor/ThirdPartyToolMigrationFileServiceTests.cs new file mode 100644 index 000000000..2224373d0 --- /dev/null +++ b/Assets/Tests/Editor/ThirdPartyToolMigrationFileServiceTests.cs @@ -0,0 +1,103 @@ +using System; +using System.IO; +using System.Linq; + +using NUnit.Framework; + +using io.github.hatayama.UnityCliLoop.Domain; +using io.github.hatayama.UnityCliLoop.Infrastructure; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor +{ + /// + /// Test fixture that verifies project-wide V3 third-party tool migration file behavior. + /// + public sealed class ThirdPartyToolMigrationFileServiceTests + { + [Test] + public void ApplyMigration_WhenLegacyToolAssemblyExists_RewritesSourceAndAsmdef() + { + // Verifies that project-wide migration rewrites both custom tool source and its asmdef reference. + string projectRoot = CreateProjectRoot(); + try + { + string toolDirectory = Path.Combine(projectRoot, "Assets", "VendorTools"); + Directory.CreateDirectory(toolDirectory); + string toolPath = Path.Combine(toolDirectory, "HelloTool.cs"); + string asmdefPath = Path.Combine(toolDirectory, "VendorTools.Editor.asmdef"); + File.WriteAllText(toolPath, @"using io.github.hatayama.uLoopMCP; + +[McpTool] +public sealed class HelloTool : AbstractUnityTool +{ +} + +public sealed class HelloSchema : BaseToolSchema +{ +} + +public sealed class HelloResponse : BaseToolResponse +{ +}"); + File.WriteAllText(asmdefPath, @"{ + ""name"": ""VendorTools.Editor"", + ""references"": [ + ""GUID:214998e563c124e8a88199b2dd1f522d"" + ] +}"); + + ThirdPartyToolMigrationFileService service = new(); + ThirdPartyToolMigrationPreview preview = service.PreviewMigration(projectRoot); + ThirdPartyToolMigrationResult result = service.ApplyMigration(projectRoot); + + Assert.That(preview.FileCount, Is.EqualTo(2)); + Assert.That(preview.FilePaths.Contains(toolPath), Is.True); + Assert.That(preview.FilePaths.Contains(asmdefPath), Is.True); + Assert.That(result.FileCount, Is.EqualTo(2)); + Assert.That(File.ReadAllText(toolPath), Does.Contain("UnityCliLoopTool")); + Assert.That(File.ReadAllText(asmdefPath), Does.Contain("GUID:fc3fd32eddbee40e39c2d76dc184957b")); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + + [Test] + public void PreviewMigration_WhenLegacyToolExistsUnderExcludedDirectory_IgnoresFile() + { + // Verifies that generated Unity folders are not scanned for third-party tools. + string projectRoot = CreateProjectRoot(); + try + { + string generatedDirectory = Path.Combine(projectRoot, "Library"); + Directory.CreateDirectory(generatedDirectory); + File.WriteAllText( + Path.Combine(generatedDirectory, "GeneratedTool.cs"), + "using io.github.hatayama.uLoopMCP; [McpTool] public sealed class GeneratedTool {}"); + + ThirdPartyToolMigrationFileService service = new(); + ThirdPartyToolMigrationPreview preview = service.PreviewMigration(projectRoot); + + Assert.That(preview.HasTargets, Is.False); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + + private static string CreateProjectRoot() + { + string projectRoot = Path.Combine( + Path.GetTempPath(), + "UnityCliLoopTests", + "ThirdPartyToolMigration", + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(projectRoot); + Directory.CreateDirectory(Path.Combine(projectRoot, "ProjectSettings")); + Directory.CreateDirectory(Path.Combine(projectRoot, "Assets")); + return projectRoot; + } + } +} diff --git a/Assets/Tests/Editor/ThirdPartyToolMigrationFileServiceTests.cs.meta b/Assets/Tests/Editor/ThirdPartyToolMigrationFileServiceTests.cs.meta new file mode 100644 index 000000000..6579d5119 --- /dev/null +++ b/Assets/Tests/Editor/ThirdPartyToolMigrationFileServiceTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6a4d3c9261bd7430ea2876e1cc2881dd +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/ThirdPartyToolMigrationRulesTests.cs b/Assets/Tests/Editor/ThirdPartyToolMigrationRulesTests.cs new file mode 100644 index 000000000..f18b90e0b --- /dev/null +++ b/Assets/Tests/Editor/ThirdPartyToolMigrationRulesTests.cs @@ -0,0 +1,181 @@ +using System.Linq; + +using NUnit.Framework; + +using io.github.hatayama.UnityCliLoop.Infrastructure; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor +{ + /// + /// Test fixture that verifies V3 third-party tool migration rewrite rules. + /// + public sealed class ThirdPartyToolMigrationRulesTests + { + [Test] + public void MigrateCSharpSource_WhenLegacyToolApiIsUsed_RewritesToV3Contracts() + { + // Verifies that V2 custom tool source is rewritten to the V3 public contract names. + string source = @"using io.github.hatayama.uLoopMCP; + +namespace Samples +{ + [McpTool(Description = ""hello"")] + public sealed class HelloTool : AbstractUnityTool + { + } + + public sealed class HelloSchema : BaseToolSchema + { + } + + public sealed class HelloResponse : BaseToolResponse + { + } +}"; + + ThirdPartyToolMigrationContentResult result = + ThirdPartyToolMigrationRules.MigrateCSharpSource(source); + + Assert.That(result.Changed, Is.True); + Assert.That(result.Content, Does.Contain("using io.github.hatayama.UnityCliLoop.ToolContracts;")); + Assert.That(result.Content, Does.Contain("[UnityCliLoopTool]")); + Assert.That(result.Content, Does.Contain("UnityCliLoopTool")); + Assert.That(result.Content, Does.Contain("UnityCliLoopToolSchema")); + Assert.That(result.Content, Does.Contain("UnityCliLoopToolResponse")); + Assert.That(result.Content, Does.Not.Contain("uLoopMCP")); + Assert.That(result.ReplacementCount, Is.EqualTo(5)); + } + + [Test] + public void MigrateCSharpSource_WhenNoLegacyToolApiIsUsed_KeepsContent() + { + // Verifies that unrelated C# files are not rewritten. + string source = "public sealed class PlainClass {}"; + + ThirdPartyToolMigrationContentResult result = + ThirdPartyToolMigrationRules.MigrateCSharpSource(source); + + Assert.That(result.Changed, Is.False); + Assert.That(result.Content, Is.EqualTo(source)); + Assert.That(result.ReplacementCount, Is.EqualTo(0)); + } + + [Test] + public void MigrateCSharpSource_WhenLegacyToolAttributeSuffixHasArguments_DropsUnsupportedArguments() + { + // Verifies that old attribute suffix syntax does not keep removed V3 attribute arguments. + string source = "[McpToolAttribute(Description = \"hello\")] public sealed class HelloTool {}"; + + ThirdPartyToolMigrationContentResult result = + ThirdPartyToolMigrationRules.MigrateCSharpSource(source); + + Assert.That(result.Changed, Is.True); + Assert.That(result.Content, Does.Contain("[UnityCliLoopTool]")); + Assert.That(result.Content, Does.Not.Contain("Description")); + Assert.That(result.Content, Does.Not.Contain("UnityCliLoopToolAttribute(")); + } + + [Test] + public void MigrateCSharpSource_WhenLegacyNamespaceLikeTextExists_KeepsContent() + { + // Verifies that namespace migration treats dots as literal characters. + string source = "using ioXgithubYhatayamaZUnityCliLoop;"; + + ThirdPartyToolMigrationContentResult result = + ThirdPartyToolMigrationRules.MigrateCSharpSource(source); + + Assert.That(result.Changed, Is.False); + Assert.That(result.Content, Is.EqualTo(source)); + } + + [Test] + public void MigrateAsmdefSource_WhenLegacyReferenceIsUsed_RewritesToToolContractsGuid() + { + // Verifies that a custom tool asmdef points at the V3 ToolContracts assembly. + string source = @"{ + ""name"": ""MyCompany.Tools.Editor"", + ""references"": [ + ""uLoopMCP.Editor"" + ] +}"; + + ThirdPartyToolMigrationContentResult result = + ThirdPartyToolMigrationRules.MigrateAsmdefSource(source, hasLegacyCSharpSource: false); + + Assert.That(result.Changed, Is.True); + Assert.That(result.Content, Does.Contain("GUID:fc3fd32eddbee40e39c2d76dc184957b")); + Assert.That(result.Content, Does.Not.Contain("uLoopMCP.Editor")); + Assert.That(result.ReplacementCount, Is.EqualTo(1)); + } + + [Test] + public void MigrateAsmdefSource_WhenOnlyLegacyGuidIsUsedWithoutLegacySource_KeepsContent() + { + // Verifies that package-owned Application references are not rewritten by GUID alone. + string source = @"{ + ""name"": ""MyCompany.Tools.Editor"", + ""references"": [ + ""GUID:214998e563c124e8a88199b2dd1f522d"" + ] +}"; + + ThirdPartyToolMigrationContentResult result = + ThirdPartyToolMigrationRules.MigrateAsmdefSource(source, hasLegacyCSharpSource: false); + + Assert.That(result.Changed, Is.False); + Assert.That(result.Content, Is.EqualTo(source)); + } + + [Test] + public void MigrateAsmdefSource_WhenLegacyGuidIsUsedWithLegacySource_RewritesToToolContractsGuid() + { + // Verifies that old custom tool assemblies with GUID references are migrated when source confirms old API usage. + string source = @"{ + ""name"": ""MyCompany.Tools.Editor"", + ""references"": [ + ""GUID:214998e563c124e8a88199b2dd1f522d"" + ] +}"; + + ThirdPartyToolMigrationContentResult result = + ThirdPartyToolMigrationRules.MigrateAsmdefSource(source, hasLegacyCSharpSource: true); + + Assert.That(result.Changed, Is.True); + Assert.That(result.Content, Does.Contain("GUID:fc3fd32eddbee40e39c2d76dc184957b")); + Assert.That(result.Content, Does.Not.Contain("GUID:214998e563c124e8a88199b2dd1f522d")); + } + + [Test] + public void ContainsLegacyCSharpApi_WhenLegacyToolApiExists_ReturnsTrue() + { + // Verifies that migration detection is based on public custom tool API usage. + string source = "[McpTool] public sealed class HelloTool : AbstractUnityTool {}"; + + bool containsLegacyApi = ThirdPartyToolMigrationRules.ContainsLegacyCSharpApi(source); + + Assert.That(containsLegacyApi, Is.True); + } + + [Test] + public void ContainsLegacyCSharpApi_WhenOnlyCurrentApiExists_ReturnsFalse() + { + // Verifies that already migrated V3 tools are not detected again. + string source = "[UnityCliLoopTool] public sealed class HelloTool : UnityCliLoopTool {}"; + + bool containsLegacyApi = ThirdPartyToolMigrationRules.ContainsLegacyCSharpApi(source); + + Assert.That(containsLegacyApi, Is.False); + } + + [Test] + public void GetExcludedDirectoryNames_IncludesUnityGeneratedDirectories() + { + // Verifies that generated Unity folders are skipped during project-wide scans. + string[] names = ThirdPartyToolMigrationRules.GetExcludedDirectoryNames(); + + Assert.That(names.Contains("Library"), Is.True); + Assert.That(names.Contains("Temp"), Is.True); + Assert.That(names.Contains(".git"), Is.True); + } + } +} diff --git a/Assets/Tests/Editor/ThirdPartyToolMigrationRulesTests.cs.meta b/Assets/Tests/Editor/ThirdPartyToolMigrationRulesTests.cs.meta new file mode 100644 index 000000000..3a1a57758 --- /dev/null +++ b/Assets/Tests/Editor/ThirdPartyToolMigrationRulesTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 421cf9412b7894ec5b5da141229f5598 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/Application/UseCases/ThirdPartyToolMigrationUseCase.cs b/Packages/src/Editor/Application/UseCases/ThirdPartyToolMigrationUseCase.cs new file mode 100644 index 000000000..50a9b08ed --- /dev/null +++ b/Packages/src/Editor/Application/UseCases/ThirdPartyToolMigrationUseCase.cs @@ -0,0 +1,36 @@ +using System; +using System.Diagnostics; + +using io.github.hatayama.UnityCliLoop.Domain; + +namespace io.github.hatayama.UnityCliLoop.Application +{ + /// + /// Coordinates V3 migration for third-party custom tools without owning file-system details. + /// + public sealed class ThirdPartyToolMigrationUseCase + { + private readonly IThirdPartyToolMigrationPort _migrationPort; + + public ThirdPartyToolMigrationUseCase(IThirdPartyToolMigrationPort migrationPort) + { + Debug.Assert(migrationPort != null, "migrationPort must not be null"); + + _migrationPort = migrationPort ?? throw new ArgumentNullException(nameof(migrationPort)); + } + + public ThirdPartyToolMigrationPreview PreviewMigration(string projectRoot) + { + Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty"); + + return _migrationPort.PreviewMigration(projectRoot); + } + + public ThirdPartyToolMigrationResult ApplyMigration(string projectRoot) + { + Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty"); + + return _migrationPort.ApplyMigration(projectRoot); + } + } +} diff --git a/Packages/src/Editor/Application/UseCases/ThirdPartyToolMigrationUseCase.cs.meta b/Packages/src/Editor/Application/UseCases/ThirdPartyToolMigrationUseCase.cs.meta new file mode 100644 index 000000000..9586e68b6 --- /dev/null +++ b/Packages/src/Editor/Application/UseCases/ThirdPartyToolMigrationUseCase.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 792cd64b3299949f9baf51e9a63eea8c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/Application/UseCases/ThirdPartyToolMigrationUseCaseRegistry.cs b/Packages/src/Editor/Application/UseCases/ThirdPartyToolMigrationUseCaseRegistry.cs new file mode 100644 index 000000000..8f6a5fcec --- /dev/null +++ b/Packages/src/Editor/Application/UseCases/ThirdPartyToolMigrationUseCaseRegistry.cs @@ -0,0 +1,30 @@ +using System; +using System.Diagnostics; + +namespace io.github.hatayama.UnityCliLoop.Application +{ + /// + /// Stores the third-party tool migration use case for Unity-created presentation objects. + /// + internal static class ThirdPartyToolMigrationUseCaseRegistry + { + private static ThirdPartyToolMigrationUseCase RegisteredUseCase; + + internal static void Register(ThirdPartyToolMigrationUseCase useCase) + { + Debug.Assert(useCase != null, "useCase must not be null"); + + RegisteredUseCase = useCase ?? throw new ArgumentNullException(nameof(useCase)); + } + + internal static ThirdPartyToolMigrationUseCase GetRegisteredUseCase() + { + if (RegisteredUseCase == null) + { + throw new InvalidOperationException("Unity CLI Loop third-party tool migration use case is not registered."); + } + + return RegisteredUseCase; + } + } +} diff --git a/Packages/src/Editor/Application/UseCases/ThirdPartyToolMigrationUseCaseRegistry.cs.meta b/Packages/src/Editor/Application/UseCases/ThirdPartyToolMigrationUseCaseRegistry.cs.meta new file mode 100644 index 000000000..04db50e14 --- /dev/null +++ b/Packages/src/Editor/Application/UseCases/ThirdPartyToolMigrationUseCaseRegistry.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c7e9d9700538b41e0ae2cd7846439cab +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/CompositionRoot/UnityCliLoopApplicationRegistration.cs b/Packages/src/Editor/CompositionRoot/UnityCliLoopApplicationRegistration.cs index bb9db5e38..7601ed5ac 100644 --- a/Packages/src/Editor/CompositionRoot/UnityCliLoopApplicationRegistration.cs +++ b/Packages/src/Editor/CompositionRoot/UnityCliLoopApplicationRegistration.cs @@ -32,6 +32,8 @@ internal UnityCliLoopApplicationServices Register() toolRegistrarService)); SkillSetupUseCase skillSetupUseCase = new(new SkillSetupService(new ToolSkillSetupService(toolSettingsService))); SkillSetupUseCaseRegistry.Register(skillSetupUseCase); + ThirdPartyToolMigrationUseCaseRegistry.Register( + new ThirdPartyToolMigrationUseCase(new ThirdPartyToolMigrationFileService())); CliSetupApplicationFacade.RegisterService(new CliSetupApplicationService( new CliInstallationDetector(), new NativeCliInstallerService())); diff --git a/Packages/src/Editor/Domain/ThirdPartyToolMigrationData.cs b/Packages/src/Editor/Domain/ThirdPartyToolMigrationData.cs new file mode 100644 index 000000000..12a756348 --- /dev/null +++ b/Packages/src/Editor/Domain/ThirdPartyToolMigrationData.cs @@ -0,0 +1,55 @@ +using System; +using System.Diagnostics; + +namespace io.github.hatayama.UnityCliLoop.Domain +{ + /// + /// Describes pending V3 custom tool migration work found in the Unity project. + /// + public readonly struct ThirdPartyToolMigrationPreview + { + public ThirdPartyToolMigrationPreview(int fileCount, int replacementCount, string[] filePaths) + { + Debug.Assert(fileCount >= 0, "fileCount must not be negative"); + Debug.Assert(replacementCount >= 0, "replacementCount must not be negative"); + Debug.Assert(filePaths != null, "filePaths must not be null"); + + FileCount = fileCount; + ReplacementCount = replacementCount; + FilePaths = filePaths ?? Array.Empty(); + } + + public int FileCount { get; } + public int ReplacementCount { get; } + public string[] FilePaths { get; } + public bool HasTargets => FileCount > 0; + } + + /// + /// Describes the files rewritten by the V3 custom tool migration workflow. + /// + public readonly struct ThirdPartyToolMigrationResult + { + public ThirdPartyToolMigrationResult(int fileCount, int replacementCount, string[] filePaths) + { + Debug.Assert(fileCount >= 0, "fileCount must not be negative"); + Debug.Assert(replacementCount >= 0, "replacementCount must not be negative"); + Debug.Assert(filePaths != null, "filePaths must not be null"); + + FileCount = fileCount; + ReplacementCount = replacementCount; + FilePaths = filePaths ?? Array.Empty(); + } + + public int FileCount { get; } + public int ReplacementCount { get; } + public string[] FilePaths { get; } + public bool Changed => FileCount > 0; + } + + public interface IThirdPartyToolMigrationPort + { + ThirdPartyToolMigrationPreview PreviewMigration(string projectRoot); + ThirdPartyToolMigrationResult ApplyMigration(string projectRoot); + } +} diff --git a/Packages/src/Editor/Domain/ThirdPartyToolMigrationData.cs.meta b/Packages/src/Editor/Domain/ThirdPartyToolMigrationData.cs.meta new file mode 100644 index 000000000..d7d4f0942 --- /dev/null +++ b/Packages/src/Editor/Domain/ThirdPartyToolMigrationData.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7d34ccf1cea694100982036051096ac6 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration.meta b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration.meta new file mode 100644 index 000000000..e835d3fcc --- /dev/null +++ b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: b3977ef001d234ff59cf1e4d41a33793 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationFileService.cs b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationFileService.cs new file mode 100644 index 000000000..d4254bf75 --- /dev/null +++ b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationFileService.cs @@ -0,0 +1,252 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; + +using io.github.hatayama.UnityCliLoop.Domain; + +namespace io.github.hatayama.UnityCliLoop.Infrastructure +{ + /// + /// Scans Unity project files and rewrites V2 custom tool source to the V3 public contract API. + /// + public sealed class ThirdPartyToolMigrationFileService : IThirdPartyToolMigrationPort + { + public ThirdPartyToolMigrationPreview PreviewMigration(string projectRoot) + { + Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty"); + + MigrationPlan plan = CreateMigrationPlan(projectRoot); + return new ThirdPartyToolMigrationPreview( + plan.ChangedFilePaths.Count, + plan.ReplacementCount, + plan.ChangedFilePaths.ToArray()); + } + + public ThirdPartyToolMigrationResult ApplyMigration(string projectRoot) + { + Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty"); + + MigrationPlan plan = CreateMigrationPlan(projectRoot); + foreach (MigrationFileChange change in plan.Changes) + { + AtomicFileWriter.Write(change.FilePath, change.Content); + AtomicFileWriter.CleanupBackup(change.FilePath + ".bak"); + } + + return new ThirdPartyToolMigrationResult( + plan.ChangedFilePaths.Count, + plan.ReplacementCount, + plan.ChangedFilePaths.ToArray()); + } + + private static MigrationPlan CreateMigrationPlan(string projectRoot) + { + if (!Directory.Exists(projectRoot)) + { + throw new DirectoryNotFoundException(projectRoot); + } + + ProjectFileInventory inventory = ProjectFileInventory.Create(projectRoot); + HashSet legacyAssemblyDirectories = FindLegacyAssemblyDirectories( + inventory.CSharpFilePaths, + inventory.AsmdefFilePaths); + List changes = new(); + int replacementCount = 0; + + foreach (string csharpFilePath in inventory.CSharpFilePaths) + { + string source = File.ReadAllText(csharpFilePath); + ThirdPartyToolMigrationContentResult result = + ThirdPartyToolMigrationRules.MigrateCSharpSource(source); + if (!result.Changed) + { + continue; + } + + replacementCount += result.ReplacementCount; + changes.Add(new MigrationFileChange(csharpFilePath, result.Content)); + } + + foreach (string asmdefFilePath in inventory.AsmdefFilePaths) + { + string source = File.ReadAllText(asmdefFilePath); + string asmdefDirectory = Path.GetDirectoryName(asmdefFilePath) ?? projectRoot; + bool hasLegacyCSharpSource = legacyAssemblyDirectories.Contains(asmdefDirectory); + ThirdPartyToolMigrationContentResult result = + ThirdPartyToolMigrationRules.MigrateAsmdefSource(source, hasLegacyCSharpSource); + if (!result.Changed) + { + continue; + } + + replacementCount += result.ReplacementCount; + changes.Add(new MigrationFileChange(asmdefFilePath, result.Content)); + } + + return new MigrationPlan(changes, replacementCount); + } + + private static HashSet FindLegacyAssemblyDirectories( + List csharpFilePaths, + List asmdefFilePaths) + { + Debug.Assert(csharpFilePaths != null, "csharpFilePaths must not be null"); + Debug.Assert(asmdefFilePaths != null, "asmdefFilePaths must not be null"); + + List asmdefDirectories = asmdefFilePaths + .Select(path => Path.GetDirectoryName(path) ?? string.Empty) + .Where(path => !string.IsNullOrEmpty(path)) + .OrderByDescending(path => path.Length) + .ToList(); + HashSet legacyAssemblyDirectories = new(StringComparer.Ordinal); + + foreach (string csharpFilePath in csharpFilePaths) + { + string source = File.ReadAllText(csharpFilePath); + if (!ThirdPartyToolMigrationRules.ContainsLegacyCSharpApi(source)) + { + continue; + } + + string assemblyDirectory = FindNearestAssemblyDirectory(csharpFilePath, asmdefDirectories); + if (!string.IsNullOrEmpty(assemblyDirectory)) + { + legacyAssemblyDirectories.Add(assemblyDirectory); + } + } + + return legacyAssemblyDirectories; + } + + private static string FindNearestAssemblyDirectory( + string csharpFilePath, + List asmdefDirectories) + { + Debug.Assert(!string.IsNullOrEmpty(csharpFilePath), "csharpFilePath must not be null or empty"); + Debug.Assert(asmdefDirectories != null, "asmdefDirectories must not be null"); + + string csharpDirectory = Path.GetDirectoryName(csharpFilePath) ?? string.Empty; + foreach (string asmdefDirectory in asmdefDirectories) + { + if (IsSameOrChildPath(csharpDirectory, asmdefDirectory)) + { + return asmdefDirectory; + } + } + + return string.Empty; + } + + private static bool IsSameOrChildPath(string childPath, string parentPath) + { + Debug.Assert(childPath != null, "childPath must not be null"); + Debug.Assert(parentPath != null, "parentPath must not be null"); + + if (string.Equals(childPath, parentPath, StringComparison.Ordinal)) + { + return true; + } + + string parentWithSeparator = parentPath.TrimEnd(Path.DirectorySeparatorChar) + + Path.DirectorySeparatorChar; + return childPath.StartsWith(parentWithSeparator, StringComparison.Ordinal); + } + + private readonly struct MigrationFileChange + { + public MigrationFileChange(string filePath, string content) + { + Debug.Assert(!string.IsNullOrEmpty(filePath), "filePath must not be null or empty"); + Debug.Assert(content != null, "content must not be null"); + + FilePath = filePath; + Content = content ?? string.Empty; + } + + public string FilePath { get; } + public string Content { get; } + } + + private readonly struct MigrationPlan + { + public MigrationPlan(List changes, int replacementCount) + { + Debug.Assert(changes != null, "changes must not be null"); + Debug.Assert(replacementCount >= 0, "replacementCount must not be negative"); + + Changes = changes ?? new List(); + ReplacementCount = replacementCount; + ChangedFilePaths = Changes + .Select(change => change.FilePath) + .OrderBy(path => path, StringComparer.Ordinal) + .ToList(); + } + + public List Changes { get; } + public int ReplacementCount { get; } + public List ChangedFilePaths { get; } + } + + private sealed class ProjectFileInventory + { + private ProjectFileInventory(List csharpFilePaths, List asmdefFilePaths) + { + CSharpFilePaths = csharpFilePaths; + AsmdefFilePaths = asmdefFilePaths; + } + + public List CSharpFilePaths { get; } + public List AsmdefFilePaths { get; } + + public static ProjectFileInventory Create(string projectRoot) + { + Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty"); + + List csharpFilePaths = new(); + List asmdefFilePaths = new(); + CollectCandidateFiles(projectRoot, csharpFilePaths, asmdefFilePaths); + csharpFilePaths.Sort(StringComparer.Ordinal); + asmdefFilePaths.Sort(StringComparer.Ordinal); + return new ProjectFileInventory(csharpFilePaths, asmdefFilePaths); + } + + private static void CollectCandidateFiles( + string directoryPath, + List csharpFilePaths, + List asmdefFilePaths) + { + Debug.Assert(!string.IsNullOrEmpty(directoryPath), "directoryPath must not be null or empty"); + Debug.Assert(csharpFilePaths != null, "csharpFilePaths must not be null"); + Debug.Assert(asmdefFilePaths != null, "asmdefFilePaths must not be null"); + + foreach (string filePath in Directory.EnumerateFiles(directoryPath)) + { + string extension = Path.GetExtension(filePath); + if (string.Equals(extension, ".cs", StringComparison.OrdinalIgnoreCase)) + { + csharpFilePaths.Add(filePath); + continue; + } + + if (string.Equals(extension, ".asmdef", StringComparison.OrdinalIgnoreCase)) + { + asmdefFilePaths.Add(filePath); + } + } + + foreach (string childDirectoryPath in Directory.EnumerateDirectories(directoryPath)) + { + string directoryName = Path.GetFileName(childDirectoryPath); + if (ThirdPartyToolMigrationRules.IsExcludedDirectoryName(directoryName)) + { + continue; + } + + CollectCandidateFiles(childDirectoryPath, csharpFilePaths, asmdefFilePaths); + } + } + } + } +} diff --git a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationFileService.cs.meta b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationFileService.cs.meta new file mode 100644 index 000000000..155b99fe4 --- /dev/null +++ b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationFileService.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8dee7da723ba64861ba649d28577d373 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationRules.cs b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationRules.cs new file mode 100644 index 000000000..320576549 --- /dev/null +++ b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationRules.cs @@ -0,0 +1,221 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Text.RegularExpressions; + +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace io.github.hatayama.UnityCliLoop.Infrastructure +{ + /// + /// Applies deterministic source rewrites for V2 custom tools that need the V3 public contract API. + /// + internal static class ThirdPartyToolMigrationRules + { + internal const string LegacyNamespace = "io.github.hatayama.uLoopMCP"; + internal const string CurrentNamespace = "io.github.hatayama.UnityCliLoop.ToolContracts"; + internal const string LegacyEditorAssemblyName = "uLoopMCP.Editor"; + internal const string LegacyEditorAssemblyGuidReference = "GUID:214998e563c124e8a88199b2dd1f522d"; + internal const string CurrentToolContractsGuidReference = "GUID:fc3fd32eddbee40e39c2d76dc184957b"; + + private static readonly string[] ExcludedDirectoryNames = + { + ".git", + "Library", + "Temp", + "Logs", + "obj", + "bin", + "Build", + "Builds" + }; + + private static readonly Regex LegacyToolAttributeWithArgumentsRegex = + new(@"\[\s*McpTool(?:Attribute)?\s*\([^\]]*\)\s*\]", RegexOptions.Compiled); + + private static readonly Regex LegacyToolAttributeRegex = + new(@"\[\s*McpTool(?:Attribute)?\s*\]", RegexOptions.Compiled); + + private static readonly ReplacementRule[] CSharpReplacementRules = + { + new(Regex.Escape(LegacyNamespace), CurrentNamespace), + new(@"\bMcpToolAttribute\b", "UnityCliLoopToolAttribute"), + new(@"\bIUnityTool\b", "IUnityCliLoopTool"), + new(@"\bAbstractUnityTool\b", "UnityCliLoopTool"), + new(@"\bBaseToolSchema\b", "UnityCliLoopToolSchema"), + new(@"\bBaseToolResponse\b", "UnityCliLoopToolResponse"), + new(@"\bCustomToolManager\b", "UnityCliLoopToolRegistrar") + }; + + internal static ThirdPartyToolMigrationContentResult MigrateCSharpSource(string source) + { + Debug.Assert(source != null, "source must not be null"); + + string migratedContent = source; + int replacementCount = 0; + migratedContent = ReplaceRegex( + migratedContent, + LegacyToolAttributeWithArgumentsRegex, + "[UnityCliLoopTool]", + ref replacementCount); + migratedContent = ReplaceRegex( + migratedContent, + LegacyToolAttributeRegex, + "[UnityCliLoopTool]", + ref replacementCount); + + foreach (ReplacementRule rule in CSharpReplacementRules) + { + migratedContent = ReplaceRegex( + migratedContent, + rule.PatternRegex, + rule.Replacement, + ref replacementCount); + } + + return new ThirdPartyToolMigrationContentResult( + migratedContent, + replacementCount); + } + + internal static ThirdPartyToolMigrationContentResult MigrateAsmdefSource( + string source, + bool hasLegacyCSharpSource) + { + Debug.Assert(source != null, "source must not be null"); + + JObject asmdef = JObject.Parse(source); + JArray references = asmdef["references"] as JArray; + if (references == null) + { + return new ThirdPartyToolMigrationContentResult(source, 0); + } + + int replacementCount = 0; + HashSet addedReferences = new(StringComparer.Ordinal); + JArray migratedReferences = new(); + foreach (JToken referenceToken in references) + { + string reference = referenceToken.Value() ?? string.Empty; + string migratedReference = GetMigratedAsmdefReference(reference, hasLegacyCSharpSource); + if (!string.Equals(reference, migratedReference, StringComparison.Ordinal)) + { + replacementCount++; + } + + if (!addedReferences.Add(migratedReference)) + { + continue; + } + + migratedReferences.Add(migratedReference); + } + + if (replacementCount == 0) + { + return new ThirdPartyToolMigrationContentResult(source, 0); + } + + asmdef["references"] = migratedReferences; + return new ThirdPartyToolMigrationContentResult( + asmdef.ToString(Formatting.Indented), + replacementCount); + } + + internal static bool ContainsLegacyCSharpApi(string source) + { + Debug.Assert(source != null, "source must not be null"); + + if (source.Contains(LegacyNamespace)) return true; + if (LegacyToolAttributeWithArgumentsRegex.IsMatch(source)) return true; + if (LegacyToolAttributeRegex.IsMatch(source)) return true; + + return CSharpReplacementRules.Any(rule => rule.PatternRegex.IsMatch(source)); + } + + internal static bool IsExcludedDirectoryName(string directoryName) + { + Debug.Assert(!string.IsNullOrEmpty(directoryName), "directoryName must not be null or empty"); + + return ExcludedDirectoryNames.Any( + excludedDirectoryName => string.Equals( + excludedDirectoryName, + directoryName, + StringComparison.OrdinalIgnoreCase)); + } + + internal static string[] GetExcludedDirectoryNames() + { + string[] names = new string[ExcludedDirectoryNames.Length]; + Array.Copy(ExcludedDirectoryNames, names, ExcludedDirectoryNames.Length); + return names; + } + + private static string GetMigratedAsmdefReference(string reference, bool hasLegacyCSharpSource) + { + if (string.Equals(reference, LegacyEditorAssemblyName, StringComparison.Ordinal)) + { + return CurrentToolContractsGuidReference; + } + + if (hasLegacyCSharpSource + && string.Equals(reference, LegacyEditorAssemblyGuidReference, StringComparison.Ordinal)) + { + return CurrentToolContractsGuidReference; + } + + return reference; + } + + private static string ReplaceRegex( + string source, + Regex regex, + string replacement, + ref int replacementCount) + { + int localReplacementCount = 0; + string migrated = regex.Replace( + source, + _ => + { + localReplacementCount++; + return replacement; + }); + replacementCount += localReplacementCount; + return migrated; + } + + private readonly struct ReplacementRule + { + public ReplacementRule(string pattern, string replacement) + { + Debug.Assert(!string.IsNullOrEmpty(pattern), "pattern must not be null or empty"); + Debug.Assert(!string.IsNullOrEmpty(replacement), "replacement must not be null or empty"); + + PatternRegex = new Regex(pattern, RegexOptions.Compiled); + Replacement = replacement; + } + + public Regex PatternRegex { get; } + public string Replacement { get; } + } + } + + internal readonly struct ThirdPartyToolMigrationContentResult + { + public ThirdPartyToolMigrationContentResult(string content, int replacementCount) + { + Debug.Assert(content != null, "content must not be null"); + Debug.Assert(replacementCount >= 0, "replacementCount must not be negative"); + + Content = content ?? string.Empty; + ReplacementCount = replacementCount; + } + + public string Content { get; } + public int ReplacementCount { get; } + public bool Changed => ReplacementCount > 0; + } +} diff --git a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationRules.cs.meta b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationRules.cs.meta new file mode 100644 index 000000000..afdf58950 --- /dev/null +++ b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationRules.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 18d9dfae996e44930809dad94d0255b6 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/Presentation/Setup/SetupWizardWindow.cs b/Packages/src/Editor/Presentation/Setup/SetupWizardWindow.cs index c9a07daf9..099156fea 100644 --- a/Packages/src/Editor/Presentation/Setup/SetupWizardWindow.cs +++ b/Packages/src/Editor/Presentation/Setup/SetupWizardWindow.cs @@ -56,11 +56,14 @@ public static void ShowWindow() internal static bool ShouldAutoShowForVersion( string currentVersion, string lastSeenVersion, - bool suppressAutoShow) + bool suppressAutoShow, + bool hasThirdPartyToolMigrationTargets) { - if (suppressAutoShow) return false; + bool versionChanged = !string.Equals(currentVersion, lastSeenVersion, System.StringComparison.Ordinal); + if (!versionChanged) return false; + if (hasThirdPartyToolMigrationTargets) return true; - return !string.Equals(currentVersion, lastSeenVersion, System.StringComparison.Ordinal); + return !suppressAutoShow; } internal static void MaybeRecordLastSeenVersion(bool shouldRecordVersion, string version) @@ -84,13 +87,30 @@ private static void TryShowOnVersionChange() string currentVersion = UnityCliLoopConstants.PackageInfo.version; UnityCliLoopEditorSettingsService editorSettingsService = GetEditorSettingsService(); bool suppressAutoShow = editorSettingsService.GetSuppressSetupWizardAutoShow(); - MaybeRecordSuppressedVersion(suppressAutoShow, currentVersion); string lastSeenVersion = editorSettingsService.GetLastSeenSetupWizardVersion(); - if (!ShouldAutoShowForVersion(currentVersion, lastSeenVersion, suppressAutoShow)) return; + bool hasThirdPartyToolMigrationTargets = suppressAutoShow + && HasThirdPartyToolMigrationTargets(); + if (!ShouldAutoShowForVersion( + currentVersion, + lastSeenVersion, + suppressAutoShow, + hasThirdPartyToolMigrationTargets)) + { + MaybeRecordSuppressedVersion(suppressAutoShow, currentVersion); + return; + } EditorApplication.delayCall += ShowWindowOnVersionChange; } + private static bool HasThirdPartyToolMigrationTargets() + { + string projectRoot = UnityCliLoopPathResolver.GetProjectRoot(); + ThirdPartyToolMigrationPreview preview = + ThirdPartyToolMigrationUseCaseRegistry.GetRegisteredUseCase().PreviewMigration(projectRoot); + return preview.HasTargets; + } + private static void ShowWindowOnVersionChange() { ShowWindowInternal(true); @@ -210,6 +230,11 @@ private static UnityCliLoopEditorSettingsService GetEditorSettingsService() private Label _skillsStatusLabel; private Button _installSkillsButton; + // V3 custom tool migration + private VisualElement _thirdPartyToolMigrationSection; + private Label _thirdPartyToolMigrationStatusLabel; + private Button _migrateThirdPartyToolsButton; + // Footer private Toggle _suppressAutoShowToggle; private Button _openSettingsButton; @@ -222,6 +247,7 @@ private static UnityCliLoopEditorSettingsService GetEditorSettingsService() // State private bool _isInstallingCli; private bool _isInstallingSkills; + private bool _isMigratingThirdPartyTools; private bool _isApplyingContentSize; private bool _isSkillsTargetFieldInitialized; private bool _shouldUseFirstInstallSkillsUi; @@ -235,6 +261,7 @@ private static UnityCliLoopEditorSettingsService GetEditorSettingsService() private CancellationTokenSource _skillInstallStateRefreshCts; private SkillsTarget _skillsTarget = SkillsTarget.Claude; private SkillSetupUseCase _skillSetupUseCase; + private ThirdPartyToolMigrationUseCase _thirdPartyToolMigrationUseCase; private UnityCliLoopEditorSettingsService _editorSettingsService; private void CreateGUI() @@ -254,6 +281,7 @@ private void CreateGUI() private void InitializeApplicationServices() { _skillSetupUseCase = SkillSetupUseCaseRegistry.GetRegisteredUseCase(); + _thirdPartyToolMigrationUseCase = ThirdPartyToolMigrationUseCaseRegistry.GetRegisteredUseCase(); _editorSettingsService = GetEditorSettingsService(); } @@ -310,6 +338,13 @@ private void BindElements() _skillsStatusLabel = rootVisualElement.Q