uloop-cli 0.68.2 → 0.69.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cli.ts CHANGED
@@ -12,7 +12,7 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync } from '
12
12
  import { join, basename, dirname } from 'path';
13
13
  import { homedir } from 'os';
14
14
  import { spawn } from 'child_process';
15
- import { Command } from 'commander';
15
+ import { Command, Option } from 'commander';
16
16
  import {
17
17
  executeToolCommand,
18
18
  listAvailableTools,
@@ -24,6 +24,7 @@ import {
24
24
  loadToolsCache,
25
25
  hasCacheFile,
26
26
  getDefaultTools,
27
+ getDefaultToolNames,
27
28
  ToolDefinition,
28
29
  ToolProperty,
29
30
  getCachedServerVersion,
@@ -34,7 +35,8 @@ import { registerLaunchCommand } from './commands/launch.js';
34
35
  import { registerFocusWindowCommand } from './commands/focus-window.js';
35
36
  import { VERSION } from './version.js';
36
37
  import { findUnityProjectRoot } from './project-root.js';
37
- import { validateProjectPath } from './port-resolver.js';
38
+ import { validateProjectPath, UnityNotRunningError } from './port-resolver.js';
39
+ import { ProjectMismatchError } from './project-validator.js';
38
40
  import { filterEnabledTools, isToolEnabled } from './tool-settings-loader.js';
39
41
 
40
42
  interface CliOptions extends GlobalOptions {
@@ -45,6 +47,16 @@ const FOCUS_WINDOW_COMMAND = 'focus-window' as const;
45
47
  const LAUNCH_COMMAND = 'launch' as const;
46
48
  const UPDATE_COMMAND = 'update' as const;
47
49
 
50
+ const HELP_GROUP_BUILTIN_TOOLS = 'Built-in Tools:' as const;
51
+ const HELP_GROUP_THIRD_PARTY_TOOLS = 'Third-party Tools:' as const;
52
+ const HELP_GROUP_CLI_COMMANDS = 'CLI Commands:' as const;
53
+
54
+ const HELP_GROUP_ORDER = [
55
+ HELP_GROUP_CLI_COMMANDS,
56
+ HELP_GROUP_BUILTIN_TOOLS,
57
+ HELP_GROUP_THIRD_PARTY_TOOLS,
58
+ ] as const;
59
+
48
60
  // commander.js built-in flags that exit immediately without needing Unity
49
61
  const NO_SYNC_FLAGS = ['-v', '--version', '-h', '--help'] as const;
50
62
 
@@ -66,7 +78,44 @@ program
66
78
  .description('Unity MCP CLI - Direct communication with Unity Editor')
67
79
  .version(VERSION, '-v, --version', 'Output the version number')
68
80
  .showHelpAfterError('(run with -h for available options)')
69
- .configureHelp({ sortSubcommands: true });
81
+ .configureHelp({
82
+ sortSubcommands: true,
83
+ // commander.js default groupItems determines group display order by registration order,
84
+ // but CLI commands are registered at module level (before tools), so the default order
85
+ // would be wrong. We re-implement to enforce HELP_GROUP_ORDER.
86
+ groupItems<T extends Command | Option>(
87
+ unsortedItems: T[],
88
+ visibleItems: T[],
89
+ getGroup: (item: T) => string,
90
+ ): Map<string, T[]> {
91
+ const groupMap = new Map<string, T[]>();
92
+ for (const item of unsortedItems) {
93
+ const group: string = getGroup(item);
94
+ if (!groupMap.has(group)) {
95
+ groupMap.set(group, []);
96
+ }
97
+ }
98
+ for (const item of visibleItems) {
99
+ const group: string = getGroup(item);
100
+ if (!groupMap.has(group)) {
101
+ groupMap.set(group, []);
102
+ }
103
+ groupMap.get(group)!.push(item);
104
+ }
105
+ const ordered = new Map<string, T[]>();
106
+ for (const key of HELP_GROUP_ORDER) {
107
+ const items: T[] | undefined = groupMap.get(key);
108
+ if (items !== undefined) {
109
+ ordered.set(key, items);
110
+ groupMap.delete(key);
111
+ }
112
+ }
113
+ for (const [key, value] of groupMap) {
114
+ ordered.set(key, value);
115
+ }
116
+ return ordered;
117
+ },
118
+ });
70
119
 
71
120
  // --list-commands: Output command names for shell completion
72
121
  program.option('--list-commands', 'List all command names (for shell completion)');
@@ -74,6 +123,12 @@ program.option('--list-commands', 'List all command names (for shell completion)
74
123
  // --list-options <cmd>: Output options for a specific command (for shell completion)
75
124
  program.option('--list-options <cmd>', 'List options for a command (for shell completion)');
76
125
 
126
+ // Set default help group for CLI commands registered at module level
127
+ program.commandsGroup(HELP_GROUP_CLI_COMMANDS);
128
+ // Eagerly initialize the implicit help command so it inherits the CLI Commands group.
129
+ // Without this, the lazy-created help command skips _initCommandGroup and falls into "Commands:" group.
130
+ program.helpCommand(true);
131
+
77
132
  // Built-in commands (not from tools.json)
78
133
  program
79
134
  .command('list')
@@ -132,12 +187,13 @@ registerLaunchCommand(program);
132
187
  /**
133
188
  * Register a tool as a CLI command dynamically.
134
189
  */
135
- function registerToolCommand(tool: ToolDefinition): void {
190
+ function registerToolCommand(tool: ToolDefinition, helpGroup: string): void {
136
191
  // Skip if already registered as a built-in command
137
192
  if (BUILTIN_COMMANDS.includes(tool.name as (typeof BUILTIN_COMMANDS)[number])) {
138
193
  return;
139
194
  }
140
- const cmd = program.command(tool.name).description(tool.description);
195
+ const firstLine: string = tool.description.split('\n')[0];
196
+ const cmd = program.command(tool.name).description(firstLine).helpGroup(helpGroup);
141
197
 
142
198
  // Add options from inputSchema.properties
143
199
  const properties = tool.inputSchema.properties;
@@ -304,6 +360,10 @@ function convertValue(value: unknown, propInfo: ToolProperty): unknown {
304
360
  return value;
305
361
  }
306
362
 
363
+ function getToolHelpGroup(toolName: string, defaultToolNames: ReadonlySet<string>): string {
364
+ return defaultToolNames.has(toolName) ? HELP_GROUP_BUILTIN_TOOLS : HELP_GROUP_THIRD_PARTY_TOOLS;
365
+ }
366
+
307
367
  function extractGlobalOptions(options: Record<string, unknown>): GlobalOptions {
308
368
  return {
309
369
  port: options['port'] as string | undefined,
@@ -363,6 +423,28 @@ async function runWithErrorHandling(fn: () => Promise<void>): Promise<void> {
363
423
  try {
364
424
  await fn();
365
425
  } catch (error) {
426
+ if (error instanceof UnityNotRunningError) {
427
+ console.error('\x1b[31mError: Unity Editor for this project is not running.\x1b[0m');
428
+ console.error('');
429
+ console.error(` Project: ${error.projectRoot}`);
430
+ console.error('');
431
+ console.error('Start the Unity Editor for this project and try again.');
432
+ process.exit(1);
433
+ }
434
+
435
+ if (error instanceof ProjectMismatchError) {
436
+ console.error('\x1b[31mError: Unity Editor for this project is not running.\x1b[0m');
437
+ console.error('');
438
+ console.error(` Project: ${error.expectedProjectRoot}`);
439
+ console.error(` Connected to: ${error.connectedProjectRoot}`);
440
+ console.error('');
441
+ console.error('Another Unity instance was found, but it belongs to a different project.');
442
+ console.error(
443
+ 'Start the Unity Editor for this project, or use --port to specify the target.',
444
+ );
445
+ process.exit(1);
446
+ }
447
+
366
448
  const message = error instanceof Error ? error.message : String(error);
367
449
 
368
450
  // Unity busy states have clear causes - no version diagnostic needed
@@ -788,6 +870,26 @@ function shouldSkipAutoSync(cmdName: string | undefined, args: string[]): boolea
788
870
  return args.some((arg) => (NO_SYNC_FLAGS as readonly string[]).includes(arg));
789
871
  }
790
872
 
873
+ // Options that consume the next argument as a value
874
+ const OPTIONS_WITH_VALUE: ReadonlySet<string> = new Set(['--port', '-p', '--project-path']);
875
+
876
+ /**
877
+ * Find the first non-option argument that is not a value of a known option.
878
+ */
879
+ function findCommandName(args: readonly string[]): string | undefined {
880
+ for (let i = 0; i < args.length; i++) {
881
+ const arg: string = args[i];
882
+ if (arg.startsWith('-')) {
883
+ if (OPTIONS_WITH_VALUE.has(arg)) {
884
+ i++; // skip the next arg (option value)
885
+ }
886
+ continue;
887
+ }
888
+ return arg;
889
+ }
890
+ return undefined;
891
+ }
892
+
791
893
  function extractSyncGlobalOptions(args: string[]): GlobalOptions {
792
894
  const options: GlobalOptions = {};
793
895
 
@@ -832,8 +934,8 @@ async function main(): Promise<void> {
832
934
  }
833
935
 
834
936
  const args = process.argv.slice(2);
835
- const cmdName = args.find((arg) => !arg.startsWith('-'));
836
937
  const syncGlobalOptions = extractSyncGlobalOptions(args);
938
+ const cmdName = findCommandName(args);
837
939
 
838
940
  // No command name = no Unity operation; skip project detection
839
941
  const NO_PROJECT_COMMANDS = [UPDATE_COMMAND, 'completion'] as const;
@@ -841,20 +943,24 @@ async function main(): Promise<void> {
841
943
  cmdName === undefined || (NO_PROJECT_COMMANDS as readonly string[]).includes(cmdName);
842
944
 
843
945
  if (skipProjectDetection) {
844
- const defaultTools = getDefaultTools();
946
+ const defaultToolNames: ReadonlySet<string> = getDefaultToolNames();
845
947
  // Only filter disabled tools for top-level help (uloop --help); subcommand help
846
948
  // (e.g. uloop completion --help) does not list dynamic tools, so scanning is unnecessary
847
949
  const isTopLevelHelp: boolean =
848
950
  cmdName === undefined && (args.includes('-h') || args.includes('--help'));
849
951
  const shouldFilter: boolean = syncGlobalOptions.projectPath !== undefined || isTopLevelHelp;
952
+ // Use cache to include third-party tools in help output; falls back to defaults when no cache exists
953
+ const sourceTools: ToolDefinition[] = shouldFilter
954
+ ? loadToolsCache(syncGlobalOptions.projectPath).tools
955
+ : getDefaultTools().tools;
850
956
  const tools: ToolDefinition[] = shouldFilter
851
- ? filterEnabledTools(defaultTools.tools, syncGlobalOptions.projectPath)
852
- : defaultTools.tools;
957
+ ? filterEnabledTools(sourceTools, syncGlobalOptions.projectPath)
958
+ : sourceTools;
853
959
  if (!shouldFilter || isToolEnabled(FOCUS_WINDOW_COMMAND, syncGlobalOptions.projectPath)) {
854
- registerFocusWindowCommand(program);
960
+ registerFocusWindowCommand(program, HELP_GROUP_BUILTIN_TOOLS);
855
961
  }
856
962
  for (const tool of tools) {
857
- registerToolCommand(tool);
963
+ registerToolCommand(tool, getToolHelpGroup(tool.name, defaultToolNames));
858
964
  }
859
965
  program.parse();
860
966
  return;
@@ -887,11 +993,12 @@ async function main(): Promise<void> {
887
993
  // Register tool commands from cache (after potential auto-sync)
888
994
  const toolsCache = loadToolsCache();
889
995
  const projectPath: string | undefined = syncGlobalOptions.projectPath;
996
+ const defaultToolNames: ReadonlySet<string> = getDefaultToolNames();
890
997
  if (isToolEnabled(FOCUS_WINDOW_COMMAND, projectPath)) {
891
- registerFocusWindowCommand(program);
998
+ registerFocusWindowCommand(program, HELP_GROUP_BUILTIN_TOOLS);
892
999
  }
893
1000
  for (const tool of filterEnabledTools(toolsCache.tools, projectPath)) {
894
- registerToolCommand(tool);
1001
+ registerToolCommand(tool, getToolHelpGroup(tool.name, defaultToolNames));
895
1002
  }
896
1003
 
897
1004
  if (cmdName && !commandExists(cmdName, projectPath)) {
@@ -906,7 +1013,7 @@ async function main(): Promise<void> {
906
1013
  const newCache = loadToolsCache();
907
1014
  const tool = filterEnabledTools(newCache.tools, projectPath).find((t) => t.name === cmdName);
908
1015
  if (tool) {
909
- registerToolCommand(tool);
1016
+ registerToolCommand(tool, getToolHelpGroup(tool.name, defaultToolNames));
910
1017
  console.log(`\x1b[32m✓ Found '${cmdName}' after sync.\x1b[0m\n`);
911
1018
  } else {
912
1019
  console.error(`\x1b[31mError: Command '${cmdName}' not found even after sync.\x1b[0m`);
@@ -12,66 +12,71 @@ import { findRunningUnityProcess, focusUnityProcess } from 'launch-unity';
12
12
  import { findUnityProjectRoot } from '../project-root.js';
13
13
  import { validateProjectPath } from '../port-resolver.js';
14
14
 
15
- export function registerFocusWindowCommand(program: Command): void {
16
- program
15
+ export function registerFocusWindowCommand(program: Command, helpGroup?: string): void {
16
+ const cmd = program
17
17
  .command('focus-window')
18
18
  .description('Bring Unity Editor window to front using OS-level commands')
19
- .option('--project-path <path>', 'Unity project path')
20
- .action(async (options: { projectPath?: string }) => {
21
- let projectRoot: string | null;
22
- if (options.projectPath !== undefined) {
23
- try {
24
- projectRoot = validateProjectPath(options.projectPath);
25
- } catch (error) {
26
- console.error(
27
- JSON.stringify({
28
- Success: false,
29
- Message: error instanceof Error ? error.message : String(error),
30
- }),
31
- );
32
- process.exit(1);
33
- return;
34
- }
35
- } else {
36
- projectRoot = findUnityProjectRoot();
37
- }
38
- if (projectRoot === null) {
39
- console.error(
40
- JSON.stringify({
41
- Success: false,
42
- Message: 'Unity project not found',
43
- }),
44
- );
45
- process.exit(1);
46
- }
19
+ .option('--project-path <path>', 'Unity project path');
47
20
 
48
- const runningProcess = await findRunningUnityProcess(projectRoot);
49
- if (!runningProcess) {
50
- console.error(
51
- JSON.stringify({
52
- Success: false,
53
- Message: 'No running Unity process found for this project',
54
- }),
55
- );
56
- process.exit(1);
57
- }
21
+ if (helpGroup !== undefined) {
22
+ cmd.helpGroup(helpGroup);
23
+ }
58
24
 
25
+ cmd.action(async (options: { projectPath?: string }) => {
26
+ let projectRoot: string | null;
27
+ if (options.projectPath !== undefined) {
59
28
  try {
60
- await focusUnityProcess(runningProcess.pid);
61
- console.log(
62
- JSON.stringify({
63
- Success: true,
64
- Message: `Unity Editor window focused (PID: ${runningProcess.pid})`,
65
- }),
66
- );
29
+ projectRoot = validateProjectPath(options.projectPath);
67
30
  } catch (error) {
68
31
  console.error(
69
32
  JSON.stringify({
70
33
  Success: false,
71
- Message: `Failed to focus Unity window: ${error instanceof Error ? error.message : String(error)}`,
34
+ Message: error instanceof Error ? error.message : String(error),
72
35
  }),
73
36
  );
74
37
  process.exit(1);
38
+ return;
75
39
  }
76
- });
40
+ } else {
41
+ projectRoot = findUnityProjectRoot();
42
+ }
43
+ if (projectRoot === null) {
44
+ console.error(
45
+ JSON.stringify({
46
+ Success: false,
47
+ Message: 'Unity project not found',
48
+ }),
49
+ );
50
+ process.exit(1);
51
+ }
52
+
53
+ const runningProcess = await findRunningUnityProcess(projectRoot);
54
+ if (!runningProcess) {
55
+ console.error(
56
+ JSON.stringify({
57
+ Success: false,
58
+ Message: 'No running Unity process found for this project',
59
+ }),
60
+ );
61
+ process.exit(1);
62
+ }
63
+
64
+ try {
65
+ await focusUnityProcess(runningProcess.pid);
66
+ console.log(
67
+ JSON.stringify({
68
+ Success: true,
69
+ Message: `Unity Editor window focused (PID: ${runningProcess.pid})`,
70
+ }),
71
+ );
72
+ } catch (error) {
73
+ console.error(
74
+ JSON.stringify({
75
+ Success: false,
76
+ Message: `Failed to focus Unity window: ${error instanceof Error ? error.message : String(error)}`,
77
+ }),
78
+ );
79
+ process.exit(1);
80
+ }
81
+ });
77
82
  }
@@ -17,14 +17,14 @@ import { join } from 'path';
17
17
  // Only alphanumeric, underscore, and hyphen — blocks path separators and traversal sequences
18
18
  const SAFE_REQUEST_ID_PATTERN: RegExp = /^[a-zA-Z0-9_-]+$/;
19
19
 
20
- export const COMPILE_FORCE_RECOMPILE_ARG_KEYS = [
20
+ const COMPILE_FORCE_RECOMPILE_ARG_KEYS = [
21
21
  'ForceRecompile',
22
22
  'forceRecompile',
23
23
  'force_recompile',
24
24
  'force-recompile',
25
25
  ] as const;
26
26
 
27
- export const COMPILE_WAIT_FOR_DOMAIN_RELOAD_ARG_KEYS = [
27
+ const COMPILE_WAIT_FOR_DOMAIN_RELOAD_ARG_KEYS = [
28
28
  'WaitForDomainReload',
29
29
  'waitForDomainReload',
30
30
  'wait_for_domain_reload',
@@ -36,7 +36,7 @@ export interface CompileExecutionOptions {
36
36
  waitForDomainReload: boolean;
37
37
  }
38
38
 
39
- export interface CompileCompletionWaitOptions {
39
+ interface CompileCompletionWaitOptions {
40
40
  projectRoot: string;
41
41
  requestId: string;
42
42
  timeoutMs: number;
@@ -45,9 +45,9 @@ export interface CompileCompletionWaitOptions {
45
45
  isUnityReadyWhenIdle?: () => Promise<boolean>;
46
46
  }
47
47
 
48
- export type CompileCompletionOutcome = 'completed' | 'timed_out';
48
+ type CompileCompletionOutcome = 'completed' | 'timed_out';
49
49
 
50
- export interface CompileCompletionResult<T> {
50
+ interface CompileCompletionResult<T> {
51
51
  outcome: CompileCompletionOutcome;
52
52
  result?: T;
53
53
  }
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.68.2",
2
+ "version": "0.69.0",
3
3
  "tools": [
4
4
  {
5
5
  "name": "compile",
@@ -239,6 +239,34 @@
239
239
  }
240
240
  }
241
241
  },
242
+ {
243
+ "name": "get-unity-search-providers",
244
+ "description": "Get detailed information about Unity Search providers including display names, descriptions, active status, and capabilities",
245
+ "inputSchema": {
246
+ "type": "object",
247
+ "properties": {
248
+ "ProviderId": {
249
+ "type": "string",
250
+ "description": "Specific provider ID to get details for (empty = all providers). Examples: 'asset', 'scene', 'menu', 'settings'"
251
+ },
252
+ "ActiveOnly": {
253
+ "type": "boolean",
254
+ "description": "Whether to include only active providers",
255
+ "default": false
256
+ },
257
+ "SortByPriority": {
258
+ "type": "boolean",
259
+ "description": "Sort providers by priority (lower number = higher priority)",
260
+ "default": true
261
+ },
262
+ "IncludeDescriptions": {
263
+ "type": "boolean",
264
+ "description": "Include detailed descriptions for each provider",
265
+ "default": true
266
+ }
267
+ }
268
+ }
269
+ },
242
270
  {
243
271
  "name": "get-menu-items",
244
272
  "description": "Retrieve Unity MenuItems",
@@ -13,14 +13,14 @@ const JSONRPC_VERSION = '2.0';
13
13
  const DEFAULT_HOST = '127.0.0.1';
14
14
  const NETWORK_TIMEOUT_MS = 180000;
15
15
 
16
- export interface JsonRpcRequest {
16
+ interface JsonRpcRequest {
17
17
  jsonrpc: string;
18
18
  method: string;
19
19
  params?: Record<string, unknown>;
20
20
  id: number;
21
21
  }
22
22
 
23
- export interface JsonRpcResponse {
23
+ interface JsonRpcResponse {
24
24
  jsonrpc: string;
25
25
  result?: unknown;
26
26
  error?: {
@@ -13,6 +13,7 @@ import { join } from 'path';
13
13
  import * as semver from 'semver';
14
14
  import { DirectUnityClient } from './direct-unity-client.js';
15
15
  import { resolveUnityPort, validateProjectPath } from './port-resolver.js';
16
+ import { validateConnectedProject } from './project-validator.js';
16
17
  import { saveToolsCache, getCacheFilePath, ToolsCache, ToolDefinition } from './tool-cache.js';
17
18
  import { VERSION } from './version.js';
18
19
  import { createSpinner } from './spinner.js';
@@ -214,6 +215,9 @@ export async function executeToolCommand(
214
215
  ? validateProjectPath(globalOptions.projectPath)
215
216
  : findUnityProjectRoot();
216
217
 
218
+ // Validate project identity only when port was auto-resolved (not --port) and project root is known
219
+ const shouldValidateProject = portNumber === undefined && projectRoot !== null;
220
+
217
221
  // Monotonically-increasing flag: once true, retries cannot reset it to false.
218
222
  // The retry loop overwrites `lastError` and `immediateResult` on each attempt,
219
223
  // which destroys the evidence of whether an earlier attempt successfully dispatched
@@ -229,6 +233,10 @@ export async function executeToolCommand(
229
233
  try {
230
234
  await client.connect();
231
235
 
236
+ if (shouldValidateProject) {
237
+ await validateConnectedProject(client, projectRoot);
238
+ }
239
+
232
240
  spinner.update(`Executing ${toolName}...`);
233
241
  // connect() succeeded: socket is established. sendRequest() calls socket.write()
234
242
  // synchronously (direct-unity-client.ts:136), so the data reaches the kernel
@@ -383,6 +391,11 @@ export async function listAvailableTools(globalOptions: GlobalOptions): Promise<
383
391
  portNumber = parsed;
384
392
  }
385
393
  const port = await resolveUnityPort(portNumber, globalOptions.projectPath);
394
+ const projectRoot =
395
+ globalOptions.projectPath !== undefined
396
+ ? validateProjectPath(globalOptions.projectPath)
397
+ : findUnityProjectRoot();
398
+ const shouldValidateProject = portNumber === undefined && projectRoot !== null;
386
399
 
387
400
  const restoreStdin = suppressStdinEcho();
388
401
  const spinner = createSpinner('Connecting to Unity...');
@@ -395,6 +408,10 @@ export async function listAvailableTools(globalOptions: GlobalOptions): Promise<
395
408
  try {
396
409
  await client.connect();
397
410
 
411
+ if (shouldValidateProject) {
412
+ await validateConnectedProject(client, projectRoot);
413
+ }
414
+
398
415
  spinner.update('Fetching tool list...');
399
416
  const result = await client.sendRequest<{
400
417
  Tools: Array<{ name: string; description: string }>;
@@ -471,6 +488,11 @@ export async function syncTools(globalOptions: GlobalOptions): Promise<void> {
471
488
  portNumber = parsed;
472
489
  }
473
490
  const port = await resolveUnityPort(portNumber, globalOptions.projectPath);
491
+ const projectRoot =
492
+ globalOptions.projectPath !== undefined
493
+ ? validateProjectPath(globalOptions.projectPath)
494
+ : findUnityProjectRoot();
495
+ const shouldValidateProject = portNumber === undefined && projectRoot !== null;
474
496
 
475
497
  const restoreStdin = suppressStdinEcho();
476
498
  const spinner = createSpinner('Connecting to Unity...');
@@ -483,6 +505,10 @@ export async function syncTools(globalOptions: GlobalOptions): Promise<void> {
483
505
  try {
484
506
  await client.connect();
485
507
 
508
+ if (shouldValidateProject) {
509
+ await validateConnectedProject(client, projectRoot);
510
+ }
511
+
486
512
  spinner.update('Syncing tools...');
487
513
  const result = await client.sendRequest<{
488
514
  Tools: UnityToolInfo[];
@@ -11,7 +11,11 @@ import { existsSync } from 'fs';
11
11
  import { join, resolve } from 'path';
12
12
  import { findUnityProjectRoot, isUnityProject, hasUloopInstalled } from './project-root.js';
13
13
 
14
- const DEFAULT_PORT = 8700;
14
+ export class UnityNotRunningError extends Error {
15
+ constructor(public readonly projectRoot: string) {
16
+ super('UNITY_NOT_RUNNING');
17
+ }
18
+ }
15
19
 
16
20
  interface UnityMcpSettings {
17
21
  isServerRunning?: boolean;
@@ -78,11 +82,7 @@ export async function resolveUnityPort(
78
82
 
79
83
  if (projectPath !== undefined) {
80
84
  const resolved = validateProjectPath(projectPath);
81
- const settingsPort = await readPortFromSettings(resolved);
82
- if (settingsPort !== null) {
83
- return settingsPort;
84
- }
85
- return DEFAULT_PORT;
85
+ return await readPortFromSettingsOrThrow(resolved);
86
86
  }
87
87
 
88
88
  const projectRoot = findUnityProjectRoot();
@@ -92,34 +92,55 @@ export async function resolveUnityPort(
92
92
  );
93
93
  }
94
94
 
95
- const settingsPort = await readPortFromSettings(projectRoot);
96
- if (settingsPort !== null) {
97
- return settingsPort;
98
- }
95
+ return await readPortFromSettingsOrThrow(projectRoot);
96
+ }
99
97
 
100
- return DEFAULT_PORT;
98
+ function createSettingsReadError(projectRoot: string): Error {
99
+ const settingsPath = join(projectRoot, 'UserSettings/UnityMcpSettings.json');
100
+ return new Error(
101
+ `Could not read Unity server port from settings.\n\n` +
102
+ ` Settings file: ${settingsPath}\n\n` +
103
+ `Run 'uloop launch -r' to restart Unity, or use --port to specify the port directly.`,
104
+ );
101
105
  }
102
106
 
103
- async function readPortFromSettings(projectRoot: string): Promise<number | null> {
107
+ // File I/O and JSON parsing can fail for external reasons (permissions, corruption, concurrent writes)
108
+ async function readPortFromSettingsOrThrow(projectRoot: string): Promise<number> {
104
109
  const settingsPath = join(projectRoot, 'UserSettings/UnityMcpSettings.json');
105
110
 
106
111
  if (!existsSync(settingsPath)) {
107
- return null;
112
+ throw createSettingsReadError(projectRoot);
108
113
  }
109
114
 
110
115
  let content: string;
111
116
  try {
112
117
  content = await readFile(settingsPath, 'utf-8');
113
118
  } catch {
114
- return null;
119
+ throw createSettingsReadError(projectRoot);
115
120
  }
116
121
 
117
- let settings: UnityMcpSettings;
122
+ let parsed: unknown;
118
123
  try {
119
- settings = JSON.parse(content) as UnityMcpSettings;
124
+ parsed = JSON.parse(content);
120
125
  } catch {
121
- return null;
126
+ throw createSettingsReadError(projectRoot);
122
127
  }
123
128
 
124
- return resolvePortFromUnitySettings(settings);
129
+ if (typeof parsed !== 'object' || parsed === null) {
130
+ throw createSettingsReadError(projectRoot);
131
+ }
132
+ const settings = parsed as UnityMcpSettings;
133
+
134
+ // Only block when isServerRunning is explicitly false (Unity clean shutdown).
135
+ // undefined/missing means old settings format — proceed to next validation stage.
136
+ if (settings.isServerRunning === false) {
137
+ throw new UnityNotRunningError(projectRoot);
138
+ }
139
+
140
+ const port = resolvePortFromUnitySettings(settings);
141
+ if (port === null) {
142
+ throw createSettingsReadError(projectRoot);
143
+ }
144
+
145
+ return port;
125
146
  }
@@ -143,7 +143,7 @@ export function findUnityProjectRoot(startPath: string = process.cwd()): string
143
143
  return findUnityProjectInParents(startPath);
144
144
  }
145
145
 
146
- export interface UnityProjectStatus {
146
+ interface UnityProjectStatus {
147
147
  found: boolean;
148
148
  path: string | null;
149
149
  hasUloop: boolean;