uloop-cli 0.44.2 → 0.45.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uloop-cli",
3
- "version": "0.44.2",
3
+ "version": "0.45.0",
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",
package/src/arg-parser.ts CHANGED
@@ -3,6 +3,9 @@
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
+
6
9
  export interface ToolParameter {
7
10
  Type: string;
8
11
  Description: string;
package/src/cli.ts CHANGED
@@ -4,7 +4,11 @@
4
4
  * Commands are dynamically registered from tools.json cache.
5
5
  */
6
6
 
7
- import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
7
+ // CLI tools output to console by design, file paths are constructed from trusted sources (project root detection),
8
+ // and object keys come from tool definitions which are internal trusted data
9
+ /* eslint-disable no-console, security/detect-non-literal-fs-filename, security/detect-object-injection */
10
+
11
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync } from 'fs';
8
12
  import { join, basename, dirname } from 'path';
9
13
  import { homedir } from 'os';
10
14
  import { spawn } from 'child_process';
@@ -25,7 +29,7 @@ interface CliOptions extends GlobalOptions {
25
29
  [key: string]: unknown;
26
30
  }
27
31
 
28
- const BUILTIN_COMMANDS = ['list', 'sync', 'completion', 'update', 'skills'] as const;
32
+ const BUILTIN_COMMANDS = ['list', 'sync', 'completion', 'update', 'fix', 'skills'] as const;
29
33
 
30
34
  const program = new Command();
31
35
 
@@ -73,6 +77,13 @@ program
73
77
  updateCli();
74
78
  });
75
79
 
80
+ program
81
+ .command('fix')
82
+ .description('Clean up stale lock files that may prevent CLI from connecting')
83
+ .action(() => {
84
+ cleanupLockFiles();
85
+ });
86
+
76
87
  // Register skills subcommand
77
88
  registerSkillsCommand(program);
78
89
 
@@ -202,29 +213,39 @@ function extractGlobalOptions(options: Record<string, unknown>): GlobalOptions {
202
213
  };
203
214
  }
204
215
 
