uloop-cli 0.64.1 → 0.66.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/README.md +3 -0
- package/dist/cli.bundle.cjs +567 -147
- package/dist/cli.bundle.cjs.map +4 -4
- package/package.json +5 -5
- package/src/__tests__/execute-tool.test.ts +31 -0
- package/src/__tests__/port-resolver.test.ts +31 -1
- package/src/cli.ts +54 -8
- package/src/commands/focus-window.ts +20 -2
- package/src/commands/launch.ts +3 -0
- package/src/compile-helpers.ts +291 -0
- package/src/default-tools.json +5 -1
- package/src/direct-unity-client.ts +22 -3
- package/src/execute-tool.ts +184 -19
- package/src/port-resolver.ts +42 -4
- package/src/project-root.ts +2 -2
- package/src/skills/skill-definitions/cli-only/uloop-focus-window/SKILL.md +7 -0
- package/src/version.ts +1 -1
package/src/execute-tool.ts
CHANGED
|
@@ -12,11 +12,18 @@ import { existsSync } from 'fs';
|
|
|
12
12
|
import { join } from 'path';
|
|
13
13
|
import * as semver from 'semver';
|
|
14
14
|
import { DirectUnityClient } from './direct-unity-client.js';
|
|
15
|
-
import { resolveUnityPort } from './port-resolver.js';
|
|
15
|
+
import { resolveUnityPort, validateProjectPath } from './port-resolver.js';
|
|
16
16
|
import { saveToolsCache, getCacheFilePath, ToolsCache, ToolDefinition } from './tool-cache.js';
|
|
17
17
|
import { VERSION } from './version.js';
|
|
18
18
|
import { createSpinner } from './spinner.js';
|
|
19
19
|
import { findUnityProjectRoot } from './project-root.js';
|
|
20
|
+
import {
|
|
21
|
+
type CompileExecutionOptions,
|
|
22
|
+
ensureCompileRequestId,
|
|
23
|
+
resolveCompileExecutionOptions,
|
|
24
|
+
sleep,
|
|
25
|
+
waitForCompileCompletion,
|
|
26
|
+
} from './compile-helpers.js';
|
|
20
27
|
|
|
21
28
|
/**
|
|
22
29
|
* Suppress stdin echo during async operation to prevent escape sequences from being displayed.
|
|
@@ -54,13 +61,32 @@ function suppressStdinEcho(): () => void {
|
|
|
54
61
|
|
|
55
62
|
export interface GlobalOptions {
|
|
56
63
|
port?: string;
|
|
64
|
+
projectPath?: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function stripInternalFields(result: Record<string, unknown>): Record<string, unknown> {
|
|
68
|
+
const cleaned = { ...result };
|
|
69
|
+
delete cleaned['ProjectRoot'];
|
|
70
|
+
return cleaned;
|
|
57
71
|
}
|
|
58
72
|
|
|
59
73
|
const RETRY_DELAY_MS = 500;
|
|
60
74
|
const MAX_RETRIES = 3;
|
|
75
|
+
const COMPILE_WAIT_TIMEOUT_MS = 90000;
|
|
76
|
+
const COMPILE_WAIT_POLL_INTERVAL_MS = 100;
|
|
61
77
|
|
|
62
|
-
function
|
|
63
|
-
|
|
78
|
+
function getCompileExecutionOptions(
|
|
79
|
+
toolName: string,
|
|
80
|
+
params: Record<string, unknown>,
|
|
81
|
+
): CompileExecutionOptions {
|
|
82
|
+
if (toolName !== 'compile') {
|
|
83
|
+
return {
|
|
84
|
+
forceRecompile: false,
|
|
85
|
+
waitForDomainReload: false,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return resolveCompileExecutionOptions(params);
|
|
64
90
|
}
|
|
65
91
|
|
|
66
92
|
function isRetryableError(error: unknown): boolean {
|
|
@@ -75,6 +101,18 @@ function isRetryableError(error: unknown): boolean {
|
|
|
75
101
|
);
|
|
76
102
|
}
|
|
77
103
|
|
|
104
|
+
// Distinct from isRetryableError(): that function covers pre-connection failures
|
|
105
|
+
// (ECONNREFUSED, EADDRNOTAVAIL) which cannot occur after dispatch.
|
|
106
|
+
// This function covers post-dispatch TCP failures where Unity may have received
|
|
107
|
+
// the request but the response was lost — file-based recovery is appropriate.
|
|
108
|
+
export function isTransportDisconnectError(error: unknown): boolean {
|
|
109
|
+
if (!(error instanceof Error)) {
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
const message: string = error.message;
|
|
113
|
+
return message === 'UNITY_NO_RESPONSE' || message.startsWith('Connection lost:');
|
|
114
|
+
}
|
|
115
|
+
|
|
78
116
|
/**
|
|
79
117
|
* Compare two semantic versions safely.
|
|
80
118
|
* Returns true if v1 < v2, false otherwise.
|
|
@@ -125,8 +163,9 @@ function checkServerVersion(result: Record<string, unknown>): void {
|
|
|
125
163
|
* Check if Unity is in a busy state (compiling, reloading, or server starting).
|
|
126
164
|
* Throws an error with appropriate message if busy.
|
|
127
165
|
*/
|
|
128
|
-
function checkUnityBusyState(): void {
|
|
129
|
-
const projectRoot =
|
|
166
|
+
function checkUnityBusyState(projectPath?: string): void {
|
|
167
|
+
const projectRoot =
|
|
168
|
+
projectPath !== undefined ? validateProjectPath(projectPath) : findUnityProjectRoot();
|
|
130
169
|
if (projectRoot === null) {
|
|
131
170
|
return;
|
|
132
171
|
}
|
|
@@ -160,39 +199,82 @@ export async function executeToolCommand(
|
|
|
160
199
|
}
|
|
161
200
|
portNumber = parsed;
|
|
162
201
|
}
|
|
163
|
-
const port = await resolveUnityPort(portNumber);
|
|
202
|
+
const port = await resolveUnityPort(portNumber, globalOptions.projectPath);
|
|
203
|
+
const compileOptions = getCompileExecutionOptions(toolName, params);
|
|
204
|
+
const shouldWaitForDomainReload = compileOptions.waitForDomainReload;
|
|
205
|
+
const compileRequestId = shouldWaitForDomainReload ? ensureCompileRequestId(params) : undefined;
|
|
164
206
|
|
|
165
207
|
const restoreStdin = suppressStdinEcho();
|
|
166
208
|
const spinner = createSpinner('Connecting to Unity...');
|
|
167
209
|
|
|
168
210
|
let lastError: unknown;
|
|
211
|
+
let immediateResult: Record<string, unknown> | undefined;
|
|
212
|
+
const projectRoot =
|
|
213
|
+
globalOptions.projectPath !== undefined
|
|
214
|
+
? validateProjectPath(globalOptions.projectPath)
|
|
215
|
+
: findUnityProjectRoot();
|
|
216
|
+
|
|
217
|
+
// Monotonically-increasing flag: once true, retries cannot reset it to false.
|
|
218
|
+
// The retry loop overwrites `lastError` and `immediateResult` on each attempt,
|
|
219
|
+
// which destroys the evidence of whether an earlier attempt successfully dispatched
|
|
220
|
+
// the request to Unity. This flag preserves that information across retries.
|
|
221
|
+
// See: git log cb3d63e..HEAD for the history of oscillating fixes caused by
|
|
222
|
+
// inferring dispatch status from `immediateResult` alone.
|
|
223
|
+
let requestDispatched = false;
|
|
224
|
+
|
|
169
225
|
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
170
|
-
checkUnityBusyState();
|
|
226
|
+
checkUnityBusyState(globalOptions.projectPath);
|
|
171
227
|
|
|
172
228
|
const client = new DirectUnityClient(port);
|
|
173
229
|
try {
|
|
174
230
|
await client.connect();
|
|
175
231
|
|
|
176
232
|
spinner.update(`Executing ${toolName}...`);
|
|
233
|
+
// connect() succeeded: socket is established. sendRequest() calls socket.write()
|
|
234
|
+
// synchronously (direct-unity-client.ts:136), so the data reaches the kernel
|
|
235
|
+
// send buffer before any async error can occur. Safe to mark as dispatched here.
|
|
236
|
+
requestDispatched = true;
|
|
177
237
|
const result = await client.sendRequest<Record<string, unknown>>(toolName, params);
|
|
178
238
|
|
|
179
239
|
if (result === undefined || result === null) {
|
|
180
240
|
throw new Error('UNITY_NO_RESPONSE');
|
|
181
241
|
}
|
|
182
242
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
243
|
+
immediateResult = result;
|
|
244
|
+
if (!shouldWaitForDomainReload) {
|
|
245
|
+
spinner.stop();
|
|
246
|
+
restoreStdin();
|
|
186
247
|
|
|
187
|
-
|
|
188
|
-
|
|
248
|
+
checkServerVersion(result);
|
|
249
|
+
console.log(JSON.stringify(stripInternalFields(result), null, 2));
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
189
252
|
|
|
190
|
-
|
|
191
|
-
return;
|
|
253
|
+
break;
|
|
192
254
|
} catch (error) {
|
|
193
255
|
lastError = error;
|
|
194
256
|
client.disconnect();
|
|
195
257
|
|
|
258
|
+
// After a compile request has been dispatched, retrying is counterproductive:
|
|
259
|
+
// the next loop iteration calls checkUnityBusyState() OUTSIDE the try block,
|
|
260
|
+
// which throws UNITY_DOMAIN_RELOAD during domain reload and escapes the
|
|
261
|
+
// entire function — bypassing waitForCompileCompletion() recovery.
|
|
262
|
+
if (requestDispatched && shouldWaitForDomainReload) {
|
|
263
|
+
if (isTransportDisconnectError(error)) {
|
|
264
|
+
// Unity may have received the request before the TCP drop.
|
|
265
|
+
// Break out of retry loop → proceed to file-based recovery below.
|
|
266
|
+
spinner.update('Connection lost during compile. Waiting for result file...');
|
|
267
|
+
break;
|
|
268
|
+
}
|
|
269
|
+
// JSON-RPC error (e.g. "Unity error: ..."): Unity processed the request
|
|
270
|
+
// and returned an explicit error. No result file will be written
|
|
271
|
+
// (confirmed: CompileUseCase.ExecuteAsync() is not reached when
|
|
272
|
+
// JSON-RPC error occurs at parameter validation / security check).
|
|
273
|
+
spinner.stop();
|
|
274
|
+
restoreStdin();
|
|
275
|
+
throw error instanceof Error ? error : new Error(String(error));
|
|
276
|
+
}
|
|
277
|
+
|
|
196
278
|
if (!isRetryableError(error) || attempt >= MAX_RETRIES) {
|
|
197
279
|
break;
|
|
198
280
|
}
|
|
@@ -203,9 +285,92 @@ export async function executeToolCommand(
|
|
|
203
285
|
}
|
|
204
286
|
}
|
|
205
287
|
|
|
288
|
+
if (shouldWaitForDomainReload && compileRequestId) {
|
|
289
|
+
// Fail fast when the compile request never reached Unity.
|
|
290
|
+
// Without this guard, unreachable Unity would cause a 90-second wait for a
|
|
291
|
+
// result file that will never be created.
|
|
292
|
+
// We check both conditions because:
|
|
293
|
+
// - immediateResult === undefined: no JSON-RPC response was received
|
|
294
|
+
// - !requestDispatched: no attempt ever successfully connected and called sendRequest()
|
|
295
|
+
// If requestDispatched is true but immediateResult is undefined, the request was sent
|
|
296
|
+
// but the TCP connection dropped before the response arrived (domain reload scenario).
|
|
297
|
+
// In that case, Unity may have already written the result file, so we proceed to
|
|
298
|
+
// file-based polling recovery.
|
|
299
|
+
if (immediateResult === undefined && !requestDispatched) {
|
|
300
|
+
spinner.stop();
|
|
301
|
+
restoreStdin();
|
|
302
|
+
if (lastError instanceof Error) {
|
|
303
|
+
throw lastError;
|
|
304
|
+
}
|
|
305
|
+
throw new Error(
|
|
306
|
+
'Compile request never reached Unity. Check that Unity is running and retry.',
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const projectRootFromUnity: string | undefined =
|
|
311
|
+
immediateResult !== undefined
|
|
312
|
+
? (immediateResult['ProjectRoot'] as string | undefined)
|
|
313
|
+
: undefined;
|
|
314
|
+
const effectiveProjectRoot: string | null = projectRootFromUnity ?? projectRoot;
|
|
315
|
+
|
|
316
|
+
// File-based polling requires a known project root
|
|
317
|
+
if (effectiveProjectRoot === null) {
|
|
318
|
+
spinner.stop();
|
|
319
|
+
restoreStdin();
|
|
320
|
+
if (immediateResult !== undefined) {
|
|
321
|
+
checkServerVersion(immediateResult);
|
|
322
|
+
console.log(JSON.stringify(stripInternalFields(immediateResult), null, 2));
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
if (lastError instanceof Error) {
|
|
326
|
+
throw lastError;
|
|
327
|
+
}
|
|
328
|
+
throw new Error(
|
|
329
|
+
'Compile request failed and project root is unknown. Check connection and retry.',
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
spinner.update('Waiting for domain reload to complete...');
|
|
334
|
+
const { outcome, result: storedResult } = await waitForCompileCompletion<
|
|
335
|
+
Record<string, unknown>
|
|
336
|
+
>({
|
|
337
|
+
projectRoot: effectiveProjectRoot,
|
|
338
|
+
requestId: compileRequestId,
|
|
339
|
+
timeoutMs: COMPILE_WAIT_TIMEOUT_MS,
|
|
340
|
+
pollIntervalMs: COMPILE_WAIT_POLL_INTERVAL_MS,
|
|
341
|
+
unityPort: port,
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
if (outcome === 'timed_out') {
|
|
345
|
+
lastError = new Error(
|
|
346
|
+
`Compile wait timed out after ${COMPILE_WAIT_TIMEOUT_MS}ms. Run 'uloop fix' and retry.`,
|
|
347
|
+
);
|
|
348
|
+
} else {
|
|
349
|
+
const finalResult = storedResult ?? immediateResult;
|
|
350
|
+
if (finalResult !== undefined) {
|
|
351
|
+
spinner.stop();
|
|
352
|
+
restoreStdin();
|
|
353
|
+
checkServerVersion(finalResult);
|
|
354
|
+
console.log(JSON.stringify(stripInternalFields(finalResult), null, 2));
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
206
360
|
spinner.stop();
|
|
207
361
|
restoreStdin();
|
|
208
|
-
|
|
362
|
+
if (lastError === undefined) {
|
|
363
|
+
throw new Error('Tool execution failed without error details.');
|
|
364
|
+
}
|
|
365
|
+
if (lastError instanceof Error) {
|
|
366
|
+
throw lastError;
|
|
367
|
+
}
|
|
368
|
+
if (typeof lastError === 'string') {
|
|
369
|
+
throw new Error(lastError);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const serializedError = JSON.stringify(lastError);
|
|
373
|
+
throw new Error(serializedError ?? 'Unknown error');
|
|
209
374
|
}
|
|
210
375
|
|
|
211
376
|
export async function listAvailableTools(globalOptions: GlobalOptions): Promise<void> {
|
|
@@ -217,14 +382,14 @@ export async function listAvailableTools(globalOptions: GlobalOptions): Promise<
|
|
|
217
382
|
}
|
|
218
383
|
portNumber = parsed;
|
|
219
384
|
}
|
|
220
|
-
const port = await resolveUnityPort(portNumber);
|
|
385
|
+
const port = await resolveUnityPort(portNumber, globalOptions.projectPath);
|
|
221
386
|
|
|
222
387
|
const restoreStdin = suppressStdinEcho();
|
|
223
388
|
const spinner = createSpinner('Connecting to Unity...');
|
|
224
389
|
|
|
225
390
|
let lastError: unknown;
|
|
226
391
|
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
227
|
-
checkUnityBusyState();
|
|
392
|
+
checkUnityBusyState(globalOptions.projectPath);
|
|
228
393
|
|
|
229
394
|
const client = new DirectUnityClient(port);
|
|
230
395
|
try {
|
|
@@ -305,14 +470,14 @@ export async function syncTools(globalOptions: GlobalOptions): Promise<void> {
|
|
|
305
470
|
}
|
|
306
471
|
portNumber = parsed;
|
|
307
472
|
}
|
|
308
|
-
const port = await resolveUnityPort(portNumber);
|
|
473
|
+
const port = await resolveUnityPort(portNumber, globalOptions.projectPath);
|
|
309
474
|
|
|
310
475
|
const restoreStdin = suppressStdinEcho();
|
|
311
476
|
const spinner = createSpinner('Connecting to Unity...');
|
|
312
477
|
|
|
313
478
|
let lastError: unknown;
|
|
314
479
|
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
315
|
-
checkUnityBusyState();
|
|
480
|
+
checkUnityBusyState(globalOptions.projectPath);
|
|
316
481
|
|
|
317
482
|
const client = new DirectUnityClient(port);
|
|
318
483
|
try {
|
package/src/port-resolver.ts
CHANGED
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
|
|
9
9
|
import { readFile } from 'fs/promises';
|
|
10
10
|
import { existsSync } from 'fs';
|
|
11
|
-
import { join } from 'path';
|
|
12
|
-
import { findUnityProjectRoot } from './project-root.js';
|
|
11
|
+
import { join, resolve } from 'path';
|
|
12
|
+
import { findUnityProjectRoot, isUnityProject, hasUloopInstalled } from './project-root.js';
|
|
13
13
|
|
|
14
14
|
const DEFAULT_PORT = 8700;
|
|
15
15
|
|
|
@@ -54,14 +54,52 @@ export function resolvePortFromUnitySettings(settings: UnityMcpSettings): number
|
|
|
54
54
|
return null;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
export
|
|
57
|
+
export function validateProjectPath(projectPath: string): string {
|
|
58
|
+
const resolved = resolve(projectPath);
|
|
59
|
+
|
|
60
|
+
if (!existsSync(resolved)) {
|
|
61
|
+
throw new Error(`Path does not exist: ${resolved}`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (!isUnityProject(resolved)) {
|
|
65
|
+
throw new Error(`Not a Unity project (Assets/ or ProjectSettings/ not found): ${resolved}`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (!hasUloopInstalled(resolved)) {
|
|
69
|
+
throw new Error(
|
|
70
|
+
`uLoopMCP is not installed in this project (UserSettings/UnityMcpSettings.json not found): ${resolved}`,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return resolved;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function resolveUnityPort(
|
|
78
|
+
explicitPort?: number,
|
|
79
|
+
projectPath?: string,
|
|
80
|
+
): Promise<number> {
|
|
81
|
+
if (explicitPort !== undefined && projectPath !== undefined) {
|
|
82
|
+
throw new Error('Cannot specify both --port and --project-path. Use one or the other.');
|
|
83
|
+
}
|
|
84
|
+
|
|
58
85
|
if (explicitPort !== undefined) {
|
|
59
86
|
return explicitPort;
|
|
60
87
|
}
|
|
61
88
|
|
|
89
|
+
if (projectPath !== undefined) {
|
|
90
|
+
const resolved = validateProjectPath(projectPath);
|
|
91
|
+
const settingsPort = await readPortFromSettings(resolved);
|
|
92
|
+
if (settingsPort !== null) {
|
|
93
|
+
return settingsPort;
|
|
94
|
+
}
|
|
95
|
+
return DEFAULT_PORT;
|
|
96
|
+
}
|
|
97
|
+
|
|
62
98
|
const projectRoot = findUnityProjectRoot();
|
|
63
99
|
if (projectRoot === null) {
|
|
64
|
-
throw new Error(
|
|
100
|
+
throw new Error(
|
|
101
|
+
'Unity project not found. Use --port or --project-path option to specify the target.',
|
|
102
|
+
);
|
|
65
103
|
}
|
|
66
104
|
|
|
67
105
|
const settingsPort = await readPortFromSettings(projectRoot);
|
package/src/project-root.ts
CHANGED
|
@@ -22,13 +22,13 @@ const EXCLUDED_DIRS = new Set([
|
|
|
22
22
|
'Library',
|
|
23
23
|
]);
|
|
24
24
|
|
|
25
|
-
function isUnityProject(dirPath: string): boolean {
|
|
25
|
+
export function isUnityProject(dirPath: string): boolean {
|
|
26
26
|
const hasAssets = existsSync(join(dirPath, 'Assets'));
|
|
27
27
|
const hasProjectSettings = existsSync(join(dirPath, 'ProjectSettings'));
|
|
28
28
|
return hasAssets && hasProjectSettings;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
function hasUloopInstalled(dirPath: string): boolean {
|
|
31
|
+
export function hasUloopInstalled(dirPath: string): boolean {
|
|
32
32
|
return existsSync(join(dirPath, 'UserSettings/UnityMcpSettings.json'));
|
|
33
33
|
}
|
|
34
34
|
|
|
@@ -17,6 +17,13 @@ uloop focus-window
|
|
|
17
17
|
|
|
18
18
|
None.
|
|
19
19
|
|
|
20
|
+
## Global Options
|
|
21
|
+
|
|
22
|
+
| Option | Description |
|
|
23
|
+
|--------|-------------|
|
|
24
|
+
| `--project-path <path>` | Target a specific Unity project (mutually exclusive with `--port`). Path resolution follows the same rules as `cd` — absolute paths are used as-is, relative paths are resolved from cwd. |
|
|
25
|
+
| `-p, --port <port>` | Specify Unity TCP port directly (mutually exclusive with `--project-path`). |
|
|
26
|
+
|
|
20
27
|
## Examples
|
|
21
28
|
|
|
22
29
|
```bash
|
package/src/version.ts
CHANGED
|
@@ -4,4 +4,4 @@
|
|
|
4
4
|
* This file exists to avoid bundling the entire package.json into the CLI bundle.
|
|
5
5
|
* This version is automatically updated by release-please.
|
|
6
6
|
*/
|
|
7
|
-
export const VERSION = '0.
|
|
7
|
+
export const VERSION = '0.66.0'; // x-release-please-version
|