Files
ft/Client/Packages/io.github.hatayama.uloopmcp/Editor/UI/McpEditorWindow.cs

1056 lines
38 KiB
C#

using UnityEngine;
using UnityEditor;
using System.Linq;
using System.Collections.Generic;
using System;
using System.Threading;
using System.Threading.Tasks;
namespace io.github.hatayama.uLoopMCP
{
public class McpEditorWindow : EditorWindow
{
private const bool ForceFlatSkillInstall = true;
private const double DeferredInitialRefreshDelaySeconds = 0.05;
private const double ToolSettingsRegistryWarmupInitialDelaySeconds = 0.05;
private const double ToolSettingsRegistryWarmupMaxDelaySeconds = 0.8;
private const int ToolSettingsRegistryWarmupMaxAttempts = 5;
private McpConfigServiceFactory _configServiceFactory;
private McpEditorWindowUI _view;
private McpEditorModel _model;
private McpEditorWindowEventHandler _eventHandler;
private SkillsTarget _skillsTarget = SkillsTarget.Claude;
private bool _installSkillsFlat;
private bool _isInstallingCli;
private bool _isInstallingSkills;
private bool _isRefreshingVersion;
private bool _isToolSettingsCatalogDirty = true;
private bool _isDeferredInitialRefreshScheduled;
private bool _hasCompletedDeferredInitialRefresh;
private double _deferredInitialRefreshDueTime;
private bool _isToolSettingsRegistryWarmupScheduled;
private double _toolSettingsRegistryWarmupDueTime;
private int _toolSettingsRegistryWarmupAttemptCount;
private SkillInstallState _selectedTargetInstallState = SkillInstallState.Missing;
private CancellationTokenSource _skillInstallStateRefreshCts;
[MenuItem("Window/Unity CLI Loop/Settings", priority = 0)]
public static void ShowWindow()
{
McpEditorWindow window = GetWindow<McpEditorWindow>("Unity CLI Loop");
window.Show();
}
private void OnEnable()
{
InitializeAll();
}
private void OnDestroy()
{
CancelDeferredInitialRefresh();
CancelToolSettingsRegistryWarmup();
ResetToolSettingsRegistryWarmupAttemptCount();
CancelSkillInstallStateRefresh();
_view?.Dispose();
_view = null;
}
private void CreateGUI()
{
InitializeView();
RefreshAllSections(refreshMode: McpEditorWindowRefreshMode.InitialPaint);
ScheduleDeferredInitialRefresh();
}
private void InitializeAll()
{
InitializeModel();
InitializeConfigurationServices();
InitializeEventHandler();
LoadSavedSettings();
RestoreSessionState();
HandlePostCompileMode();
}
private void InitializeModel()
{
_model = new McpEditorModel();
}
private void InitializeView()
{
_view = new McpEditorWindowUI(rootVisualElement);
SetupViewCallbacks();
}
private void SetupViewCallbacks()
{
_view.OnConnectionModeChanged += UpdateConnectionMode;
_view.OnRefreshCliVersion += HandleRefreshCliVersion;
_view.OnInstallCli += HandleInstallCli;
_view.OnInstallSkills += HandleInstallSkills;
_view.OnRefreshSkillsState += HandleRefreshSkillsState;
_view.OnSkillsTargetChanged += value =>
{
_skillsTarget = value;
RefreshSelectedTargetInstallStateFast();
RefreshSelectedTargetInstallStateInBackground();
};
_view.OnGroupSkillsChanged += HandleGroupSkillsChanged;
_view.OnUseProjectCliVersionChanged += HandleUseProjectCliVersionChanged;
_view.OnConfigurationFoldoutChanged += UpdateShowConfiguration;
_view.OnConnectedToolsFoldoutChanged += UpdateShowConnectedTools;
_view.OnEditorTypeChanged += UpdateSelectedEditorType;
_view.OnRepositoryRootChanged += UpdateAddRepositoryRoot;
_view.OnConfigureClicked += ConfigureEditor;
_view.OnDeleteConfigClicked += DeleteEditorConfiguration;
_view.OnOpenSettingsClicked += OpenConfigurationFile;
_view.OnToolSettingsFoldoutChanged += UpdateShowToolSettings;
_view.OnToolToggled += HandleToolToggled;
_view.OnAllowThirdPartyChanged += UpdateAllowThirdPartyTools;
_view.OnSecurityLevelChanged += UpdateDynamicCodeSecurityLevel;
}
public IEnumerable<ConnectedClient> GetConnectedToolsAsClients()
{
return ConnectedToolsMonitoringService.GetConnectedToolsAsClients();
}
private void InitializeConfigurationServices()
{
_configServiceFactory = new McpConfigServiceFactory();
}
private void InitializeEventHandler()
{
_eventHandler = new McpEditorWindowEventHandler(_model, this);
_eventHandler.Initialize();
}
private void LoadSavedSettings()
{
_model.LoadFromSettings();
_installSkillsFlat = ForceFlatSkillInstall;
}
private void RestoreSessionState()
{
_model.LoadFromSessionState();
}
private async void HandlePostCompileMode()
{
_model.EnablePostCompileMode();
McpEditorSettings.SetShowReconnectingUI(false);
Task recoveryTask = McpServerController.RecoveryTask;
if (recoveryTask != null && !recoveryTask.IsCompleted)
{
await recoveryTask;
}
bool isAfterCompile = McpEditorSettings.GetIsAfterCompile();
if (isAfterCompile)
{
McpEditorSettings.ClearAfterCompileFlag();
int savedPort = McpEditorSettings.GetCustomPort();
bool portNeedsUpdate = savedPort != _model.UI.CustomPort;
if (portNeedsUpdate)
{
_model.UpdateCustomPort(savedPort);
}
return;
}
// McpServerController.[InitializeOnLoad] handles automatic server recovery via RestoreServerStateIfNeeded()
}
private void OnDisable()
{
CancelDeferredInitialRefresh();
CancelToolSettingsRegistryWarmup();
ResetToolSettingsRegistryWarmupAttemptCount();
CancelSkillInstallStateRefresh();
CleanupEventHandler();
SaveSessionState();
_view?.Dispose();
_view = null;
}
private void CleanupEventHandler()
{
_eventHandler?.Cleanup();
}
private void SaveSessionState()
{
_model.SaveToSessionState();
}
private void ScheduleDeferredInitialRefresh()
{
if (_isDeferredInitialRefreshScheduled)
{
return;
}
_isDeferredInitialRefreshScheduled = true;
_deferredInitialRefreshDueTime = EditorApplication.timeSinceStartup + DeferredInitialRefreshDelaySeconds;
EditorApplication.update += RunDeferredInitialRefreshWhenDue;
}
private void RunDeferredInitialRefreshWhenDue()
{
if (EditorApplication.timeSinceStartup < _deferredInitialRefreshDueTime)
{
return;
}
CancelDeferredInitialRefresh();
if (_view == null)
{
return;
}
_hasCompletedDeferredInitialRefresh = true;
_selectedTargetInstallState = SkillInstallState.Checking;
RefreshRepositoryRootSupport();
RefreshAllSections(
refreshSkillInstallState: false,
refreshMode: McpEditorWindowRefreshMode.Full);
RefreshSelectedTargetInstallStateInBackground();
}
private void CancelDeferredInitialRefresh()
{
if (!_isDeferredInitialRefreshScheduled)
{
return;
}
EditorApplication.update -= RunDeferredInitialRefreshWhenDue;
_isDeferredInitialRefreshScheduled = false;
}
private void OnFocus()
{
if (!_hasCompletedDeferredInitialRefresh)
{
RefreshAllSections(refreshMode: McpEditorWindowRefreshMode.InitialPaint);
}
ScheduleDeferredInitialRefresh();
}
internal void RefreshAllSections(
bool refreshSkillInstallState = false,
McpEditorWindowRefreshMode refreshMode = McpEditorWindowRefreshMode.Full)
{
if (_view == null)
{
return;
}
bool runExpensiveChecks = McpEditorWindowRefreshPolicy.ShouldRunExpensiveChecks(refreshMode);
ConnectionModeData modeData = new ConnectionModeData(_model.UI.ConnectionMode);
_view.UpdateConnectionMode(modeData);
_view.UpdateConfigurationFoldout(_model.UI.ShowConfiguration);
_view.UpdateSectionVisibility(_model.UI.ConnectionMode);
if (McpEditorWindowRefreshPolicy.ShouldRefreshSkillInstallState(refreshMode, refreshSkillInstallState))
{
RefreshSelectedTargetInstallStateFast();
}
if (runExpensiveChecks)
{
RefreshCliVersionInBackground();
if (refreshSkillInstallState)
{
RefreshSelectedTargetInstallStateInBackground();
}
}
RefreshCliSetupSection(runExpensiveChecks);
ConnectedToolsData toolsData = CreateConnectedToolsData();
_view.UpdateConnectedTools(toolsData);
EditorConfigData configData = runExpensiveChecks
? CreateEditorConfigData()
: CreateEditorConfigPlaceholderData();
_view.UpdateEditorConfig(configData);
RefreshToolSettingsHeader();
if (runExpensiveChecks)
{
RefreshToolSettingsCatalogIfNeeded();
}
}
private async void RefreshCliVersionInBackground()
{
if (CliInstallationDetector.IsCheckCompleted())
{
return;
}
await CliInstallationDetector.RefreshCliVersionAsync(CancellationToken.None);
RefreshCliSetupSection();
RefreshSelectedTargetInstallStateInBackground();
}
private async void HandleRefreshCliVersion()
{
if (_isRefreshingVersion)
{
return;
}
_isRefreshingVersion = true;
RefreshCliSetupSection();
try
{
Task forceRefresh = CliInstallationDetector.ForceRefreshCliVersionAsync(CancellationToken.None);
Task minimumDelay = Task.Delay(500);
await Task.WhenAll(forceRefresh, minimumDelay);
}
finally
{
_isRefreshingVersion = false;
RefreshCliSetupSection();
RefreshSelectedTargetInstallStateInBackground();
}
}
public void RefreshConnectedToolsSection()
{
if (_view == null)
{
return;
}
ConnectedToolsData toolsData = CreateConnectedToolsData();
_view.UpdateConnectedTools(toolsData);
}
private ConnectedToolsData CreateConnectedToolsData()
{
bool isServerRunning = McpServerController.IsServerRunning;
ConnectedClient[] connectedClients = GetConnectedToolsAsClients().ToArray();
bool showReconnectingUIFlag = McpEditorSettings.GetShowReconnectingUI();
bool showPostCompileUIFlag = McpEditorSettings.GetShowPostCompileReconnectingUI();
bool hasNamedClients = connectedClients.Any();
bool showReconnectingUI = (showReconnectingUIFlag || showPostCompileUIFlag) && !hasNamedClients;
if (hasNamedClients && showPostCompileUIFlag)
{
McpEditorSettings.ClearPostCompileReconnectingUI();
}
bool showSection = isServerRunning && hasNamedClients;
return new ConnectedToolsData(connectedClients, _model.UI.ShowConnectedTools, isServerRunning, showReconnectingUI, showSection);
}
private EditorConfigData CreateEditorConfigData()
{
bool isServerRunning = McpServerController.IsServerRunning;
int currentPort = McpServerController.ServerPort;
bool isConfigured = false;
bool hasPortMismatch = false;
bool isUpdateNeeded = true;
string configurationError = null;
IMcpConfigService configService = GetConfigService(_model.UI.SelectedEditorType);
isConfigured = configService.IsConfigured();
if (isConfigured)
{
int configuredPort = configService.GetConfiguredPort();
if (isServerRunning)
{
hasPortMismatch = currentPort != configuredPort;
}
else
{
hasPortMismatch = McpEditorSettings.GetCustomPort() != configuredPort;
}
}
int portToCheck = isServerRunning ? currentPort : McpEditorSettings.GetCustomPort();
isUpdateNeeded = configService.IsUpdateNeeded(portToCheck);
return new EditorConfigData(
_model.UI.SelectedEditorType,
isServerRunning,
currentPort,
isConfigured,
hasPortMismatch,
configurationError,
isUpdateNeeded,
_model.UI.AddRepositoryRoot,
_model.UI.SupportsRepositoryRootToggle,
_model.UI.ShowRepositoryRootToggle);
}
private EditorConfigData CreateEditorConfigPlaceholderData()
{
return new EditorConfigData(
_model.UI.SelectedEditorType,
McpServerController.IsServerRunning,
McpServerController.ServerPort,
isConfigured: false,
hasPortMismatch: false,
configurationError: null,
isUpdateNeeded: true,
addRepositoryRoot: _model.UI.AddRepositoryRoot,
supportsRepositoryRootToggle: _model.UI.SupportsRepositoryRootToggle,
showRepositoryRootToggle: _model.UI.ShowRepositoryRootToggle,
isChecking: true);
}
public void InvalidateToolSettingsCatalog()
{
_isToolSettingsCatalogDirty = true;
}
private void RefreshToolSettingsHeader()
{
ToolSettingsSectionData toolSettingsData = CreateToolSettingsHeaderData();
_view.UpdateToolSettings(toolSettingsData);
}
private void RefreshToolSettingsCatalog()
{
ToolSettingsSectionData toolSettingsData = CreateToolSettingsData();
_view.UpdateToolSettings(toolSettingsData);
if (McpEditorWindowRefreshPolicy.ShouldKeepToolSettingsCatalogDirty(toolSettingsData))
{
if (ScheduleToolSettingsRegistryWarmup())
{
_isToolSettingsCatalogDirty = true;
return;
}
_isToolSettingsCatalogDirty = false;
return;
}
CancelToolSettingsRegistryWarmup();
ResetToolSettingsRegistryWarmupAttemptCount();
_isToolSettingsCatalogDirty = false;
}
private void RefreshToolSettingsCatalogIfNeeded()
{
if (!_model.UI.ShowToolSettings || !_isToolSettingsCatalogDirty)
{
return;
}
if (_view == null)
{
return;
}
RefreshToolSettingsCatalog();
}
private ToolSettingsSectionData CreateToolSettingsHeaderData()
{
return new ToolSettingsSectionData(
_model.UI.ShowToolSettings,
ULoopSettings.GetAllowThirdPartyTools(),
ULoopSettings.GetDynamicCodeSecurityLevel(),
System.Array.Empty<ToolToggleItem>(),
System.Array.Empty<ToolToggleItem>(),
true,
false);
}
private ToolSettingsSectionData CreateToolSettingsData()
{
UnityToolRegistry registry = CustomToolManager.TryGetRegistry();
if (registry == null)
{
return new ToolSettingsSectionData(
_model.UI.ShowToolSettings,
ULoopSettings.GetAllowThirdPartyTools(),
ULoopSettings.GetDynamicCodeSecurityLevel(),
System.Array.Empty<ToolToggleItem>(),
System.Array.Empty<ToolToggleItem>(),
false,
true);
}
ToolSettingsCatalogItem[] allTools = registry.GetToolSettingsCatalog();
System.Collections.Generic.List<ToolToggleItem> builtIn = new();
System.Collections.Generic.List<ToolToggleItem> thirdParty = new();
foreach (ToolSettingsCatalogItem tool in allTools)
{
if (tool.DisplayDevelopmentOnly)
{
continue;
}
bool isEnabled = ToolSettings.IsToolEnabled(tool.Name);
bool isThirdPartyTool = tool.IsThirdParty;
ToolToggleItem item = new ToolToggleItem(tool.Name, tool.Description, isEnabled, isThirdPartyTool);
if (isThirdPartyTool)
{
thirdParty.Add(item);
}
else
{
builtIn.Add(item);
}
}
Comparison<ToolToggleItem> compareByName = (a, b) => string.Compare(a.ToolName, b.ToolName, StringComparison.Ordinal);
builtIn.Sort(compareByName);
thirdParty.Sort(compareByName);
return new ToolSettingsSectionData(
_model.UI.ShowToolSettings,
ULoopSettings.GetAllowThirdPartyTools(),
ULoopSettings.GetDynamicCodeSecurityLevel(),
builtIn.ToArray(),
thirdParty.ToArray(),
true,
true);
}
private void UpdateShowToolSettings(bool show)
{
_model.UpdateShowToolSettings(show);
RefreshToolSettingsHeader();
if (!show)
{
_isToolSettingsCatalogDirty = true;
CancelToolSettingsRegistryWarmup();
ResetToolSettingsRegistryWarmupAttemptCount();
return;
}
RefreshToolSettingsCatalogIfNeeded();
}
private bool ScheduleToolSettingsRegistryWarmup()
{
if (McpEditorWindowRefreshPolicy.ShouldStartToolSettingsRegistryWarmup(
_isToolSettingsRegistryWarmupScheduled,
_toolSettingsRegistryWarmupAttemptCount,
ToolSettingsRegistryWarmupMaxAttempts))
{
double delaySeconds = McpEditorWindowRefreshPolicy.CalculateToolSettingsRegistryWarmupDelaySeconds(
ToolSettingsRegistryWarmupInitialDelaySeconds,
ToolSettingsRegistryWarmupMaxDelaySeconds,
_toolSettingsRegistryWarmupAttemptCount);
_isToolSettingsRegistryWarmupScheduled = true;
_toolSettingsRegistryWarmupDueTime = EditorApplication.timeSinceStartup + delaySeconds;
_toolSettingsRegistryWarmupAttemptCount++;
EditorApplication.update += RunToolSettingsRegistryWarmupWhenDue;
return true;
}
return _isToolSettingsRegistryWarmupScheduled;
}
private void RunToolSettingsRegistryWarmupWhenDue()
{
if (EditorApplication.timeSinceStartup < _toolSettingsRegistryWarmupDueTime)
{
return;
}
CancelToolSettingsRegistryWarmup();
if (_view == null || !_model.UI.ShowToolSettings)
{
ResetToolSettingsRegistryWarmupAttemptCount();
return;
}
CustomToolManager.WarmupRegistry();
InvalidateToolSettingsCatalog();
RefreshToolSettingsCatalogIfNeeded();
}
private void CancelToolSettingsRegistryWarmup()
{
if (!_isToolSettingsRegistryWarmupScheduled)
{
return;
}
EditorApplication.update -= RunToolSettingsRegistryWarmupWhenDue;
_isToolSettingsRegistryWarmupScheduled = false;
}
private void ResetToolSettingsRegistryWarmupAttemptCount()
{
_toolSettingsRegistryWarmupAttemptCount = 0;
}
private void HandleToolToggled(string toolName, bool enabled)
{
_model.UpdateToolEnabled(toolName, enabled);
_view?.UpdateSingleToolToggle(toolName, enabled);
// Skill synchronization can touch many files, so defer it to keep UI input responsive.
EditorApplication.delayCall += () => ApplyToolToggleSideEffects(toolName, enabled);
}
private async void ApplyToolToggleSideEffects(string toolName, bool enabled)
{
ClientNotificationService.TriggerToolChangeNotification();
if (!enabled)
{
ToolSkillSynchronizer.RemoveSkillFiles(toolName);
}
else
{
await ToolSkillSynchronizer.InstallSkillFilesForTool(toolName, !_installSkillsFlat);
if (!ToolSkillSynchronizer.IsSkillInstalled(toolName))
{
Debug.LogWarning(
$"[uLoopMCP] Skill for '{toolName}' was not installed after enabling. " +
"The skill source may have an incorrect directory structure " +
"(expected: <ToolDir>/Skill/SKILL.md). Run 'uloop skills list' for details."
);
}
}
}
private void ConfigureEditor()
{
IMcpConfigService configService = GetConfigService(_model.UI.SelectedEditorType);
bool isServerRunning = McpServerController.IsServerRunning;
int portToUse = isServerRunning ? McpServerController.ServerPort : McpEditorSettings.GetCustomPort();
configService.AutoConfigure(portToUse);
RefreshAllSections();
}
private void DeleteEditorConfiguration()
{
string editorName = GetEditorDisplayName(_model.UI.SelectedEditorType);
bool confirmed = EditorUtility.DisplayDialog(
"Delete MCP Configuration",
$"Are you sure you want to delete the {editorName} MCP configuration?\n\n" +
"This will remove the uLoopMCP entry from the configuration file. " +
"Other MCP server configurations will not be affected.",
"Delete",
"Cancel");
if (!confirmed)
{
return;
}
IMcpConfigService configService = GetConfigService(_model.UI.SelectedEditorType);
configService.DeleteConfiguration();
RefreshAllSections();
}
private void OpenConfigurationFile()
{
string projectRoot = UnityMcpPathResolver.GetProjectRoot();
string gitRoot = UnityMcpPathResolver.GetGitRepositoryRoot();
string baseRoot = _model.UI.AddRepositoryRoot
? (gitRoot ?? projectRoot)
: projectRoot;
string configPath = UnityMcpPathResolver.GetConfigPathForRoot(_model.UI.SelectedEditorType, baseRoot);
bool exists = System.IO.File.Exists(configPath);
if (exists)
{
EditorUtility.OpenWithDefaultApp(configPath);
}
else
{
string editorName = GetEditorDisplayName(_model.UI.SelectedEditorType);
EditorUtility.DisplayDialog(
"Configuration File Not Found",
$"Configuration file for {editorName} not found at:\n{configPath}\n\nPlease run 'Configure {editorName}' first to create the configuration file.",
"OK");
}
}
private string GetEditorDisplayName(McpEditorType editorType)
{
return editorType switch
{
McpEditorType.Cursor => "Cursor",
McpEditorType.ClaudeCode => "Claude Code",
McpEditorType.VSCode => "VSCode",
McpEditorType.GeminiCLI => "Gemini CLI",
McpEditorType.Codex => "Codex",
McpEditorType.McpInspector => "MCP Inspector",
_ => editorType.ToString()
};
}
private IMcpConfigService GetConfigService(McpEditorType editorType)
{
return _configServiceFactory.GetConfigService(editorType);
}
private void UpdateShowConnectedTools(bool show)
{
_model.UpdateShowConnectedTools(show);
}
private void UpdateSelectedEditorType(McpEditorType type)
{
_model.UpdateSelectedEditorType(type);
RefreshAllSections();
}
private void UpdateShowConfiguration(bool show)
{
_model.UpdateShowConfiguration(show);
}
private void UpdateAllowThirdPartyTools(bool allow)
{
_model.UpdateAllowThirdPartyTools(allow);
RefreshToolSettingsHeader();
}
private void UpdateAddRepositoryRoot(bool addRepositoryRoot)
{
_model.UpdateAddRepositoryRoot(addRepositoryRoot);
RefreshAllSections();
}
private void UpdateDynamicCodeSecurityLevel(DynamicCodeSecurityLevel level)
{
ULoopSettings.SetDynamicCodeSecurityLevel(level);
}
private void UpdateConnectionMode(ConnectionMode mode)
{
_model.UpdateConnectionMode(mode);
_view.UpdateConnectionMode(new ConnectionModeData(mode));
_view.UpdateSectionVisibility(mode);
RefreshCliSetupSection();
}
private void RefreshCliSetupSection(bool includeSkillDirectoryChecks = true)
{
if (_view == null)
{
return;
}
CliSetupData cliData = CreateCliSetupData(includeSkillDirectoryChecks);
_view.UpdateCliSetup(cliData);
}
private CliSetupData CreateCliSetupData(bool includeSkillDirectoryChecks = true)
{
string cliVersion = CliInstallationDetector.GetCachedCliVersion();
bool isCliInstalled = cliVersion != null;
bool isChecking = !CliInstallationDetector.IsCheckCompleted()
|| _isRefreshingVersion
|| !includeSkillDirectoryChecks;
string packageVersion = McpConstants.PackageInfo.version;
bool needsUpdate = false;
bool needsDowngrade = false;
if (isCliInstalled)
{
bool comparisonAvailable = CliVersionComparison.TryCompare(
cliVersion,
packageVersion,
out int cliVersionComparison);
needsUpdate = comparisonAvailable && cliVersionComparison < 0;
needsDowngrade = comparisonAvailable && cliVersionComparison > 0;
}
bool groupSkillsUnderUnityCliLoop = !_installSkillsFlat;
bool useProjectCliVersion =
ToolSettings.GetSkillCliInvocation() == CliConstants.SKILL_CLI_INVOCATION_NPX;
SkillInstallState selectedTargetInstallState = includeSkillDirectoryChecks
? _selectedTargetInstallState
: SkillInstallState.Checking;
return new CliSetupData(
isCliInstalled,
cliVersion,
packageVersion,
needsUpdate,
needsDowngrade,
_isInstallingCli,
isChecking,
isClaudeSkillsInstalled: false,
isAgentsSkillsInstalled: false,
isCursorSkillsInstalled: false,
isGeminiSkillsInstalled: false,
isCodexSkillsInstalled: false,
isAntigravitySkillsInstalled: false,
selectedTargetInstallState,
_skillsTarget,
groupSkillsUnderUnityCliLoop,
useProjectCliVersion,
_isInstallingSkills);
}
private void RefreshSelectedTargetInstallStateFast()
{
if (!CanManageSkills())
{
_selectedTargetInstallState = SkillInstallState.Missing;
RefreshCliSetupSection();
return;
}
_selectedTargetInstallState = GetSelectedTargetInstallState(includeFreshnessCheck: false);
RefreshCliSetupSection();
}
private void RefreshSelectedTargetInstallStateInBackground()
{
CancelSkillInstallStateRefresh();
if (!CanManageSkills() || _isRefreshingVersion || _isInstallingSkills)
{
return;
}
CancellationTokenSource cts = new();
_skillInstallStateRefreshCts = cts;
RefreshSelectedTargetInstallStateAsync(cts.Token);
}
private async void RefreshSelectedTargetInstallStateAsync(CancellationToken ct)
{
string projectRoot = UnityMcpPathResolver.GetProjectRoot();
SkillInstallState installState = await Task.Run(
() => GetSelectedTargetInstallState(projectRoot, includeFreshnessCheck: true));
if (ct.IsCancellationRequested)
{
return;
}
_selectedTargetInstallState = installState;
RefreshCliSetupSection();
}
private SkillInstallState GetSelectedTargetInstallState(bool includeFreshnessCheck)
{
string projectRoot = UnityMcpPathResolver.GetProjectRoot();
return GetSelectedTargetInstallState(projectRoot, includeFreshnessCheck);
}
private SkillInstallState GetSelectedTargetInstallState(
string projectRoot,
bool includeFreshnessCheck)
{
SkillsTargetSelection selection = SkillsTargetSelectionResolver.Resolve(
_skillsTarget,
!_installSkillsFlat);
List<ToolSkillSynchronizer.SkillTargetInfo> targets = includeFreshnessCheck
? ToolSkillSynchronizer.DetectTargetsForLayoutAtProjectRoot(projectRoot, !_installSkillsFlat)
: ToolSkillSynchronizer.DetectTargetsForLayoutFastAtProjectRoot(projectRoot, !_installSkillsFlat);
ToolSkillSynchronizer.SkillTargetInfo targetInfo = targets
.FirstOrDefault(target => target.DirName == selection.DirectoryName);
return string.IsNullOrEmpty(targetInfo.DirName)
? SkillInstallState.Missing
: targetInfo.InstallState;
}
private void CancelSkillInstallStateRefresh()
{
if (_skillInstallStateRefreshCts == null)
{
return;
}
_skillInstallStateRefreshCts.Cancel();
_skillInstallStateRefreshCts.Dispose();
_skillInstallStateRefreshCts = null;
}
private async void HandleInstallCli()
{
bool wasCliInstalledBeforeInstall = CliInstallationDetector.IsCliInstalled();
string npmPath = NodeEnvironmentResolver.FindNpmPath();
if (string.IsNullOrEmpty(npmPath))
{
EditorUtility.DisplayDialog(
"npm Not Found",
"npm was not found on this system.\nPlease install Node.js first, then try again.",
"OK");
return;
}
string packageVersion = McpConstants.PackageInfo.version;
string installTarget = $"{CliConstants.NPM_PACKAGE_NAME}@{packageVersion}";
bool permissionOk = CliInstaller.CheckWindowsPermissions(
npmPath, installTarget, out string globalPrefix, out string manualCommand);
if (!permissionOk)
{
EditorUtility.DisplayDialog(
"Permission Issue Detected",
$"npm's global directory ({globalPrefix}) requires elevated permissions.\n\n"
+ NpmInstallDiagnostics.BuildPermissionSolutions(manualCommand),
"OK");
return;
}
_isInstallingCli = true;
RefreshCliSetupSection();
try
{
string nodePath = NodeEnvironmentResolver.FindNodePath();
CliInstallResult result = await CliInstaller.InstallAsync(npmPath, installTarget, nodePath);
if (!result.Success)
{
string installCommand = $"npm install -g {installTarget}";
// Classifier emits Windows-specific remediation with the command embedded;
// on other platforms (or unrecognized errors) show raw stderr + manual command footer
string guidance = Application.platform == RuntimePlatform.WindowsEditor
? NpmInstallDiagnostics.ClassifyInstallError(result.ErrorOutput, installCommand)
: null;
string message;
if (guidance != null && !string.IsNullOrEmpty(result.ErrorOutput))
{
message = "Failed to install uLoop CLI.\n\n"
+ NpmInstallDiagnostics.BuildInstallErrorMessage(guidance, result.ErrorOutput);
}
else
{
message = $"Failed to install uLoop CLI.\n\n{result.ErrorOutput}\n\nYou can try manually:\n{installCommand}";
}
EditorUtility.DisplayDialog("Installation Failed", message, "OK");
}
}
finally
{
_isInstallingCli = false;
RefreshAllSections(
refreshSkillInstallState:
CliInstallRefreshPolicy.ShouldRefreshSkillsAfterCliInstall(wasCliInstalledBeforeInstall));
}
}
private async void HandleInstallSkills()
{
if (!CanManageSkills())
{
EditorUtility.DisplayDialog(
"CLI Not Found",
"uloop-cli is not installed. Please install the CLI first or enable project CLI version.",
"OK");
return;
}
CancelSkillInstallStateRefresh();
_isInstallingSkills = true;
RefreshCliSetupSection();
try
{
SkillsTargetSelection selection = SkillsTargetSelectionResolver.Resolve(
_skillsTarget,
!_installSkillsFlat);
ToolSkillSynchronizer.SkillTargetInfo target = new(
selection.DisplayName,
selection.DirectoryName,
selection.InstallFlag,
hasSkillsDirectory: true,
hasExistingSkills: false);
await ToolSkillSynchronizer.InstallSkillFiles(
new List<ToolSkillSynchronizer.SkillTargetInfo> { target },
!_installSkillsFlat);
EditorDialogHelper.ShowSkillsInstalledDialog();
}
finally
{
_isInstallingSkills = false;
RefreshSelectedTargetInstallStateFast();
RefreshSelectedTargetInstallStateInBackground();
RefreshCliSetupSection();
}
}
private void HandleGroupSkillsChanged(bool groupSkillsUnderUnityCliLoop)
{
ApplyFlatSkillInstallPreference();
RefreshSelectedTargetInstallStateFast();
RefreshSelectedTargetInstallStateInBackground();
}
private void HandleUseProjectCliVersionChanged(bool useProjectCliVersion)
{
string invocation = useProjectCliVersion
? CliConstants.SKILL_CLI_INVOCATION_NPX
: CliConstants.SKILL_CLI_INVOCATION_GLOBAL;
ToolSettings.SetSkillCliInvocation(invocation);
RefreshSelectedTargetInstallStateFast();
RefreshSelectedTargetInstallStateInBackground();
}
private static bool CanManageSkills()
{
return CliInstallationDetector.IsCliInstalled()
|| ToolSettings.GetSkillCliInvocation() == CliConstants.SKILL_CLI_INVOCATION_NPX;
}
private void RefreshRepositoryRootSupport()
{
ApplyFlatSkillInstallPreference();
bool gitRootDiffers = UnityMcpPathResolver.GitRootDiffersFromProjectRoot();
_model.UpdateSupportsRepositoryRootToggle(gitRootDiffers);
_model.UpdateShowRepositoryRootToggle(gitRootDiffers);
if (!gitRootDiffers && _model.UI.AddRepositoryRoot)
{
_model.UpdateAddRepositoryRoot(false);
}
}
private void ApplyFlatSkillInstallPreference()
{
// Claude Code does not resolve nested skill folders, so editor-driven installs stay flat for every target.
_installSkillsFlat = ForceFlatSkillInstall;
McpEditorSettings.SetInstallSkillsFlat(_installSkillsFlat);
}
private void HandleRefreshSkillsState()
{
RefreshSelectedTargetInstallStateFast();
RefreshSelectedTargetInstallStateInBackground();
}
}
}