205
- function isDomainReloadLockFilePresent(): boolean {
206
- const projectRoot = findUnityProjectRoot();
207
- if (projectRoot === null) {
208
- return false;
209
- }
210
- const lockPath = join(projectRoot, 'Temp', 'domainreload.lock');
211
- return existsSync(lockPath);
212
- }
213
-
214
216
  async function runWithErrorHandling(fn: () => Promise<void>): Promise<void> {
215
217
  try {
216
218
  await fn();
217
219
  } catch (error) {
218
220
  const message = error instanceof Error ? error.message : String(error);
219
221
 
222
+ if (message === 'UNITY_COMPILING') {
223
+ console.error('\x1b[33m⏳ Unity is compiling scripts.\x1b[0m');
224
+ console.error('Please wait for compilation to finish and try again.');
225
+ process.exit(1);
226
+ }
227
+
228
+ if (message === 'UNITY_DOMAIN_RELOAD') {
229
+ console.error('\x1b[33m⏳ Unity is reloading (Domain Reload in progress).\x1b[0m');
230
+ console.error('Please wait a moment and try again.');
231
+ process.exit(1);
232
+ }
233
+
234
+ if (message === 'UNITY_SERVER_STARTING') {
235
+ console.error('\x1b[33m⏳ Unity server is starting.\x1b[0m');
236
+ console.error('Please wait a moment and try again.');
237
+ process.exit(1);
238
+ }
239
+
240
+ if (message === 'UNITY_NO_RESPONSE') {
241
+ console.error('\x1b[33m⏳ Unity is busy (no response received).\x1b[0m');
242
+ console.error('Unity may be compiling, reloading, or starting. Please wait and try again.');
243
+ process.exit(1);
244
+ }
245
+
220
246
  if (message.includes('ECONNREFUSED')) {
221
- if (isDomainReloadLockFilePresent()) {
222
- console.error('\x1b[33m⏳ Unity is reloading (Domain Reload in progress).\x1b[0m');
223
- console.error('Please wait a moment and try again.');
224
- } else {
225
- console.error('\x1b[31mError: Cannot connect to Unity.\x1b[0m');
226
- console.error('Make sure Unity is running with uLoopMCP installed.');
227
- }
247
+ console.error('\x1b[31mError: Cannot connect to Unity.\x1b[0m');
248
+ console.error('Make sure Unity is running with uLoopMCP installed.');
228
249
  process.exit(1);
229
250
  }
230
251
 
@@ -334,7 +355,6 @@ compdef _uloop uloop`;
334
355
  * Update uloop CLI to the latest version using npm.
335
356
  */
336
357
  function updateCli(): void {
337
- // eslint-disable-next-line no-console
338
358
  console.log('Updating uloop-cli to the latest version...');
339
359
 
340
360
  const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
@@ -345,24 +365,51 @@ function updateCli(): void {
345
365
 
346
366
  child.on('close', (code) => {
347
367
  if (code === 0) {
348
- // eslint-disable-next-line no-console
349
368
  console.log('\n✅ uloop-cli has been updated successfully!');
350
- // eslint-disable-next-line no-console
351
369
  console.log('Run "uloop --version" to check the new version.');
352
370
  } else {
353
- // eslint-disable-next-line no-console
354
371
  console.error(`\n❌ Update failed with exit code ${code}`);
355
372
  process.exit(1);
356
373
  }
357
374
  });
358
375
 
359
376
  child.on('error', (err) => {
360
- // eslint-disable-next-line no-console
361
377
  console.error(`❌ Failed to run npm: ${err.message}`);
362
378
  process.exit(1);
363
379
  });
364
380
  }
365
381
 
382
+ const LOCK_FILES = ['compiling.lock', 'domainreload.lock', 'serverstarting.lock'] as const;
383
+
384
+ /**
385
+ * Clean up stale lock files that may prevent CLI from connecting to Unity.
386
+ */
387
+ function cleanupLockFiles(): void {
388
+ const projectRoot = findUnityProjectRoot();
389
+ if (projectRoot === null) {
390
+ console.error('Could not find Unity project root.');
391
+ process.exit(1);
392
+ }
393
+
394
+ const tempDir = join(projectRoot, 'Temp');
395
+ let cleaned = 0;
396
+
397
+ for (const lockFile of LOCK_FILES) {
398
+ const lockPath = join(tempDir, lockFile);
399
+ if (existsSync(lockPath)) {
400
+ unlinkSync(lockPath);
401
+ console.log(`Removed: ${lockFile}`);
402
+ cleaned++;
403
+ }
404
+ }
405
+
406
+ if (cleaned === 0) {
407
+ console.log('No lock files found.');
408
+ } else {
409
+ console.log(`\n✅ Cleaned up ${cleaned} lock file(s).`);
410
+ }
411
+ }
412
+
366
413
  /**
367
414
  * Handle completion command.
368
415
  */
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.44.2",
2
+ "version": "0.45.0",
3
3
  "tools": [
4
4
  {
5
5
  "name": "compile",
@@ -3,6 +3,9 @@
3
3
  * Establishes one-shot TCP connections to Unity without going through MCP server.
4
4
  */
5
5
 
6
+ // Non-null assertions are used after TCP frame parsing where data existence is guaranteed by protocol
7
+ /* eslint-disable @typescript-eslint/no-non-null-assertion */
8
+
6
9
  import * as net from 'net';
7
10
  import { createFrame, parseFrameFromBuffer, extractFrameFromBuffer } from './simple-framer.js';
8
11
 
@@ -3,15 +3,99 @@
3
3
  * Handles dynamic tool execution by connecting to Unity and sending requests.
4
4
  */
5
5
 
6
+ // CLI tools output to console by design, object keys come from Unity tool responses which are trusted,
7
+ // and lock file paths are constructed from trusted project root detection
8
+ /* eslint-disable no-console, security/detect-object-injection, security/detect-non-literal-fs-filename */
9
+
10
+ import * as readline from 'readline';
11
+ import { existsSync } from 'fs';
12
+ import { join } from 'path';
6
13
  import { DirectUnityClient } from './direct-unity-client.js';
7
14
  import { resolveUnityPort } from './port-resolver.js';
8
15
  import { saveToolsCache, getCacheFilePath, ToolsCache, ToolDefinition } from './tool-cache.js';
9
16
  import { VERSION } from './version.js';
17
+ import { createSpinner } from './spinner.js';
18
+ import { findUnityProjectRoot } from './project-root.js';
19
+
20
+ /**
21
+ * Suppress stdin echo during async operation to prevent escape sequences from being displayed.
22
+ * Returns a cleanup function to restore stdin state.
23
+ */
24
+ function suppressStdinEcho(): () => void {
25
+ if (!process.stdin.isTTY) {
26
+ return () => {};
27
+ }
28
+
29
+ const rl = readline.createInterface({
30
+ input: process.stdin,
31
+ output: process.stdout,
32
+ terminal: false,
33
+ });
34
+
35
+ process.stdin.setRawMode(true);
36
+ process.stdin.resume();
37
+
38
+ const onData = (data: Buffer): void => {
39
+ // Ctrl+C (0x03) should trigger process exit
40
+ if (data[0] === 0x03) {
41
+ process.exit(130);
42
+ }
43
+ };
44
+ process.stdin.on('data', onData);
45
+
46
+ return () => {
47
+ process.stdin.off('data', onData);
48
+ process.stdin.setRawMode(false);
49
+ process.stdin.pause();
50
+ rl.close();
51
+ };
52
+ }
10
53
 
11
54
  export interface GlobalOptions {
12
55
  port?: string;
13
56
  }
14
57
 
58
+ const RETRY_DELAY_MS = 500;
59
+ const MAX_RETRIES = 3;
60
+
61
+ function sleep(ms: number): Promise<void> {
62
+ return new Promise((resolve) => setTimeout(resolve, ms));
63
+ }
64
+
65
+ function isRetryableError(error: unknown): boolean {
66
+ if (!(error instanceof Error)) {
67
+ return false;
68
+ }
69
+ const message = error.message;
70
+ return message.includes('ECONNREFUSED') || message === 'UNITY_NO_RESPONSE';
71
+ }
72
+
73
+ /**
74
+ * Check if Unity is in a busy state (compiling, reloading, or server starting).
75
+ * Throws an error with appropriate message if busy.
76
+ */
77
+ function checkUnityBusyState(): void {
78
+ const projectRoot = findUnityProjectRoot();
79
+ if (projectRoot === null) {
80
+ return;
81
+ }
82
+
83
+ const compilingLock = join(projectRoot, 'Temp', 'compiling.lock');
84
+ if (existsSync(compilingLock)) {
85
+ throw new Error('UNITY_COMPILING');
86
+ }
87
+
88
+ const domainReloadLock = join(projectRoot, 'Temp', 'domainreload.lock');
89
+ if (existsSync(domainReloadLock)) {
90
+ throw new Error('UNITY_DOMAIN_RELOAD');
91
+ }
92
+
93
+ const serverStartingLock = join(projectRoot, 'Temp', 'serverstarting.lock');
94
+ if (existsSync(serverStartingLock)) {
95
+ throw new Error('UNITY_SERVER_STARTING');
96
+ }
97
+ }
98
+
15
99
  export async function executeToolCommand(
16
100
  toolName: string,
17
101
  params: Record<string, unknown>,
@@ -27,18 +111,46 @@ export async function executeToolCommand(
27
111
  }
28
112
  const port = await resolveUnityPort(portNumber);
29
113
 
30
- const client = new DirectUnityClient(port);
114
+ const restoreStdin = suppressStdinEcho();
115
+ const spinner = createSpinner('Connecting to Unity...');
116
+
117
+ let lastError: unknown;
118
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
119
+ checkUnityBusyState();
120
+
121
+ const client = new DirectUnityClient(port);
122
+ try {
123
+ await client.connect();
124
+
125
+ spinner.update(`Executing ${toolName}...`);
126
+ const result = await client.sendRequest(toolName, params);
31
127
 
32
- try {
33
- await client.connect();
128
+ if (result === undefined || result === null) {
129
+ throw new Error('UNITY_NO_RESPONSE');
130
+ }
34
131
 
35
- const result = await client.sendRequest(toolName, params);
132
+ // Success - stop spinner and output result
133
+ spinner.stop();
134
+ restoreStdin();
135
+ console.log(JSON.stringify(result, null, 2));
136
+ return;
137
+ } catch (error) {
138
+ lastError = error;
139
+ client.disconnect();
36
140
 
37
- // Always output JSON to match MCP response format
38
- console.log(JSON.stringify(result, null, 2));
39
- } finally {
40
- client.disconnect();
141
+ if (!isRetryableError(error) || attempt >= MAX_RETRIES) {
142
+ break;
143
+ }
144
+ spinner.update('Retrying connection...');
145
+ await sleep(RETRY_DELAY_MS);
146
+ } finally {
147
+ client.disconnect();
148
+ }
41
149
  }
150
+
151
+ spinner.stop();
152
+ restoreStdin();
153
+ throw lastError;
42
154
  }
43
155
 
44
156
  export async function listAvailableTools(globalOptions: GlobalOptions): Promise<void> {
@@ -52,25 +164,50 @@ export async function listAvailableTools(globalOptions: GlobalOptions): Promise<
52
164
  }
53
165
  const port = await resolveUnityPort(portNumber);
54
166
 
55
- const client = new DirectUnityClient(port);
167
+ const restoreStdin = suppressStdinEcho();
168
+ const spinner = createSpinner('Connecting to Unity...');
56
169
 
57
- try {
58
- await client.connect();
170
+ let lastError: unknown;
171
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
172
+ checkUnityBusyState();
59
173
 
60
- const result = await client.sendRequest<{
61
- Tools: Array<{ name: string; description: string }>;
62
- }>('get-tool-details', { IncludeDevelopmentOnly: false });
174
+ const client = new DirectUnityClient(port);
175
+ try {
176
+ await client.connect();
63
177
 
64
- if (!result.Tools || !Array.isArray(result.Tools)) {
65
- throw new Error('Unexpected response from Unity: missing Tools array');
66
- }
178
+ spinner.update('Fetching tool list...');
179
+ const result = await client.sendRequest<{
180
+ Tools: Array<{ name: string; description: string }>;
181
+ }>('get-tool-details', { IncludeDevelopmentOnly: false });
67
182
 
68
- for (const tool of result.Tools) {
69
- console.log(` - ${tool.name}`);
183
+ if (!result.Tools || !Array.isArray(result.Tools)) {
184
+ throw new Error('Unexpected response from Unity: missing Tools array');
185
+ }
186
+
187
+ // Success - stop spinner and output result
188
+ spinner.stop();
189
+ restoreStdin();
190
+ for (const tool of result.Tools) {
191
+ console.log(` - ${tool.name}`);
192
+ }
193
+ return;
194
+ } catch (error) {
195
+ lastError = error;
196
+ client.disconnect();
197
+
198
+ if (!isRetryableError(error) || attempt >= MAX_RETRIES) {
199
+ break;
200
+ }
201
+ spinner.update('Retrying connection...');
202
+ await sleep(RETRY_DELAY_MS);
203
+ } finally {
204
+ client.disconnect();
70
205
  }
71
- } finally {
72
- client.disconnect();
73
206
  }
207
+
208
+ spinner.stop();
209
+ restoreStdin();
210
+ throw lastError;
74
211
  }
75
212
 
76
213
  interface UnityToolInfo {
@@ -115,41 +252,66 @@ export async function syncTools(globalOptions: GlobalOptions): Promise<void> {
115
252
  }
116
253
  const port = await resolveUnityPort(portNumber);
117
254
 
118
- const client = new DirectUnityClient(port);
255
+ const restoreStdin = suppressStdinEcho();
256
+ const spinner = createSpinner('Connecting to Unity...');
119
257
 
120
- try {
121
- await client.connect();
258
+ let lastError: unknown;
259
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
260
+ checkUnityBusyState();
122
261
 
123
- const result = await client.sendRequest<{
124
- Tools: UnityToolInfo[];
125
- }>('get-tool-details', { IncludeDevelopmentOnly: false });
262
+ const client = new DirectUnityClient(port);
263
+ try {
264
+ await client.connect();
126
265
 
127
- if (!result.Tools || !Array.isArray(result.Tools)) {
128
- throw new Error('Unexpected response from Unity: missing Tools array');
129
- }
266
+ spinner.update('Syncing tools...');
267
+ const result = await client.sendRequest<{
268
+ Tools: UnityToolInfo[];
269
+ }>('get-tool-details', { IncludeDevelopmentOnly: false });
130
270
 
131
- const cache: ToolsCache = {
132
- version: VERSION,
133
- updatedAt: new Date().toISOString(),
134
- tools: result.Tools.map((tool) => ({
135
- name: tool.name,
136
- description: tool.description,
137
- inputSchema: {
138
- type: 'object',
139
- properties: convertProperties(tool.parameterSchema.Properties),
140
- required: tool.parameterSchema.Required,
141
- },
142
- })),
143
- };
271
+ spinner.stop();
272
+ if (!result.Tools || !Array.isArray(result.Tools)) {
273
+ restoreStdin();
274
+ throw new Error('Unexpected response from Unity: missing Tools array');
275
+ }
276
+
277
+ const cache: ToolsCache = {
278
+ version: VERSION,
279
+ updatedAt: new Date().toISOString(),
280
+ tools: result.Tools.map((tool) => ({
281
+ name: tool.name,
282
+ description: tool.description,
283
+ inputSchema: {
284
+ type: 'object',
285
+ properties: convertProperties(tool.parameterSchema.Properties),
286
+ required: tool.parameterSchema.Required,
287
+ },
288
+ })),
289
+ };
144
290
 
145
- saveToolsCache(cache);
291
+ saveToolsCache(cache);
146
292
 
147
- console.log(`Synced ${cache.tools.length} tools to ${getCacheFilePath()}`);
148
- console.log('\nTools:');
149
- for (const tool of cache.tools) {
150
- console.log(` - ${tool.name}`);
293
+ console.log(`Synced ${cache.tools.length} tools to ${getCacheFilePath()}`);
294
+ console.log('\nTools:');
295
+ for (const tool of cache.tools) {
296
+ console.log(` - ${tool.name}`);
297
+ }
298
+ restoreStdin();
299
+ return;
300
+ } catch (error) {
301
+ lastError = error;
302
+ client.disconnect();
303
+
304
+ if (!isRetryableError(error) || attempt >= MAX_RETRIES) {
305
+ break;
306
+ }
307
+ spinner.update('Retrying connection...');
308
+ await sleep(RETRY_DELAY_MS);
309
+ } finally {
310
+ client.disconnect();
151
311
  }
152
- } finally {
153
- client.disconnect();
154
312
  }
313
+
314
+ spinner.stop();
315
+ restoreStdin();
316
+ throw lastError;
155
317
  }
@@ -3,6 +3,9 @@
3
3
  * Resolves Unity server port from various sources.
4
4
  */
5
5
 
6
+ // File paths are constructed from Unity project root detection, not from user input
7
+ /* eslint-disable security/detect-non-literal-fs-filename */
8
+
6
9
  import { readFile } from 'fs/promises';
7
10
  import { join } from 'path';
8
11
  import { findUnityProjectRoot } from './project-root.js';
@@ -3,6 +3,9 @@
3
3
  * Searches upward from current directory to find Unity project markers.
4
4
  */
5
5
 
6
+ // Path traversal is intentional for finding Unity project root by walking up directory tree
7
+ /* eslint-disable security/detect-non-literal-fs-filename */
8
+
6
9
  import { existsSync } from 'fs';
7
10
  import { join, dirname } from 'path';
8
11
 
@@ -35,3 +35,13 @@ Returns JSON:
35
35
  - `Success`: boolean
36
36
  - `ErrorCount`: number
37
37
  - `WarningCount`: number
38
+
39
+ ## Troubleshooting
40
+
41
+ If CLI hangs or shows "Unity is busy" errors after compilation, stale lock files may be preventing connection. Run the following to clean them up:
42
+
43
+ ```bash
44
+ uloop fix
45
+ ```
46
+
47
+ This removes any leftover lock files (`compiling.lock`, `domainreload.lock`, `serverstarting.lock`) from the Unity project's Temp directory.
@@ -2,6 +2,9 @@
2
2
  * CLI command definitions for skills management.
3
3
  */
4
4
 
5
+ // CLI commands output to console by design
6
+ /* eslint-disable no-console */
7
+
5
8
  import { Command } from 'commander';
6
9
  import {
7
10
  getAllSkillStatuses,
@@ -2,6 +2,9 @@
2
2
  * Skills manager for installing/uninstalling/listing uloop skills.
3
3
  */
4
4
 
5
+ // File paths are constructed from home directory and skill names, not from untrusted user input
6
+ /* eslint-disable security/detect-non-literal-fs-filename */
7
+
5
8
  import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'fs';
6
9
  import { join } from 'path';
7
10
  import { homedir } from 'os';
package/src/spinner.ts ADDED
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Terminal spinner for showing loading state during async operations.
3
+ */
4
+
5
+ // Array index access is safe here (modulo operation keeps it in bounds)
6
+ /* eslint-disable security/detect-object-injection */
7
+
8
+ const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] as const;
9
+ const FRAME_INTERVAL_MS = 80;
10
+
11
+ export interface Spinner {
12
+ update(message: string): void;
13
+ stop(): void;
14
+ }
15
+
16
+ /**
17
+ * Create a terminal spinner that displays a rotating animation with a message.
18
+ * Returns a Spinner object with update() and stop() methods.
19
+ */
20
+ export function createSpinner(initialMessage: string): Spinner {
21
+ if (!process.stderr.isTTY) {
22
+ return {
23
+ update: (): void => {},
24
+ stop: (): void => {},
25
+ };
26
+ }
27
+
28
+ let frameIndex = 0;
29
+ let currentMessage = initialMessage;
30
+
31
+ const render = (): void => {
32
+ const frame = SPINNER_FRAMES[frameIndex];
33
+ process.stderr.write(`\r\x1b[K${frame} ${currentMessage}`);
34
+ frameIndex = (frameIndex + 1) % SPINNER_FRAMES.length;
35
+ };
36
+
37
+ render();
38
+ const intervalId = setInterval(render, FRAME_INTERVAL_MS);
39
+
40
+ return {
41
+ update(message: string): void {
42
+ currentMessage = message;
43
+ },
44
+ stop(): void {
45
+ clearInterval(intervalId);
46
+ process.stderr.write('\r\x1b[K');
47
+ },
48
+ };
49
+ }
package/src/tool-cache.ts CHANGED
@@ -3,6 +3,9 @@
3
3
  * Handles loading/saving tool definitions from .uloop/tools.json cache.
4
4
  */
5
5
 
6
+ // File paths are constructed from Unity project root, not from untrusted user input
7
+ /* eslint-disable security/detect-non-literal-fs-filename */
8
+
6
9
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
7
10
  import { join } from 'path';
8
11
  import defaultToolsData from './default-tools.json';
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.44.2'; // x-release-please-version
7
+ export const VERSION = '0.45.0'; // x-release-please-version