uloop-cli 0.68.3 → 0.69.1

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/knip.json ADDED
@@ -0,0 +1,4 @@
1
+ {
2
+ "$schema": "https://unpkg.com/knip@5/schema.json",
3
+ "project": ["src/**/*.ts"]
4
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uloop-cli",
3
- "version": "0.68.3",
3
+ "version": "0.69.1",
4
4
  "//version": "x-release-please-version",
5
5
  "description": "CLI tool for Unity Editor communication via uLoopMCP",
6
6
  "main": "dist/cli.bundle.cjs",
@@ -14,6 +14,7 @@
14
14
  "lint:fix": "eslint src --fix",
15
15
  "format": "prettier --write src/**/*.ts",
16
16
  "format:check": "prettier --check src/**/*.ts",
17
+ "knip": "knip",
17
18
  "test:cli": "jest src/__tests__ --testTimeout=60000 --runInBand"
18
19
  },
19
20
  "keywords": [
@@ -48,7 +49,7 @@
48
49
  "devDependencies": {
49
50
  "@eslint/js": "10.0.1",
50
51
  "@types/jest": "30.0.0",
51
- "@types/node": "25.3.2",
52
+ "@types/node": "25.3.3",
52
53
  "@types/semver": "7.7.1",
53
54
  "esbuild": "0.27.3",
54
55
  "eslint": "10.0.2",
@@ -56,9 +57,9 @@
56
57
  "eslint-plugin-prettier": "5.5.5",
57
58
  "eslint-plugin-security": "4.0.0",
58
59
  "jest": "30.2.0",
60
+ "knip": "5.85.0",
59
61
  "prettier": "3.8.1",
60
62
  "ts-jest": "29.4.6",
61
- "tsx": "4.21.0",
62
63
  "typescript": "5.9.3",
63
64
  "typescript-eslint": "8.56.1"
64
65
  },
@@ -1,4 +1,6 @@
1
1
  import { isTransportDisconnectError } from '../execute-tool.js';
2
+ import { UnityNotRunningError } from '../port-resolver.js';
3
+ import { ProjectMismatchError } from '../project-validator.js';
2
4
 
3
5
  describe('isTransportDisconnectError', () => {
4
6
  it('returns true for UNITY_NO_RESPONSE', () => {
@@ -28,4 +30,12 @@ describe('isTransportDisconnectError', () => {
28
30
  expect(isTransportDisconnectError(null)).toBe(false);
29
31
  expect(isTransportDisconnectError(undefined)).toBe(false);
30
32
  });
33
+
34
+ it('returns false for UnityNotRunningError', () => {
35
+ expect(isTransportDisconnectError(new UnityNotRunningError('/project'))).toBe(false);
36
+ });
37
+
38
+ it('returns false for ProjectMismatchError', () => {
39
+ expect(isTransportDisconnectError(new ProjectMismatchError('/a', '/b'))).toBe(false);
40
+ });
31
41
  });
@@ -1,8 +1,11 @@
1
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'fs';
2
+ import { join } from 'path';
1
3
  import { tmpdir } from 'os';
2
4
  import {
3
5
  resolvePortFromUnitySettings,
4
6
  validateProjectPath,
5
7
  resolveUnityPort,
8
+ UnityNotRunningError,
6
9
  } from '../port-resolver.js';
7
10
 
8
11
  describe('resolvePortFromUnitySettings', () => {
@@ -75,3 +78,68 @@ describe('resolveUnityPort', () => {
75
78
  expect(port).toBe(8711);
76
79
  });
77
80
  });
81
+
82
+ describe('resolveUnityPort with project settings', () => {
83
+ let tempProjectRoot: string;
84
+
85
+ beforeEach(() => {
86
+ tempProjectRoot = mkdtempSync(join(tmpdir(), 'unity-port-test-'));
87
+ mkdirSync(join(tempProjectRoot, 'Assets'));
88
+ mkdirSync(join(tempProjectRoot, 'ProjectSettings'));
89
+ mkdirSync(join(tempProjectRoot, 'UserSettings'));
90
+ });
91
+
92
+ afterEach(() => {
93
+ rmSync(tempProjectRoot, { recursive: true });
94
+ });
95
+
96
+ it('throws UnityNotRunningError when isServerRunning is false', async () => {
97
+ writeFileSync(
98
+ join(tempProjectRoot, 'UserSettings/UnityMcpSettings.json'),
99
+ JSON.stringify({ isServerRunning: false, customPort: 8700 }),
100
+ );
101
+
102
+ await expect(resolveUnityPort(undefined, tempProjectRoot)).rejects.toThrow(
103
+ UnityNotRunningError,
104
+ );
105
+ });
106
+
107
+ it('returns port when isServerRunning is true', async () => {
108
+ writeFileSync(
109
+ join(tempProjectRoot, 'UserSettings/UnityMcpSettings.json'),
110
+ JSON.stringify({ isServerRunning: true, customPort: 8711 }),
111
+ );
112
+
113
+ const port = await resolveUnityPort(undefined, tempProjectRoot);
114
+ expect(port).toBe(8711);
115
+ });
116
+
117
+ it('returns port when isServerRunning is undefined (old settings format)', async () => {
118
+ writeFileSync(
119
+ join(tempProjectRoot, 'UserSettings/UnityMcpSettings.json'),
120
+ JSON.stringify({ customPort: 8711 }),
121
+ );
122
+
123
+ const port = await resolveUnityPort(undefined, tempProjectRoot);
124
+ expect(port).toBe(8711);
125
+ });
126
+
127
+ it('throws when settings file has no valid port', async () => {
128
+ writeFileSync(
129
+ join(tempProjectRoot, 'UserSettings/UnityMcpSettings.json'),
130
+ JSON.stringify({ isServerRunning: true }),
131
+ );
132
+
133
+ await expect(resolveUnityPort(undefined, tempProjectRoot)).rejects.toThrow(
134
+ 'Could not read Unity server port from settings',
135
+ );
136
+ });
137
+
138
+ it('throws when settings file contains invalid JSON', async () => {
139
+ writeFileSync(join(tempProjectRoot, 'UserSettings/UnityMcpSettings.json'), 'not valid json{{{');
140
+
141
+ await expect(resolveUnityPort(undefined, tempProjectRoot)).rejects.toThrow(
142
+ 'Could not read Unity server port from settings',
143
+ );
144
+ });
145
+ });
@@ -0,0 +1,121 @@
1
+ import { mkdtempSync, mkdirSync, rmSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { tmpdir } from 'os';
4
+ import { ProjectMismatchError, validateConnectedProject } from '../project-validator.js';
5
+ import { isTransportDisconnectError } from '../execute-tool.js';
6
+ import type { DirectUnityClient } from '../direct-unity-client.js';
7
+
8
+ function createMockClient(response?: unknown, error?: Error): DirectUnityClient {
9
+ return {
10
+ isConnected: () => true,
11
+ sendRequest: jest.fn().mockImplementation(() => {
12
+ if (error) {
13
+ return Promise.reject(error);
14
+ }
15
+ return Promise.resolve(response);
16
+ }),
17
+ connect: jest.fn(),
18
+ disconnect: jest.fn(),
19
+ } as unknown as DirectUnityClient;
20
+ }
21
+
22
+ describe('validateConnectedProject', () => {
23
+ let tempDirA: string;
24
+ let tempDirB: string;
25
+
26
+ beforeEach(() => {
27
+ tempDirA = mkdtempSync(join(tmpdir(), 'project-a-'));
28
+ tempDirB = mkdtempSync(join(tmpdir(), 'project-b-'));
29
+ mkdirSync(join(tempDirA, 'Assets'));
30
+ mkdirSync(join(tempDirB, 'Assets'));
31
+ });
32
+
33
+ afterEach(() => {
34
+ jest.restoreAllMocks();
35
+ rmSync(tempDirA, { recursive: true });
36
+ rmSync(tempDirB, { recursive: true });
37
+ });
38
+
39
+ it('throws ProjectMismatchError when connected project differs from expected', async () => {
40
+ const client = createMockClient({ DataPath: join(tempDirB, 'Assets') });
41
+
42
+ await expect(validateConnectedProject(client, tempDirA)).rejects.toThrow(ProjectMismatchError);
43
+ });
44
+
45
+ it('does not throw when connected project matches expected', async () => {
46
+ const client = createMockClient({ DataPath: join(tempDirA, 'Assets') });
47
+
48
+ await expect(validateConnectedProject(client, tempDirA)).resolves.toBeUndefined();
49
+ });
50
+
51
+ it('normalizes paths with trailing separators', async () => {
52
+ const client = createMockClient({ DataPath: join(tempDirA, 'Assets') });
53
+
54
+ await expect(validateConnectedProject(client, tempDirA + '/')).resolves.toBeUndefined();
55
+ });
56
+
57
+ it('logs warning and continues when get-version returns Method not found', async () => {
58
+ const client = createMockClient(undefined, new Error('Unity error: Method not found (-32601)'));
59
+ const stderrSpy = jest.spyOn(console, 'error').mockImplementation();
60
+
61
+ await expect(validateConnectedProject(client, tempDirA)).resolves.toBeUndefined();
62
+
63
+ expect(stderrSpy).toHaveBeenCalledWith(
64
+ expect.stringContaining('Could not verify project identity'),
65
+ );
66
+ });
67
+
68
+ it('logs warning and continues when get-version returns Unknown tool error', async () => {
69
+ const client = createMockClient(
70
+ undefined,
71
+ new Error('Unity error: Internal error (Unknown tool: get-version)'),
72
+ );
73
+ const stderrSpy = jest.spyOn(console, 'error').mockImplementation();
74
+
75
+ await expect(validateConnectedProject(client, tempDirA)).resolves.toBeUndefined();
76
+
77
+ expect(stderrSpy).toHaveBeenCalledWith(
78
+ expect.stringContaining('Could not verify project identity'),
79
+ );
80
+ });
81
+
82
+ it('re-throws non-Method-not-found errors', async () => {
83
+ const client = createMockClient(undefined, new Error('Unity error: some other error'));
84
+
85
+ await expect(validateConnectedProject(client, tempDirA)).rejects.toThrow(
86
+ 'Unity error: some other error',
87
+ );
88
+ });
89
+
90
+ it('logs warning and continues when DataPath is missing from response', async () => {
91
+ const client = createMockClient({});
92
+ const stderrSpy = jest.spyOn(console, 'error').mockImplementation();
93
+
94
+ await expect(validateConnectedProject(client, tempDirA)).resolves.toBeUndefined();
95
+
96
+ expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining('invalid get-version response'));
97
+ });
98
+
99
+ it('logs warning and continues when DataPath is empty string', async () => {
100
+ const client = createMockClient({ DataPath: '' });
101
+ const stderrSpy = jest.spyOn(console, 'error').mockImplementation();
102
+
103
+ await expect(validateConnectedProject(client, tempDirA)).resolves.toBeUndefined();
104
+
105
+ expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining('invalid get-version response'));
106
+ });
107
+ });
108
+
109
+ describe('ProjectMismatchError', () => {
110
+ it('is not a transport disconnect error', () => {
111
+ const error = new ProjectMismatchError('/project/a', '/project/b');
112
+ expect(isTransportDisconnectError(error)).toBe(false);
113
+ });
114
+
115
+ it('stores expected and connected project roots', () => {
116
+ const error = new ProjectMismatchError('/expected/path', '/connected/path');
117
+ expect(error.expectedProjectRoot).toBe('/expected/path');
118
+ expect(error.connectedProjectRoot).toBe('/connected/path');
119
+ expect(error.message).toBe('PROJECT_MISMATCH');
120
+ });
121
+ });
package/src/arg-parser.ts CHANGED
@@ -3,31 +3,6 @@
3
3
  * Converts CLI options to Unity tool parameters.
4
4
  */
5
5
 
6
- // Object keys come from tool schema definitions which are internal trusted data
7
- /* eslint-disable security/detect-object-injection */
8
-
9
- export interface ToolParameter {
10
- Type: string;
11
- Description: string;
12
- DefaultValue?: unknown;
13
- Enum?: string[];
14
- }
15
-
16
- export interface ToolSchema {
17
- properties: Record<string, ToolParameter>;
18
- }
19
-
20
- /**
21
- * Converts kebab-case CLI option name to PascalCase parameter name.
22
- * e.g., "force-recompile" -> "ForceRecompile"
23
- */
24
- export function kebabToPascalCase(kebab: string): string {
25
- return kebab
26
- .split('-')
27
- .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
28
- .join('');
29
- }
30
-
31
6
  /**
32
7
  * Converts PascalCase parameter name to kebab-case CLI option name.
33
8
  * e.g., "ForceRecompile" -> "force-recompile"
@@ -36,96 +11,3 @@ export function pascalToKebabCase(pascal: string): string {
36
11
  const kebab = pascal.replace(/([A-Z])/g, '-$1').toLowerCase();
37
12
  return kebab.startsWith('-') ? kebab.slice(1) : kebab;
38
13
  }
39
-
40
- /**
41
- * Parses CLI arguments into tool parameters based on the tool schema.
42
- */
43
- export function parseToolArgs(
44
- args: string[],
45
- schema: ToolSchema,
46
- cliOptions: Record<string, unknown>,
47
- ): Record<string, unknown> {
48
- const params: Record<string, unknown> = {};
49
-
50
- for (const [paramName, paramInfo] of Object.entries(schema.properties)) {
51
- const kebabName = pascalToKebabCase(paramName);
52
- const cliValue = cliOptions[kebabName];
53
-
54
- if (cliValue === undefined) {
55
- if (paramInfo.DefaultValue !== undefined) {
56
- params[paramName] = paramInfo.DefaultValue;
57
- }
58
- continue;
59
- }
60
-
61
- params[paramName] = convertValue(cliValue, paramInfo.Type);
62
- }
63
-
64
- return params;
65
- }
66
-
67
- function convertValue(value: unknown, type: string): unknown {
68
- if (value === undefined || value === null) {
69
- return value;
70
- }
71
-
72
- const lowerType = type.toLowerCase();
73
-
74
- switch (lowerType) {
75
- case 'boolean':
76
- if (typeof value === 'boolean') {
77
- return value;
78
- }
79
- if (typeof value === 'string') {
80
- return value.toLowerCase() === 'true';
81
- }
82
- return Boolean(value);
83
-
84
- case 'number':
85
- case 'integer':
86
- if (typeof value === 'number') {
87
- return value;
88
- }
89
- if (typeof value === 'string') {
90
- const parsed = parseFloat(value);
91
- return isNaN(parsed) ? 0 : parsed;
92
- }
93
- return Number(value);
94
-
95
- case 'string':
96
- if (typeof value === 'string') {
97
- return value;
98
- }
99
- if (typeof value === 'number' || typeof value === 'boolean') {
100
- return String(value);
101
- }
102
- return JSON.stringify(value);
103
-
104
- case 'array':
105
- if (Array.isArray(value)) {
106
- return value;
107
- }
108
- if (typeof value === 'string') {
109
- return value.split(',').map((s) => s.trim());
110
- }
111
- return [value];
112
-
113
- default:
114
- return value;
115
- }
116
- }
117
-
118
- /**
119
- * Generates commander.js option string from parameter info.
120
- * e.g., "--force-recompile" for boolean, "--max-count <value>" for others
121
- */
122
- export function generateOptionString(paramName: string, paramInfo: ToolParameter): string {
123
- const kebabName = pascalToKebabCase(paramName);
124
- const lowerType = paramInfo.Type.toLowerCase();
125
-
126
- if (lowerType === 'boolean') {
127
- return `--${kebabName}`;
128
- }
129
-
130
- return `--${kebabName} <value>`;
131
- }
package/src/cli.ts CHANGED
@@ -35,7 +35,8 @@ import { registerLaunchCommand } from './commands/launch.js';
35
35
  import { registerFocusWindowCommand } from './commands/focus-window.js';
36
36
  import { VERSION } from './version.js';
37
37
  import { findUnityProjectRoot } from './project-root.js';
38
- import { validateProjectPath } from './port-resolver.js';
38
+ import { validateProjectPath, UnityNotRunningError } from './port-resolver.js';
39
+ import { ProjectMismatchError } from './project-validator.js';
39
40
  import { filterEnabledTools, isToolEnabled } from './tool-settings-loader.js';
40
41
 
41
42
  interface CliOptions extends GlobalOptions {
@@ -422,6 +423,28 @@ async function runWithErrorHandling(fn: () => Promise<void>): Promise<void> {
422
423
  try {
423
424
  await fn();
424
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
+
425
448
  const message = error instanceof Error ? error.message : String(error);
426
449
 
427
450
  // Unity busy states have clear causes - no version diagnostic needed
@@ -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.3",
2
+ "version": "0.69.1",
3
3
  "tools": [
4
4
  {
5
5
  "name": "compile",
@@ -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?: {
@@ -113,7 +113,12 @@ export class DirectUnityClient {
113
113
  const response = JSON.parse(extractResult.jsonContent) as JsonRpcResponse;
114
114
 
115
115
  if (response.error) {
116
- reject(new Error(`Unity error: ${response.error.message}`));
116
+ const data = response.error.data;
117
+ const dataMessage =
118
+ data !== null && data !== undefined && typeof data === 'object' && 'message' in data
119
+ ? ` (${(data as { message: string }).message})`
120
+ : '';
121
+ reject(new Error(`Unity error: ${response.error.message}${dataMessage}`));
117
122
  return;
118
123
  }
119
124
 
@@ -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;