diff --git a/.gitignore b/.gitignore
index db42b195b..d32cf9a63 100644
--- a/.gitignore
+++ b/.gitignore
@@ -108,22 +108,20 @@ yarn-error.log*
.npm
.yarn-integrity
-# TypeScript Server (exclude development builds, commit only production bundle)
-Packages/src/TypeScriptServer~/dist/*
-!Packages/src/TypeScriptServer~/dist/server.bundle.js
-!Packages/src/TypeScriptServer~/dist/server.bundle.js.map
-
# Native Go CLI release artifacts are included in the Unity package
Packages/src/GoCli~/dist/*
!Packages/src/GoCli~/dist/darwin-arm64/
!Packages/src/GoCli~/dist/darwin-amd64/
!Packages/src/GoCli~/dist/windows-amd64/
-!Packages/src/GoCli~/dist/darwin-arm64/uloop
-!Packages/src/GoCli~/dist/darwin-amd64/uloop
-!Packages/src/GoCli~/dist/windows-amd64/uloop.exe
-!Packages/src/GoCli~/dist/darwin-arm64/uloop-launcher
-!Packages/src/GoCli~/dist/darwin-amd64/uloop-launcher
-!Packages/src/GoCli~/dist/windows-amd64/uloop-launcher.exe
+Packages/src/GoCli~/dist/darwin-arm64/*
+Packages/src/GoCli~/dist/darwin-amd64/*
+Packages/src/GoCli~/dist/windows-amd64/*
+!Packages/src/GoCli~/dist/darwin-arm64/uloop-core
+!Packages/src/GoCli~/dist/darwin-arm64/uloop-dispatcher
+!Packages/src/GoCli~/dist/darwin-amd64/uloop-core
+!Packages/src/GoCli~/dist/darwin-amd64/uloop-dispatcher
+!Packages/src/GoCli~/dist/windows-amd64/uloop-core.exe
+!Packages/src/GoCli~/dist/windows-amd64/uloop-dispatcher.exe
Packages/src/GoCli~/release/
# Environment configuration files
diff --git a/Assets/Tests/Editor/NativeCliInstallerTests.cs b/Assets/Tests/Editor/NativeCliInstallerTests.cs
index dc3837bf1..c339abd09 100644
--- a/Assets/Tests/Editor/NativeCliInstallerTests.cs
+++ b/Assets/Tests/Editor/NativeCliInstallerTests.cs
@@ -1,3 +1,5 @@
+using System.IO;
+using System.Runtime.InteropServices;
using NUnit.Framework;
using UnityEngine;
@@ -6,9 +8,9 @@ namespace io.github.hatayama.UnityCliLoop.Tests
public class NativeCliInstallerTests
{
[Test]
- public void GetInstallCommand_OnMacUsesDirectInstallScriptWithoutNpm()
+ public void GetInstallCommand_OnMacKeepsCliOnlyCurlInstallerAvailable()
{
- // Verifies that macOS installs through the native release script, not npm.
+ // Verifies that CLI-only macOS users still have the direct release script, not npm.
NativeCliInstallCommand command = NativeCliInstaller.GetInstallCommand(
RuntimePlatform.OSXEditor,
"3.0.0-beta.0",
@@ -23,9 +25,9 @@ public void GetInstallCommand_OnMacUsesDirectInstallScriptWithoutNpm()
}
[Test]
- public void GetInstallCommand_OnWindowsUsesPowerShellInstallScriptWithoutNpm()
+ public void GetInstallCommand_OnWindowsKeepsCliOnlyPowerShellInstallerAvailable()
{
- // Verifies that Windows installs through the native release script, not npm.
+ // Verifies that CLI-only Windows users still have the direct release script, not npm.
NativeCliInstallCommand command = NativeCliInstaller.GetInstallCommand(
RuntimePlatform.WindowsEditor,
"3.0.0-beta.0",
@@ -40,9 +42,9 @@ public void GetInstallCommand_OnWindowsUsesPowerShellInstallScriptWithoutNpm()
}
[Test]
- public void GetInstallCommand_OnMacCanOptIntoLegacyNpmRemoval()
+ public void GetInstallCommand_OnMacCliOnlyInstallerCanOptIntoLegacyNpmRemoval()
{
- // Verifies that UI-triggered macOS installs can opt into removing the legacy npm launcher.
+ // Verifies that CLI-only macOS installs can opt into removing the legacy npm launcher.
NativeCliInstallCommand command = NativeCliInstaller.GetInstallCommand(
RuntimePlatform.OSXEditor,
"3.0.0-beta.0",
@@ -53,9 +55,9 @@ public void GetInstallCommand_OnMacCanOptIntoLegacyNpmRemoval()
}
[Test]
- public void GetInstallCommand_OnWindowsCanOptIntoLegacyNpmRemoval()
+ public void GetInstallCommand_OnWindowsCliOnlyInstallerCanOptIntoLegacyNpmRemoval()
{
- // Verifies that UI-triggered Windows installs can opt into removing the legacy npm launcher.
+ // Verifies that CLI-only Windows installs can opt into removing the legacy npm launcher.
NativeCliInstallCommand command = NativeCliInstaller.GetInstallCommand(
RuntimePlatform.WindowsEditor,
"3.0.0-beta.0",
@@ -65,6 +67,219 @@ public void GetInstallCommand_OnWindowsCanOptIntoLegacyNpmRemoval()
Assert.That(command.ManualCommand, Does.Contain("$env:ULOOP_REMOVE_LEGACY='1'"));
}
+ [Test]
+ public void GetGlobalCliBundlePath_OnMacArm64UsesPackagedDispatcher()
+ {
+ // Verifies that the editor installer reads the bundled macOS dispatcher from the package.
+ string result = NativeCliInstaller.GetGlobalCliBundlePath(
+ "/package",
+ RuntimePlatform.OSXEditor,
+ Architecture.Arm64);
+
+ Assert.That(result, Is.EqualTo(Path.Combine(
+ "/package",
+ "GoCli~",
+ "dist",
+ "darwin-arm64",
+ "uloop-dispatcher")));
+ }
+
+ [Test]
+ public void GetGlobalCliBundlePath_OnWindowsUsesPackagedDispatcher()
+ {
+ // Verifies that the editor installer reads the bundled Windows dispatcher from the package.
+ string result = NativeCliInstaller.GetGlobalCliBundlePath(
+ "C:\\package",
+ RuntimePlatform.WindowsEditor,
+ Architecture.X64);
+
+ Assert.That(result, Is.EqualTo(Path.Combine(
+ "C:\\package",
+ "GoCli~",
+ "dist",
+ "windows-amd64",
+ "uloop-dispatcher.exe")));
+ }
+
+ [Test]
+ public void InstallGlobalCliFromBundle_OnWindowsCopiesDispatcherAsUloopExe()
+ {
+ // Verifies that editor install copies the bundled dispatcher as the user-facing uloop command.
+ string tempRoot = Path.Combine(
+ Path.GetTempPath(),
+ "uloop-native-installer-tests",
+ System.Guid.NewGuid().ToString("N"));
+ string sourceDir = Path.Combine(tempRoot, "source");
+ string sourcePath = Path.Combine(sourceDir, "uloop-dispatcher.exe");
+ string installDir = Path.Combine(tempRoot, "install");
+
+ Directory.CreateDirectory(sourceDir);
+ File.WriteAllText(sourcePath, "fake-binary");
+
+ try
+ {
+ CliInstallResult result = NativeCliInstaller.InstallGlobalCliFromBundle(
+ sourcePath,
+ installDir,
+ RuntimePlatform.WindowsEditor);
+
+ string installedPath = NativeCliInstaller.GetGlobalCliInstallPath(
+ installDir,
+ RuntimePlatform.WindowsEditor);
+ Assert.That(result.Success, Is.True);
+ Assert.That(result.ErrorOutput, Is.Empty);
+ Assert.That(Path.GetFileName(installedPath), Is.EqualTo("uloop.exe"));
+ Assert.That(File.ReadAllText(installedPath), Is.EqualTo("fake-binary"));
+ }
+ finally
+ {
+ if (Directory.Exists(tempRoot))
+ {
+ Directory.Delete(tempRoot, true);
+ }
+ }
+ }
+
+ [Test]
+ public void InstallGlobalCliFromBundle_WhenInstallPathExistsReplacesPreviousCommand()
+ {
+ // Verifies that editor install swaps the staged dispatcher into the final command path.
+ string tempRoot = Path.Combine(
+ Path.GetTempPath(),
+ "uloop-native-installer-tests",
+ System.Guid.NewGuid().ToString("N"));
+ string sourceDir = Path.Combine(tempRoot, "source");
+ string sourcePath = Path.Combine(sourceDir, "uloop-dispatcher.exe");
+ string installDir = Path.Combine(tempRoot, "install");
+ string installPath = NativeCliInstaller.GetGlobalCliInstallPath(
+ installDir,
+ RuntimePlatform.WindowsEditor);
+
+ Directory.CreateDirectory(sourceDir);
+ Directory.CreateDirectory(installDir);
+ File.WriteAllText(sourcePath, "new-binary");
+ File.WriteAllText(installPath, "old-binary");
+
+ try
+ {
+ CliInstallResult result = NativeCliInstaller.InstallGlobalCliFromBundle(
+ sourcePath,
+ installDir,
+ RuntimePlatform.WindowsEditor);
+
+ Assert.That(result.Success, Is.True);
+ Assert.That(File.ReadAllText(installPath), Is.EqualTo("new-binary"));
+ Assert.That(Directory.GetFiles(installDir, ".uloop.exe.install-*"), Is.Empty);
+ }
+ finally
+ {
+ if (Directory.Exists(tempRoot))
+ {
+ Directory.Delete(tempRoot, true);
+ }
+ }
+ }
+
+ [Test]
+ public void InstallGlobalCliFromBundle_WhenBundleIsMissingReturnsFailure()
+ {
+ // Verifies that editor install reports a missing packaged dispatcher without creating the install dir.
+ string tempRoot = Path.Combine(
+ Path.GetTempPath(),
+ "uloop-native-installer-tests",
+ System.Guid.NewGuid().ToString("N"));
+ string sourcePath = Path.Combine(tempRoot, "missing", "uloop-dispatcher.exe");
+ string installDir = Path.Combine(tempRoot, "install");
+
+ try
+ {
+ CliInstallResult result = NativeCliInstaller.InstallGlobalCliFromBundle(
+ sourcePath,
+ installDir,
+ RuntimePlatform.WindowsEditor);
+
+ Assert.That(result.Success, Is.False);
+ Assert.That(result.ErrorOutput, Does.Contain("Global CLI dispatcher binary was not found"));
+ Assert.That(Directory.Exists(installDir), Is.False);
+ }
+ finally
+ {
+ if (Directory.Exists(tempRoot))
+ {
+ Directory.Delete(tempRoot, true);
+ }
+ }
+ }
+
+ [Test]
+ public void InstallGlobalCliFromBundle_WhenInstallDirectoryIsFileReturnsFailure()
+ {
+ // Verifies that expected filesystem setup failures stay inside the installer result contract.
+ string tempRoot = Path.Combine(
+ Path.GetTempPath(),
+ "uloop-native-installer-tests",
+ System.Guid.NewGuid().ToString("N"));
+ string sourceDir = Path.Combine(tempRoot, "source");
+ string sourcePath = Path.Combine(sourceDir, "uloop-dispatcher.exe");
+ string installDir = Path.Combine(tempRoot, "install-as-file");
+
+ Directory.CreateDirectory(sourceDir);
+ File.WriteAllText(sourcePath, "fake-binary");
+ File.WriteAllText(installDir, "not-a-directory");
+
+ try
+ {
+ CliInstallResult result = NativeCliInstaller.InstallGlobalCliFromBundle(
+ sourcePath,
+ installDir,
+ RuntimePlatform.WindowsEditor);
+
+ Assert.That(result.Success, Is.False);
+ Assert.That(result.ErrorOutput, Does.Contain("Failed to install bundled CLI dispatcher"));
+ }
+ finally
+ {
+ if (Directory.Exists(tempRoot))
+ {
+ Directory.Delete(tempRoot, true);
+ }
+ }
+ }
+
+ [Test]
+ public void InstallGlobalCliFromBundle_WhenInstallDirectoryContainsNullCharacterReturnsFailure()
+ {
+ // Verifies that invalid user-provided install paths stay inside the installer result contract.
+ string tempRoot = Path.Combine(
+ Path.GetTempPath(),
+ "uloop-native-installer-tests",
+ System.Guid.NewGuid().ToString("N"));
+ string sourceDir = Path.Combine(tempRoot, "source");
+ string sourcePath = Path.Combine(sourceDir, "uloop-dispatcher.exe");
+ string installDir = tempRoot + Path.DirectorySeparatorChar + "bad\0path";
+
+ Directory.CreateDirectory(sourceDir);
+ File.WriteAllText(sourcePath, "fake-binary");
+
+ try
+ {
+ CliInstallResult result = NativeCliInstaller.InstallGlobalCliFromBundle(
+ sourcePath,
+ installDir,
+ RuntimePlatform.WindowsEditor);
+
+ Assert.That(result.Success, Is.False);
+ Assert.That(result.ErrorOutput, Does.Contain("Failed to install bundled CLI dispatcher"));
+ }
+ finally
+ {
+ if (Directory.Exists(tempRoot))
+ {
+ Directory.Delete(tempRoot, true);
+ }
+ }
+ }
+
[Test]
public void BuildPathWithInstallDirectory_OnWindowsPrependsMissingNativeInstallDir()
{
@@ -101,6 +316,169 @@ public void BuildPathWithInstallDirectory_OnMacPrependsMissingNativeInstallDir()
Assert.That(result, Is.EqualTo("/Users/masamichi/.local/bin:/usr/local/bin"));
}
+ [Test]
+ public void PersistInstallDirectoryToUserPath_OnWindowsUpdatesUserPath()
+ {
+ // Verifies that Windows editor installs survive Unity restarts by updating User PATH.
+ string capturedName = null;
+ string capturedValue = null;
+ System.EnvironmentVariableTarget capturedTarget = default;
+
+ CliInstallResult result = NativeCliInstaller.PersistInstallDirectoryToUserPath(
+ "C:\\Users\\masamichi\\Programs\\uloop\\bin",
+ RuntimePlatform.WindowsEditor,
+ (name, target) => "C:\\npm",
+ (name, value, target) =>
+ {
+ capturedName = name;
+ capturedValue = value;
+ capturedTarget = target;
+ });
+
+ Assert.That(result.Success, Is.True);
+ Assert.That(capturedName, Is.EqualTo("Path"));
+ Assert.That(capturedValue, Is.EqualTo("C:\\Users\\masamichi\\Programs\\uloop\\bin;C:\\npm"));
+ Assert.That(capturedTarget, Is.EqualTo(System.EnvironmentVariableTarget.User));
+ }
+
+ [Test]
+ public void PersistInstallDirectoryToUserPath_OnMacDoesNothing()
+ {
+ // Verifies that POSIX editor installs do not attempt unsupported .NET User PATH writes.
+ bool wroteUserPath = false;
+
+ CliInstallResult result = NativeCliInstaller.PersistInstallDirectoryToUserPath(
+ "/Users/masamichi/.local/bin",
+ RuntimePlatform.OSXEditor,
+ (name, target) => "/usr/local/bin",
+ (name, value, target) => { wroteUserPath = true; });
+
+ Assert.That(result.Success, Is.True);
+ Assert.That(wroteUserPath, Is.False);
+ }
+
+ [Test]
+ public void PersistInstallDirectoryToUserPath_OnWindowsSurfacesPermissionFailure()
+ {
+ // Verifies that permission failures are reported instead of crashing the editor installer.
+ CliInstallResult result = NativeCliInstaller.PersistInstallDirectoryToUserPath(
+ "C:\\Users\\masamichi\\Programs\\uloop\\bin",
+ RuntimePlatform.WindowsEditor,
+ (name, target) => "C:\\npm",
+ (name, value, target) => throw new System.UnauthorizedAccessException("denied"));
+
+ Assert.That(result.Success, Is.False);
+ Assert.That(result.ErrorOutput, Does.Contain("failed to persist the uLoop CLI install directory"));
+ Assert.That(result.ErrorOutput, Does.Contain("denied"));
+ }
+
+ [Test]
+ public void RemoveLegacyNpmPackageIfPresent_WhenPackageMissingSkipsUninstall()
+ {
+ // Verifies that package installs do not require npm when the legacy launcher is absent.
+ int runCount = 0;
+
+ CliInstallResult result = NativeCliInstaller.RemoveLegacyNpmPackageIfPresent(
+ RuntimePlatform.OSXEditor,
+ (command, platform) =>
+ {
+ runCount++;
+ Assert.That(command.ManualCommand, Does.Contain("npm list -g uloop-cli"));
+ return new CliInstallResult(false, "");
+ });
+
+ Assert.That(result.Success, Is.True);
+ Assert.That(runCount, Is.EqualTo(1));
+ }
+
+ [Test]
+ public void RemoveLegacyNpmPackageIfPresent_WhenPackageExistsRunsUninstall()
+ {
+ // Verifies that package installs preserve the previous UI cleanup of the legacy npm launcher.
+ int runCount = 0;
+
+ CliInstallResult result = NativeCliInstaller.RemoveLegacyNpmPackageIfPresent(
+ RuntimePlatform.WindowsEditor,
+ (command, platform) =>
+ {
+ runCount++;
+ string expectedCommand = runCount == 1
+ ? "npm list -g uloop-cli"
+ : "npm uninstall -g uloop-cli";
+ Assert.That(command.ManualCommand, Does.Contain(expectedCommand));
+ return new CliInstallResult(true, "");
+ });
+
+ Assert.That(result.Success, Is.True);
+ Assert.That(runCount, Is.EqualTo(2));
+ }
+
+ [Test]
+ public void RemoveLegacyNpmPackageIfPresent_WhenUninstallFailsReturnsManualCommand()
+ {
+ // Verifies that package installs fail visibly when the legacy launcher cannot be removed.
+ int runCount = 0;
+
+ CliInstallResult result = NativeCliInstaller.RemoveLegacyNpmPackageIfPresent(
+ RuntimePlatform.WindowsEditor,
+ (command, platform) =>
+ {
+ runCount++;
+ return runCount == 1
+ ? new CliInstallResult(true, "")
+ : new CliInstallResult(false, "denied");
+ });
+
+ Assert.That(result.Success, Is.False);
+ Assert.That(result.ErrorOutput, Does.Contain("Failed to remove legacy npm installation"));
+ Assert.That(result.ErrorOutput, Does.Contain("npm uninstall -g uloop-cli"));
+ Assert.That(result.ErrorOutput, Does.Contain("denied"));
+ }
+
+ [Test]
+ public void FinishSuccessfulBundleInstall_WhenLegacyRemovalFailsStillUpdatesPaths()
+ {
+ // Verifies that a copied dispatcher still updates PATH before reporting legacy cleanup failure.
+ bool appliedCurrentPath = false;
+ bool persistedUserPath = false;
+
+ CliInstallResult result = NativeCliInstaller.FinishSuccessfulBundleInstall(
+ new CliInstallResult(true, ""),
+ "C:\\Users\\masamichi\\Programs\\uloop\\bin",
+ RuntimePlatform.WindowsEditor,
+ platform => new CliInstallResult(false, "legacy failed"),
+ platform => { appliedCurrentPath = true; },
+ (installDirectory, platform) =>
+ {
+ persistedUserPath = true;
+ return new CliInstallResult(true, "");
+ });
+
+ Assert.That(result.Success, Is.False);
+ Assert.That(result.ErrorOutput, Does.Contain("legacy failed"));
+ Assert.That(appliedCurrentPath, Is.True);
+ Assert.That(persistedUserPath, Is.True);
+ }
+
+ [Test]
+ public void FinishSuccessfulBundleInstall_WhenPathPersistenceFailsReturnsPathFailure()
+ {
+ // Verifies that PATH persistence failure is reported after the current process PATH is updated.
+ bool appliedCurrentPath = false;
+
+ CliInstallResult result = NativeCliInstaller.FinishSuccessfulBundleInstall(
+ new CliInstallResult(true, ""),
+ "C:\\Users\\masamichi\\Programs\\uloop\\bin",
+ RuntimePlatform.WindowsEditor,
+ platform => new CliInstallResult(false, "legacy failed"),
+ platform => { appliedCurrentPath = true; },
+ (installDirectory, platform) => new CliInstallResult(false, "path failed"));
+
+ Assert.That(result.Success, Is.False);
+ Assert.That(result.ErrorOutput, Does.Contain("path failed"));
+ Assert.That(appliedCurrentPath, Is.True);
+ }
+
[Test]
public void GetDefaultInstallDirectoryFromRoots_OnMacMatchesInstallerDefault()
{
diff --git a/Packages/src/Editor/CLI/CliConstants.cs b/Packages/src/Editor/CLI/CliConstants.cs
index 37f3eb5dc..75eee08b9 100644
--- a/Packages/src/Editor/CLI/CliConstants.cs
+++ b/Packages/src/Editor/CLI/CliConstants.cs
@@ -27,6 +27,11 @@ public static class CliConstants
public const string SKILL_DIR_GLOB = "uloop-*";
public const string GO_CLI_PACKAGE_DIR_NAME = "GoCli~";
public const string DIST_DIR_NAME = "dist";
+ public const string GLOBAL_UNIX_COMMAND_NAME = EXECUTABLE_NAME;
+ public const string GLOBAL_WINDOWS_COMMAND_NAME = EXECUTABLE_NAME + ".exe";
+ public const string GLOBAL_DISPATCHER_UNIX_BUNDLE_NAME = "uloop-dispatcher";
+ public const string GLOBAL_DISPATCHER_WINDOWS_BUNDLE_NAME = "uloop-dispatcher.exe";
+ public const string LEGACY_NPM_PACKAGE_NAME = "uloop-cli";
public const string PROJECT_LOCAL_BIN_DIR_NAME = "bin";
public const string PROJECT_LOCAL_UNIX_COMMAND_NAME = "uloop-core";
public const string PROJECT_LOCAL_WINDOWS_COMMAND_NAME = "uloop-core.exe";
diff --git a/Packages/src/Editor/CLI/NativeCliInstaller.cs b/Packages/src/Editor/CLI/NativeCliInstaller.cs
index 658c1af0a..daada971b 100644
--- a/Packages/src/Editor/CLI/NativeCliInstaller.cs
+++ b/Packages/src/Editor/CLI/NativeCliInstaller.cs
@@ -1,6 +1,8 @@
using System;
using System.Diagnostics;
using System.IO;
+using System.Runtime.InteropServices;
+using System.Security;
using System.Text;
using System.Threading.Tasks;
using UnityEngine;
@@ -26,10 +28,12 @@ public NativeCliInstallCommand(string fileName, string arguments, string manualC
}
///
- /// Centralizes native installer invocation so editor setup UI and Go CLI update use the same direct-distribution channel.
+ /// Installs the package-owned global dispatcher while keeping release-script commands available for CLI-only users.
///
public static class NativeCliInstaller
{
+ private const int CHMOD_TIMEOUT_MS = 5000;
+
public static NativeCliInstallCommand GetInstallCommand(
RuntimePlatform platform,
string packageVersion,
@@ -64,61 +68,216 @@ public static NativeCliInstallCommand GetInstallCommand(
public static async Task InstallAsync(
RuntimePlatform platform,
- string packageVersion,
- bool removeLegacyNpm)
+ string packageVersion)
{
- NativeCliInstallCommand command = GetInstallCommand(platform, packageVersion, removeLegacyNpm);
- ProcessStartInfo startInfo = new ProcessStartInfo
+ UnityEngine.Debug.Assert(!string.IsNullOrWhiteSpace(packageVersion), "packageVersion must not be null or empty");
+
+ string installDirectory = GetInstallDirectoryForCurrentUser(platform);
+ if (string.IsNullOrWhiteSpace(installDirectory))
{
- FileName = command.FileName,
- Arguments = command.Arguments,
- UseShellExecute = false,
- RedirectStandardOutput = true,
- RedirectStandardError = true,
- CreateNoWindow = true
- };
+ return new CliInstallResult(
+ false,
+ $"Could not resolve the global CLI install directory. Set {CliConstants.INSTALL_DIR_ENVIRONMENT_VARIABLE} and try again.");
+ }
- bool success = false;
- string errorOutput = "";
+ string sourceBinaryPath = GetGlobalCliBundlePath(
+ McpConstants.PackageResolvedPath,
+ platform,
+ RuntimeInformation.ProcessArchitecture);
- await Task.Run(() =>
+ CliInstallResult result = await Task.Run(() => InstallGlobalCliFromBundle(
+ sourceBinaryPath,
+ installDirectory,
+ platform));
+ CliInstallationDetector.InvalidateCache();
+
+ if (result.Success)
{
- ApplyInstallerSearchPath(startInfo, platform);
- Process process = ProcessStartHelper.TryStart(startInfo);
- if (process == null)
+ result = FinishSuccessfulBundleInstall(
+ result,
+ installDirectory,
+ platform,
+ currentPlatform => RemoveLegacyNpmPackageIfPresent(currentPlatform, RunInstallCommand),
+ ApplyInstallDirectoryToCurrentProcessPath,
+ (currentInstallDirectory, currentPlatform) => PersistInstallDirectoryToUserPath(
+ currentInstallDirectory,
+ currentPlatform,
+ Environment.GetEnvironmentVariable,
+ Environment.SetEnvironmentVariable));
+ }
+
+ return result;
+ }
+
+ internal static CliInstallResult InstallGlobalCliFromBundle(
+ string sourceBinaryPath,
+ string installDirectory,
+ RuntimePlatform platform)
+ {
+ UnityEngine.Debug.Assert(!string.IsNullOrEmpty(sourceBinaryPath), "sourceBinaryPath must not be null or empty");
+ UnityEngine.Debug.Assert(!string.IsNullOrEmpty(installDirectory), "installDirectory must not be null or empty");
+
+ if (!File.Exists(sourceBinaryPath))
+ {
+ return new CliInstallResult(
+ false,
+ $"Global CLI dispatcher binary was not found for {platform}/{RuntimeInformation.ProcessArchitecture}: {sourceBinaryPath}");
+ }
+
+ try
+ {
+ string installPath = GetGlobalCliInstallPath(installDirectory, platform);
+ string stagedInstallPath = GetStagedGlobalCliInstallPath(installDirectory, platform);
+ Directory.CreateDirectory(installDirectory);
+ File.Copy(sourceBinaryPath, stagedInstallPath, overwrite: true);
+
+ CliInstallResult executableResult = MakeGlobalCliExecutable(stagedInstallPath, platform);
+ if (!executableResult.Success)
{
- errorOutput = $"Failed to start installer process. Run manually:\n{command.ManualCommand}";
- return;
+ File.Delete(stagedInstallPath);
+ return executableResult;
}
- using (process)
+ ReplaceInstalledCliFromStaged(stagedInstallPath, installPath);
+ return executableResult;
+ }
+ catch (IOException ex)
+ {
+ return BuildBundledCliInstallFailure(ex);
+ }
+ catch (UnauthorizedAccessException ex)
+ {
+ return BuildBundledCliInstallFailure(ex);
+ }
+ catch (ArgumentException ex)
+ {
+ return BuildBundledCliInstallFailure(ex);
+ }
+ catch (NotSupportedException ex)
+ {
+ return BuildBundledCliInstallFailure(ex);
+ }
+ }
+
+ internal static string GetGlobalCliBundlePath(
+ string packageResolvedPath,
+ RuntimePlatform platform,
+ Architecture architecture)
+ {
+ UnityEngine.Debug.Assert(!string.IsNullOrEmpty(packageResolvedPath), "packageResolvedPath must not be null or empty");
+
+ return Path.Combine(
+ packageResolvedPath,
+ CliConstants.GO_CLI_PACKAGE_DIR_NAME,
+ CliConstants.DIST_DIR_NAME,
+ GetNativeCliPlatformDir(platform, architecture),
+ GetGlobalCliBundleFileName(platform));
+ }
+
+ internal static string GetGlobalCliInstallPath(string installDirectory, RuntimePlatform platform)
+ {
+ UnityEngine.Debug.Assert(!string.IsNullOrEmpty(installDirectory), "installDirectory must not be null or empty");
+
+ return Path.Combine(installDirectory, GetGlobalCliInstallFileName(platform));
+ }
+
+ internal static CliInstallResult PersistInstallDirectoryToUserPath(
+ string installDirectory,
+ RuntimePlatform platform,
+ Func getEnvironmentVariable,
+ Action setEnvironmentVariable)
+ {
+ UnityEngine.Debug.Assert(!string.IsNullOrEmpty(installDirectory), "installDirectory must not be null or empty");
+ UnityEngine.Debug.Assert(getEnvironmentVariable != null, "getEnvironmentVariable must not be null");
+ UnityEngine.Debug.Assert(setEnvironmentVariable != null, "setEnvironmentVariable must not be null");
+
+ if (platform != RuntimePlatform.WindowsEditor)
+ {
+ return new CliInstallResult(true, "");
+ }
+
+ string pathVariableName = GetPathEnvironmentVariableName(platform);
+ try
+ {
+ string currentUserPath = getEnvironmentVariable(pathVariableName, EnvironmentVariableTarget.User);
+ string updatedUserPath = BuildPathWithInstallDirectory(currentUserPath, installDirectory, platform);
+ if (string.Equals(currentUserPath, updatedUserPath, GetPathComparison(platform)))
{
- StringBuilder errorBuilder = new StringBuilder();
- process.OutputDataReceived += (s, e) => { };
- process.ErrorDataReceived += (s, e) => { if (e.Data != null) errorBuilder.AppendLine(e.Data); };
- process.BeginOutputReadLine();
- process.BeginErrorReadLine();
-
- if (!process.WaitForExit(CliConstants.GLOBAL_INSTALL_TIMEOUT_MS))
- {
- if (!process.HasExited) process.Kill();
- errorOutput = $"Installation timed out after {CliConstants.GLOBAL_INSTALL_TIMEOUT_MS / 1000} seconds.\nRun manually:\n{command.ManualCommand}";
- return;
- }
-
- process.WaitForExit();
- errorOutput = errorBuilder.ToString();
- success = process.ExitCode == 0;
+ return new CliInstallResult(true, "");
}
- });
- if (success)
+ setEnvironmentVariable(pathVariableName, updatedUserPath, EnvironmentVariableTarget.User);
+ return new CliInstallResult(true, "");
+ }
+ catch (SecurityException ex)
+ {
+ return BuildUserPathPersistenceFailure(ex);
+ }
+ catch (UnauthorizedAccessException ex)
{
- ApplyInstallDirectoryToCurrentProcessPath(platform);
+ return BuildUserPathPersistenceFailure(ex);
}
+ }
- CliInstallationDetector.InvalidateCache();
- return new CliInstallResult(success, errorOutput);
+ internal static CliInstallResult RemoveLegacyNpmPackageIfPresent(
+ RuntimePlatform platform,
+ Func runCommand)
+ {
+ UnityEngine.Debug.Assert(runCommand != null, "runCommand must not be null");
+
+ NativeCliInstallCommand detectCommand = GetLegacyNpmListCommand(platform);
+ CliInstallResult detectResult = runCommand(detectCommand, platform);
+ if (!detectResult.Success)
+ {
+ return new CliInstallResult(true, "");
+ }
+
+ NativeCliInstallCommand uninstallCommand = GetLegacyNpmUninstallCommand(platform);
+ CliInstallResult uninstallResult = runCommand(uninstallCommand, platform);
+ if (uninstallResult.Success)
+ {
+ return new CliInstallResult(true, "");
+ }
+
+ string errorOutput =
+ $"Failed to remove legacy npm installation: {CliConstants.LEGACY_NPM_PACKAGE_NAME}\n"
+ + "The bundled dispatcher was installed, but an older npm launcher may still shadow it.\n"
+ + "Run manually:\n"
+ + $" {uninstallCommand.ManualCommand}\n"
+ + uninstallResult.ErrorOutput;
+ return new CliInstallResult(false, errorOutput);
+ }
+
+ internal static CliInstallResult FinishSuccessfulBundleInstall(
+ CliInstallResult installResult,
+ string installDirectory,
+ RuntimePlatform platform,
+ Func removeLegacyNpmPackage,
+ Action applyInstallDirectoryToCurrentProcessPath,
+ Func persistInstallDirectoryToUserPath)
+ {
+ UnityEngine.Debug.Assert(installResult.Success, "installResult must be successful");
+ UnityEngine.Debug.Assert(!string.IsNullOrEmpty(installDirectory), "installDirectory must not be null or empty");
+ UnityEngine.Debug.Assert(removeLegacyNpmPackage != null, "removeLegacyNpmPackage must not be null");
+ UnityEngine.Debug.Assert(applyInstallDirectoryToCurrentProcessPath != null, "applyInstallDirectoryToCurrentProcessPath must not be null");
+ UnityEngine.Debug.Assert(persistInstallDirectoryToUserPath != null, "persistInstallDirectoryToUserPath must not be null");
+
+ CliInstallResult legacyResult = removeLegacyNpmPackage(platform);
+ CliInstallResult result = legacyResult.Success ? installResult : legacyResult;
+
+ applyInstallDirectoryToCurrentProcessPath(platform);
+ CliInstallResult persistResult = persistInstallDirectoryToUserPath(installDirectory, platform);
+ return persistResult.Success ? result : persistResult;
+ }
+
+ private static string GetStagedGlobalCliInstallPath(string installDirectory, RuntimePlatform platform)
+ {
+ UnityEngine.Debug.Assert(!string.IsNullOrEmpty(installDirectory), "installDirectory must not be null or empty");
+
+ string fileName = GetGlobalCliInstallFileName(platform);
+ return Path.Combine(
+ installDirectory,
+ $".{fileName}.install-{Guid.NewGuid():N}");
}
internal static string BuildPathWithInstallDirectory(
@@ -184,24 +343,6 @@ internal static string GetDefaultInstallDirectoryFromRoots(
CliConstants.NATIVE_INSTALL_BIN_DIR_NAME);
}
- private static void ApplyInstallerSearchPath(ProcessStartInfo startInfo, RuntimePlatform platform)
- {
- UnityEngine.Debug.Assert(startInfo != null, "startInfo must not be null");
-
- if (platform == RuntimePlatform.WindowsEditor)
- {
- return;
- }
-
- string loginShellPath = NodeEnvironmentResolver.GetLoginShellPathAtPlatform(platform);
- if (string.IsNullOrEmpty(loginShellPath))
- {
- return;
- }
-
- startInfo.Environment[CliConstants.POSIX_PATH_ENVIRONMENT_VARIABLE] = loginShellPath;
- }
-
private static void ApplyInstallDirectoryToCurrentProcessPath(RuntimePlatform platform)
{
string installDirectory = GetInstallDirectoryForCurrentUser(platform);
@@ -255,6 +396,218 @@ private static StringComparison GetPathComparison(RuntimePlatform platform)
: StringComparison.Ordinal;
}
+ private static void ReplaceInstalledCliFromStaged(string stagedInstallPath, string installPath)
+ {
+ UnityEngine.Debug.Assert(!string.IsNullOrEmpty(stagedInstallPath), "stagedInstallPath must not be null or empty");
+ UnityEngine.Debug.Assert(!string.IsNullOrEmpty(installPath), "installPath must not be null or empty");
+
+ if (!File.Exists(installPath))
+ {
+ File.Move(stagedInstallPath, installPath);
+ return;
+ }
+
+ File.Replace(stagedInstallPath, installPath, null, true);
+ }
+
+ private static CliInstallResult BuildUserPathPersistenceFailure(Exception ex)
+ {
+ UnityEngine.Debug.Assert(ex != null, "ex must not be null");
+
+ string errorOutput =
+ "Installed the uLoop CLI binary, but failed to persist the uLoop CLI install directory in the Windows User PATH. "
+ + $"Update {CliConstants.WINDOWS_PATH_ENVIRONMENT_VARIABLE} manually or run the CLI-only installer.\n{ex.Message}";
+ return new CliInstallResult(false, errorOutput);
+ }
+
+ private static CliInstallResult BuildBundledCliInstallFailure(Exception ex)
+ {
+ UnityEngine.Debug.Assert(ex != null, "ex must not be null");
+
+ string errorOutput = $"Failed to install bundled CLI dispatcher: {ex.Message}";
+ return new CliInstallResult(false, errorOutput);
+ }
+
+ private static string GetGlobalCliBundleFileName(RuntimePlatform platform)
+ {
+ return platform == RuntimePlatform.WindowsEditor
+ ? CliConstants.GLOBAL_DISPATCHER_WINDOWS_BUNDLE_NAME
+ : CliConstants.GLOBAL_DISPATCHER_UNIX_BUNDLE_NAME;
+ }
+
+ private static string GetGlobalCliInstallFileName(RuntimePlatform platform)
+ {
+ return platform == RuntimePlatform.WindowsEditor
+ ? CliConstants.GLOBAL_WINDOWS_COMMAND_NAME
+ : CliConstants.GLOBAL_UNIX_COMMAND_NAME;
+ }
+
+ private static string GetNativeCliPlatformDir(RuntimePlatform platform, Architecture architecture)
+ {
+ if (platform == RuntimePlatform.OSXEditor)
+ {
+ return architecture == Architecture.Arm64 ? "darwin-arm64" : "darwin-amd64";
+ }
+
+ if (platform == RuntimePlatform.WindowsEditor)
+ {
+ return "windows-amd64";
+ }
+
+ return "unsupported";
+ }
+
+ private static CliInstallResult MakeGlobalCliExecutable(string installPath, RuntimePlatform platform)
+ {
+ UnityEngine.Debug.Assert(!string.IsNullOrEmpty(installPath), "installPath must not be null or empty");
+
+ if (platform == RuntimePlatform.WindowsEditor)
+ {
+ return new CliInstallResult(true, "");
+ }
+
+ ProcessStartInfo startInfo = new ProcessStartInfo
+ {
+ FileName = "/bin/chmod",
+ Arguments = $"+x {QuoteProcessArgument(installPath)}",
+ UseShellExecute = false,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ CreateNoWindow = true
+ };
+
+ Process process = ProcessStartHelper.TryStart(startInfo);
+ if (process == null)
+ {
+ return new CliInstallResult(false, "Failed to start chmod process");
+ }
+
+ bool exited = process.WaitForExit(CHMOD_TIMEOUT_MS);
+ if (!exited)
+ {
+ if (!process.HasExited)
+ {
+ process.Kill();
+ }
+
+ process.Dispose();
+ return new CliInstallResult(false, "Making global CLI executable timed out");
+ }
+
+ process.WaitForExit();
+ string errorOutput = process.StandardError.ReadToEnd();
+ bool success = process.ExitCode == 0;
+ process.Dispose();
+
+ return new CliInstallResult(success, errorOutput);
+ }
+
+ private static string QuoteProcessArgument(string value)
+ {
+ UnityEngine.Debug.Assert(value != null, "value must not be null");
+ return $"\"{value.Replace("\"", "\\\"")}\"";
+ }
+
+ private static NativeCliInstallCommand GetLegacyNpmListCommand(RuntimePlatform platform)
+ {
+ string command = $"npm list -g {CliConstants.LEGACY_NPM_PACKAGE_NAME} --depth=0";
+ return BuildShellCommand(platform, command);
+ }
+
+ private static NativeCliInstallCommand GetLegacyNpmUninstallCommand(RuntimePlatform platform)
+ {
+ string command = $"npm uninstall -g {CliConstants.LEGACY_NPM_PACKAGE_NAME}";
+ return BuildShellCommand(platform, command);
+ }
+
+ private static NativeCliInstallCommand BuildShellCommand(RuntimePlatform platform, string command)
+ {
+ UnityEngine.Debug.Assert(!string.IsNullOrEmpty(command), "command must not be null or empty");
+
+ if (platform == RuntimePlatform.WindowsEditor)
+ {
+ return new NativeCliInstallCommand(
+ "cmd.exe",
+ $"/c {command}",
+ command);
+ }
+
+ return new NativeCliInstallCommand(
+ "/bin/sh",
+ $"-c \"{command}\"",
+ command);
+ }
+
+ private static CliInstallResult RunInstallCommand(NativeCliInstallCommand command, RuntimePlatform platform)
+ {
+ ProcessStartInfo startInfo = new ProcessStartInfo
+ {
+ FileName = command.FileName,
+ Arguments = command.Arguments,
+ UseShellExecute = false,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ CreateNoWindow = true
+ };
+
+ ApplyInstallerSearchPath(startInfo, platform);
+
+ Process process = ProcessStartHelper.TryStart(startInfo);
+ if (process == null)
+ {
+ return new CliInstallResult(false, $"Failed to start command: {command.ManualCommand}");
+ }
+
+ StringBuilder errorBuilder = new StringBuilder();
+ process.OutputDataReceived += (sender, e) => { };
+ process.ErrorDataReceived += (sender, e) =>
+ {
+ if (e.Data != null)
+ {
+ errorBuilder.AppendLine(e.Data);
+ }
+ };
+
+ process.BeginOutputReadLine();
+ process.BeginErrorReadLine();
+
+ bool exited = process.WaitForExit(CliConstants.GLOBAL_INSTALL_TIMEOUT_MS);
+ if (!exited)
+ {
+ if (!process.HasExited)
+ {
+ process.Kill();
+ }
+
+ process.Dispose();
+ return new CliInstallResult(false, $"Command timed out: {command.ManualCommand}");
+ }
+
+ process.WaitForExit();
+ string errorOutput = errorBuilder.ToString();
+ bool success = process.ExitCode == 0;
+ process.Dispose();
+ return new CliInstallResult(success, errorOutput);
+ }
+
+ private static void ApplyInstallerSearchPath(ProcessStartInfo startInfo, RuntimePlatform platform)
+ {
+ UnityEngine.Debug.Assert(startInfo != null, "startInfo must not be null");
+
+ if (platform == RuntimePlatform.WindowsEditor)
+ {
+ return;
+ }
+
+ string loginShellPath = NodeEnvironmentResolver.GetLoginShellPathAtPlatform(platform);
+ if (string.IsNullOrEmpty(loginShellPath))
+ {
+ return;
+ }
+
+ startInfo.Environment[CliConstants.POSIX_PATH_ENVIRONMENT_VARIABLE] = loginShellPath;
+ }
+
private static string BuildWindowsRemoveLegacyAssignment(bool removeLegacyNpm)
{
if (!removeLegacyNpm)
diff --git a/Packages/src/Editor/UI/McpEditorWindow.cs b/Packages/src/Editor/UI/McpEditorWindow.cs
index c1a42a8f4..5857a28f1 100644
--- a/Packages/src/Editor/UI/McpEditorWindow.cs
+++ b/Packages/src/Editor/UI/McpEditorWindow.cs
@@ -705,8 +705,7 @@ private async void HandleInstallCli()
{
CliInstallResult result = await NativeCliInstaller.InstallAsync(
Application.platform,
- McpConstants.PackageInfo.version,
- true);
+ McpConstants.PackageInfo.version);
if (!result.Success)
{
diff --git a/Packages/src/Editor/UI/Setup/SetupWizardWindow.cs b/Packages/src/Editor/UI/Setup/SetupWizardWindow.cs
index b5eef741b..cd1ea53f7 100644
--- a/Packages/src/Editor/UI/Setup/SetupWizardWindow.cs
+++ b/Packages/src/Editor/UI/Setup/SetupWizardWindow.cs
@@ -806,8 +806,7 @@ private async void HandleInstallCli()
{
CliInstallResult result = await NativeCliInstaller.InstallAsync(
Application.platform,
- McpConstants.PackageInfo.version,
- true);
+ McpConstants.PackageInfo.version);
if (!result.Success)
{
diff --git a/Packages/src/README.md b/Packages/src/README.md
index 2ac7af7f6..38a72e2b9 100644
--- a/Packages/src/README.md
+++ b/Packages/src/README.md
@@ -79,7 +79,12 @@ Scope(s): io.github.hatayama.uloopmcp
## Step 1: Install the CLI
-Install the global `uloop` launcher from terminal:
+Select Window > Unity CLI Loop > Settings. A dedicated window will open. If the **CLI** button is not highlighted in blue, press **Install CLI**.
+
+
+CLI-only terminal install
+
+Use this only when you want to install the standalone global CLI without opening Unity package setup.
```bash
curl -fsSL https://raw.githubusercontent.com/hatayama/unity-cli-loop/main/scripts/install.sh | sh
@@ -102,13 +107,11 @@ $env:ULOOP_REMOVE_LEGACY = "1"
irm https://raw.githubusercontent.com/hatayama/unity-cli-loop/main/scripts/install.ps1 | iex
```
-The **Install CLI** buttons in Settings and Setup Wizard use the same installer and opt into removing the legacy npm package automatically.
-
-Select Window > Unity CLI Loop > Settings. A dedicated window will open — confirm that the **CLI** button is highlighted in blue.
+
-The Settings window shows whether the global `uloop` command is detected. The distributed command is backed by the `uloop-dispatcher` binary, and the Unity package refreshes this project's `.uloop/bin/uloop-core` CLI bundle automatically when it is missing, version-mismatched, or binary-mismatched.
+The Settings window shows whether the global `uloop` command is detected.
diff --git a/Packages/src/README_ja.md b/Packages/src/README_ja.md
index a93373b31..dc3e01ebb 100644
--- a/Packages/src/README_ja.md
+++ b/Packages/src/README_ja.md
@@ -80,7 +80,12 @@ Scope(s): io.github.hatayama.uloopmcp
## ステップ1: CLIのインストール
-ターミナルからグローバルな `uloop` ランチャーをインストールします。
+Window > Unity CLI Loop > Settingsを選択します。専用ウィンドウが開くので、**CLI** ボタンが青くなっていなければ **Install CLI** を押してください。
+
+
+CLIだけをterminalからinstallする場合はこちら
+
+Unity Package の setup を開かず、standalone の global CLI だけを入れたい場合に使ってください。
```bash
curl -fsSL https://raw.githubusercontent.com/hatayama/unity-cli-loop/main/scripts/install.sh | sh
@@ -103,13 +108,12 @@ $env:ULOOP_REMOVE_LEGACY = "1"
irm https://raw.githubusercontent.com/hatayama/unity-cli-loop/main/scripts/install.ps1 | iex
```
-Settings と Setup Wizard の **Install CLI** ボタンは同じ installer を使い、旧 npm package の削除も自動で有効化します。
+
-Window > Unity CLI Loop > Settingsを選択します。専用ウィンドウが開くので **CLI** ボタンが青くなっている事を確認します。
-Settings ウィンドウでは、グローバルな `uloop` コマンドが検出されているかを確認できます。配布されるコマンドの実体は `uloop-dispatcher` binary で、このプロジェクト用の `.uloop/bin/uloop-core` CLI bundle は、未作成・バージョン不一致・binary 不一致のときに Unity Package が自動更新します。
+Settings ウィンドウでは、グローバルな `uloop` コマンドが検出されているかを確認できます。
diff --git a/README.md b/README.md
index 2ac7af7f6..38a72e2b9 100644
--- a/README.md
+++ b/README.md
@@ -79,7 +79,12 @@ Scope(s): io.github.hatayama.uloopmcp
## Step 1: Install the CLI
-Install the global `uloop` launcher from terminal:
+Select Window > Unity CLI Loop > Settings. A dedicated window will open. If the **CLI** button is not highlighted in blue, press **Install CLI**.
+
+
+CLI-only terminal install
+
+Use this only when you want to install the standalone global CLI without opening Unity package setup.
```bash
curl -fsSL https://raw.githubusercontent.com/hatayama/unity-cli-loop/main/scripts/install.sh | sh
@@ -102,13 +107,11 @@ $env:ULOOP_REMOVE_LEGACY = "1"
irm https://raw.githubusercontent.com/hatayama/unity-cli-loop/main/scripts/install.ps1 | iex
```
-The **Install CLI** buttons in Settings and Setup Wizard use the same installer and opt into removing the legacy npm package automatically.
-
-Select Window > Unity CLI Loop > Settings. A dedicated window will open — confirm that the **CLI** button is highlighted in blue.
+
-The Settings window shows whether the global `uloop` command is detected. The distributed command is backed by the `uloop-dispatcher` binary, and the Unity package refreshes this project's `.uloop/bin/uloop-core` CLI bundle automatically when it is missing, version-mismatched, or binary-mismatched.
+The Settings window shows whether the global `uloop` command is detected.
diff --git a/README_ja.md b/README_ja.md
index 9feb7ec7b..dc3e01ebb 100644
--- a/README_ja.md
+++ b/README_ja.md
@@ -80,7 +80,12 @@ Scope(s): io.github.hatayama.uloopmcp
## ステップ1: CLIのインストール
-ターミナルからグローバルな `uloop` ランチャーをインストールします。
+Window > Unity CLI Loop > Settingsを選択します。専用ウィンドウが開くので、**CLI** ボタンが青くなっていなければ **Install CLI** を押してください。
+
+
+CLIだけをterminalからinstallする場合はこちら
+
+Unity Package の setup を開かず、standalone の global CLI だけを入れたい場合に使ってください。
```bash
curl -fsSL https://raw.githubusercontent.com/hatayama/unity-cli-loop/main/scripts/install.sh | sh
@@ -92,11 +97,23 @@ Windows PowerShell の場合:
irm https://raw.githubusercontent.com/hatayama/unity-cli-loop/main/scripts/install.ps1 | iex
```
-Window > Unity CLI Loop > Settingsを選択します。専用ウィンドウが開くので **CLI** ボタンが青くなっている事を確認します。
+過去に npm 経由で TypeScript 版ランチャーを入れていた場合、installer は `uloop-cli` を検出しますが、デフォルトでは削除しません。インストール時に旧 npm package も削除する場合は、次のように実行してください。
+
+```bash
+curl -fsSL https://raw.githubusercontent.com/hatayama/unity-cli-loop/main/scripts/install.sh | ULOOP_REMOVE_LEGACY=1 sh
+```
+
+```powershell
+$env:ULOOP_REMOVE_LEGACY = "1"
+irm https://raw.githubusercontent.com/hatayama/unity-cli-loop/main/scripts/install.ps1 | iex
+```
+
+
+
-Settings ウィンドウでは、グローバルな `uloop` コマンドが検出されているかを確認できます。配布されるコマンドの実体は `uloop-dispatcher` binary で、このプロジェクト用の `.uloop/bin/uloop-core` CLI bundle は、未作成・バージョン不一致・binary 不一致のときに Unity Package が自動更新します。
+Settings ウィンドウでは、グローバルな `uloop` コマンドが検出されているかを確認できます。