uloop-cli 0.55.2 → 0.57.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.55.2",
3
+ "version": "0.57.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",
@@ -41,23 +41,24 @@
41
41
  "provenance": true
42
42
  },
43
43
  "dependencies": {
44
- "commander": "^14.0.2",
45
- "semver": "^7.7.3"
44
+ "commander": "14.0.2",
45
+ "launch-unity": "0.12.0",
46
+ "semver": "7.7.3"
46
47
  },
47
48
  "devDependencies": {
48
49
  "@eslint/js": "9.39.2",
49
- "tsx": "4.21.0",
50
50
  "@types/jest": "30.0.0",
51
51
  "@types/node": "25.0.2",
52
- "@types/semver": "^7.7.1",
52
+ "@types/semver": "7.7.1",
53
53
  "esbuild": "0.27.1",
54
54
  "eslint": "9.39.2",
55
- "eslint-config-prettier": "^10.1.8",
55
+ "eslint-config-prettier": "10.1.8",
56
56
  "eslint-plugin-prettier": "5.5.4",
57
57
  "eslint-plugin-security": "3.0.1",
58
58
  "jest": "30.2.0",
59
59
  "prettier": "3.7.4",
60
60
  "ts-jest": "29.4.6",
61
+ "tsx": "4.21.0",
61
62
  "typescript": "5.9.3",
62
63
  "typescript-eslint": "8.49.0"
63
64
  }
@@ -482,6 +482,46 @@ describe('CLI E2E Tests (requires running Unity)', () => {
482
482
  });
483
483
  });
484
484
 
485
+ describe('launch', () => {
486
+ it('should display launch command help', () => {
487
+ const { stdout, exitCode } = runCli('launch --help');
488
+
489
+ expect(exitCode).toBe(0);
490
+ expect(stdout).toContain('Launch Unity project');
491
+ expect(stdout).toContain('--restart');
492
+ expect(stdout).toContain('--platform');
493
+ expect(stdout).toContain('--max-depth');
494
+ expect(stdout).toContain('--add-unity-hub');
495
+ expect(stdout).toContain('--favorite');
496
+ });
497
+
498
+ it('should detect already running Unity and focus window', () => {
499
+ // Unity is already running for this test suite, so launch should detect it
500
+ const { stdout, exitCode } = runCli(`launch "${UNITY_PROJECT_ROOT}"`);
501
+
502
+ expect(exitCode).toBe(0);
503
+ expect(stdout).toContain('Unity process already running');
504
+ });
505
+
506
+ it('should fail gracefully when project not found', () => {
507
+ const { stdout, stderr, exitCode } = runCli('launch /nonexistent/path/to/project');
508
+
509
+ expect(exitCode).not.toBe(0);
510
+ // Error message should mention project not found or version file not found
511
+ const output = stderr || stdout;
512
+ expect(output).toMatch(/not found|does not appear to be a Unity project/i);
513
+ });
514
+
515
+ it('should search for Unity project from current directory', () => {
516
+ // This test runs from Unity project root, so it should find the project
517
+ const { stdout, exitCode } = runCli('launch');
518
+
519
+ expect(exitCode).toBe(0);
520
+ // Should either find and focus existing Unity or report no Unity found
521
+ expect(stdout).toMatch(/Unity process already running|Selected project/);
522
+ });
523
+ });
524
+
485
525
  // Domain Reload tests must run last to avoid affecting other tests
486
526
  describe('compile --force-recompile (Domain Reload)', () => {
487
527
  it('should support --force-recompile option', () => {
package/src/cli.ts CHANGED
@@ -18,10 +18,18 @@ import {
18
18
  listAvailableTools,
19
19
  GlobalOptions,
20
20
  syncTools,
21
+ isVersionOlder,
21
22
  } from './execute-tool.js';
22
- import { loadToolsCache, hasCacheFile, ToolDefinition, ToolProperty } from './tool-cache.js';
23
+ import {
24
+ loadToolsCache,
25
+ hasCacheFile,
26
+ ToolDefinition,
27
+ ToolProperty,
28
+ getCachedServerVersion,
29
+ } from './tool-cache.js';
23
30
  import { pascalToKebabCase } from './arg-parser.js';
24
31
  import { registerSkillsCommand } from './skills/skills-command.js';
32
+ import { registerLaunchCommand } from './commands/launch.js';
25
33
  import { VERSION } from './version.js';
26
34
  import { findUnityProjectRoot } from './project-root.js';
27
35
 
@@ -29,7 +37,15 @@ interface CliOptions extends GlobalOptions {
29
37
  [key: string]: unknown;
30
38
  }
31
39
 
32
- const BUILTIN_COMMANDS = ['list', 'sync', 'completion', 'update', 'fix', 'skills'] as const;
40
+ const BUILTIN_COMMANDS = [
41
+ 'list',
42
+ 'sync',
43
+ 'completion',
44
+ 'update',
45
+ 'fix',
46
+ 'skills',
47
+ 'launch',
48
+ ] as const;
33
49
 
34
50
  const program = new Command();
35
51
 
@@ -87,6 +103,9 @@ program
87
103
  // Register skills subcommand
88
104
  registerSkillsCommand(program);
89
105
 
106
+ // Register launch subcommand
107
+ registerLaunchCommand(program);
108
+
90
109
  /**
91
110
  * Register a tool as a CLI command dynamically.
92
111
  */
@@ -274,12 +293,45 @@ function printConnectionError(): void {
274
293
  console.error('[For AI] Please report the above to the user.');
275
294
  }
276
295
 
296
+ /**
297
+ * Print version mismatch diagnostic information when errors occur.
298
+ * Only prints if versions are mismatched.
299
+ */
300
+ function printVersionMismatchDiagnostic(): void {
301
+ const serverVersion = getCachedServerVersion();
302
+ if (serverVersion === undefined) {
303
+ return;
304
+ }
305
+
306
+ if (serverVersion === VERSION) {
307
+ return;
308
+ }
309
+
310
+ const isCliOlder = isVersionOlder(VERSION, serverVersion);
311
+ const action = isCliOlder ? 'Update' : 'Downgrade';
312
+ const updateCommand = `npm install -g uloop-cli@${serverVersion}`;
313
+
314
+ console.error('');
315
+ console.error('\x1b[33m━━━ Version Mismatch Diagnostic ━━━\x1b[0m');
316
+ console.error('');
317
+ console.error(` CLI version: ${VERSION}`);
318
+ console.error(` Unity package: ${serverVersion}`);
319
+ console.error(' Version mismatch may cause communication errors.');
320
+ console.error('');
321
+ console.error(` To fix: ${action} CLI to match Unity package version`);
322
+ console.error(` ${updateCommand}`);
323
+ console.error('');
324
+ console.error('[For AI] Please ask the user if they would like to run this command.');
325
+ console.error('\x1b[33m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m');
326
+ }
327
+
277
328
  async function runWithErrorHandling(fn: () => Promise<void>): Promise<void> {
278
329
  try {
279
330
  await fn();
280
331
  } catch (error) {
281
332
  const message = error instanceof Error ? error.message : String(error);
282
333
 
334
+ // Unity busy states have clear causes - no version diagnostic needed
283
335
  if (message === 'UNITY_COMPILING') {
284
336
  console.error('\x1b[33m⏳ Unity is compiling scripts.\x1b[0m');
285
337
  console.error('Please wait for compilation to finish and try again.');
@@ -298,14 +350,24 @@ async function runWithErrorHandling(fn: () => Promise<void>): Promise<void> {
298
350
  process.exit(1);
299
351
  }
300
352
 
353
+ // Errors that may be caused by version mismatch - show diagnostic
301
354
  if (message === 'UNITY_NO_RESPONSE') {
302
355
  console.error('\x1b[33m⏳ Unity is busy (no response received).\x1b[0m');
303
356
  console.error('Unity may be compiling, reloading, or starting. Please wait and try again.');
357
+ printVersionMismatchDiagnostic();
304
358
  process.exit(1);
305
359
  }
306
360
 
307
361
  if (isConnectionError(message)) {
308
362
  printConnectionError();
363
+ printVersionMismatchDiagnostic();
364
+ process.exit(1);
365
+ }
366
+
367
+ // Timeout errors
368
+ if (message.includes('Request timed out')) {
369
+ console.error(`\x1b[31mError: ${message}\x1b[0m`);
370
+ printVersionMismatchDiagnostic();
309
371
  process.exit(1);
310
372
  }
311
373
 
@@ -0,0 +1,125 @@
1
+ /**
2
+ * CLI command for launching Unity projects.
3
+ * Integrates launch-unity library into uloop CLI.
4
+ */
5
+
6
+ // CLI commands output to console by design
7
+ /* eslint-disable no-console */
8
+
9
+ import { Command } from 'commander';
10
+ import { resolve } from 'path';
11
+
12
+ import {
13
+ findUnityProjectBfs,
14
+ getUnityVersion,
15
+ launch,
16
+ findRunningUnityProcess,
17
+ focusUnityProcess,
18
+ killRunningUnity,
19
+ handleStaleLockfile,
20
+ ensureProjectEntryAndUpdate,
21
+ updateLastModifiedIfExists,
22
+ LaunchResolvedOptions,
23
+ } from 'launch-unity';
24
+
25
+ interface LaunchCommandOptions {
26
+ restart?: boolean;
27
+ platform?: string;
28
+ maxDepth?: string;
29
+ addUnityHub?: boolean;
30
+ favorite?: boolean;
31
+ }
32
+
33
+ export function registerLaunchCommand(program: Command): void {
34
+ program
35
+ .command('launch')
36
+ .description('Launch Unity project with matching Editor version')
37
+ .argument('[project-path]', 'Path to Unity project')
38
+ .option('-r, --restart', 'Kill running Unity and restart')
39
+ .option('-p, --platform <platform>', 'Build target (e.g., Android, iOS)')
40
+ .option('--max-depth <n>', 'Search depth when project-path is omitted', '3')
41
+ .option('-a, --add-unity-hub', 'Add to Unity Hub (does not launch)')
42
+ .option('-f, --favorite', 'Add to Unity Hub as favorite (does not launch)')
43
+ .action(async (projectPath: string | undefined, options: LaunchCommandOptions) => {
44
+ await runLaunchCommand(projectPath, options);
45
+ });
46
+ }
47
+
48
+ function parseMaxDepth(value: string | undefined): number {
49
+ if (value === undefined) {
50
+ return 3;
51
+ }
52
+ const parsed = parseInt(value, 10);
53
+ if (Number.isNaN(parsed)) {
54
+ console.error(`Error: Invalid --max-depth value: "${value}". Must be an integer.`);
55
+ process.exit(1);
56
+ }
57
+ return parsed;
58
+ }
59
+
60
+ async function runLaunchCommand(
61
+ projectPath: string | undefined,
62
+ options: LaunchCommandOptions,
63
+ ): Promise<void> {
64
+ const maxDepth = parseMaxDepth(options.maxDepth);
65
+
66
+ let resolvedProjectPath: string | undefined = projectPath ? resolve(projectPath) : undefined;
67
+
68
+ if (!resolvedProjectPath) {
69
+ const searchRoot = process.cwd();
70
+ const depthInfo = maxDepth === -1 ? 'unlimited' : String(maxDepth);
71
+ console.log(
72
+ `No project-path provided. Searching under ${searchRoot} (max-depth: ${depthInfo})...`,
73
+ );
74
+ const found = findUnityProjectBfs(searchRoot, maxDepth);
75
+ if (!found) {
76
+ console.error(`Error: Unity project not found under ${searchRoot}.`);
77
+ process.exit(1);
78
+ }
79
+ console.log(`Selected project: ${found}`);
80
+ resolvedProjectPath = found;
81
+ }
82
+
83
+ const unityVersion = getUnityVersion(resolvedProjectPath);
84
+
85
+ const unityHubOnlyMode = options.addUnityHub === true || options.favorite === true;
86
+ if (unityHubOnlyMode) {
87
+ console.log(`Detected Unity version: ${unityVersion}`);
88
+ console.log(`Project Path: ${resolvedProjectPath}`);
89
+ const now = new Date();
90
+ await ensureProjectEntryAndUpdate(
91
+ resolvedProjectPath,
92
+ unityVersion,
93
+ now,
94
+ options.favorite === true,
95
+ );
96
+ console.log('Unity Hub entry updated.');
97
+ return;
98
+ }
99
+
100
+ if (options.restart === true) {
101
+ await killRunningUnity(resolvedProjectPath);
102
+ } else {
103
+ const runningProcess = await findRunningUnityProcess(resolvedProjectPath);
104
+ if (runningProcess) {
105
+ console.log(
106
+ `Unity process already running for project: ${resolvedProjectPath} (PID: ${runningProcess.pid})`,
107
+ );
108
+ await focusUnityProcess(runningProcess.pid);
109
+ return;
110
+ }
111
+ }
112
+
113
+ await handleStaleLockfile(resolvedProjectPath);
114
+
115
+ const resolved: LaunchResolvedOptions = {
116
+ projectPath: resolvedProjectPath,
117
+ platform: options.platform,
118
+ unityArgs: [],
119
+ unityVersion,
120
+ };
121
+ launch(resolved);
122
+
123
+ const now = new Date();
124
+ await updateLastModifiedIfExists(resolvedProjectPath, now);
125
+ }
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.55.2",
2
+ "version": "0.57.0",
3
3
  "tools": [
4
4
  {
5
5
  "name": "compile",
@@ -142,6 +142,11 @@
142
142
  "IncludePaths": {
143
143
  "type": "boolean",
144
144
  "description": "Include path information"
145
+ },
146
+ "UseSelection": {
147
+ "type": "boolean",
148
+ "description": "Use selected GameObject(s) as root(s). When true, RootPath is ignored.",
149
+ "default": false
145
150
  }
146
151
  }
147
152
  }
@@ -75,7 +75,7 @@ export class DirectUnityClient {
75
75
  const timeoutId = setTimeout(() => {
76
76
  reject(
77
77
  new Error(
78
- `Request timed out after ${NETWORK_TIMEOUT_MS}ms. Unity may be frozen or busy. Please report this to the user and ask how to proceed. Do NOT kill Unity processes without user permission.`,
78
+ `Request timed out after ${NETWORK_TIMEOUT_MS}ms. Unity may be frozen or busy. [For AI] Please report this to the user and ask how to proceed. Do NOT kill Unity processes without user permission.`,
79
79
  ),
80
80
  );
81
81
  }, NETWORK_TIMEOUT_MS);
@@ -80,7 +80,7 @@ function isRetryableError(error: unknown): boolean {
80
80
  * Returns true if v1 < v2, false otherwise.
81
81
  * Falls back to string comparison if versions are invalid.
82
82
  */
83
- function isVersionOlder(v1: string, v2: string): boolean {
83
+ export function isVersionOlder(v1: string, v2: string): boolean {
84
84
  const parsed1 = semver.valid(v1);
85
85
  const parsed2 = semver.valid(v2);
86
86
 
@@ -321,6 +321,7 @@ export async function syncTools(globalOptions: GlobalOptions): Promise<void> {
321
321
  spinner.update('Syncing tools...');
322
322
  const result = await client.sendRequest<{
323
323
  Tools: UnityToolInfo[];
324
+ Ver?: string;
324
325
  }>('get-tool-details', { IncludeDevelopmentOnly: false });
325
326
 
326
327
  spinner.stop();
@@ -331,6 +332,7 @@ export async function syncTools(globalOptions: GlobalOptions): Promise<void> {
331
332
 
332
333
  const cache: ToolsCache = {
333
334
  version: VERSION,
335
+ serverVersion: result.Ver,
334
336
  updatedAt: new Date().toISOString(),
335
337
  tools: result.Tools.map((tool) => ({
336
338
  name: tool.name,
@@ -0,0 +1,51 @@
1
+ ---
2
+ name: uloop-launch
3
+ description: "Launch Unity project with matching Editor version via uloop CLI. Use when you need to: (1) Open a Unity project with the correct Editor version, (2) Restart Unity to apply changes, (3) Switch build target when launching."
4
+ ---
5
+
6
+ # uloop launch
7
+
8
+ Launch Unity Editor with the correct version for a project.
9
+
10
+ ## Usage
11
+
12
+ ```bash
13
+ uloop launch [project-path] [options]
14
+ ```
15
+
16
+ ## Parameters
17
+
18
+ | Parameter | Type | Description |
19
+ |-----------|------|-------------|
20
+ | `project-path` | string | Path to Unity project (optional, searches current directory if omitted) |
21
+ | `-r, --restart` | boolean | Kill running Unity and restart |
22
+ | `-p, --platform <P>` | string | Build target (e.g., StandaloneOSX, Android, iOS) |
23
+ | `--max-depth <N>` | number | Search depth when project-path is omitted (default: 3, -1 for unlimited) |
24
+ | `-a, --add-unity-hub` | boolean | Add to Unity Hub only (does not launch) |
25
+ | `-f, --favorite` | boolean | Add to Unity Hub as favorite (does not launch) |
26
+
27
+ ## Examples
28
+
29
+ ```bash
30
+ # Search for Unity project in current directory and launch
31
+ uloop launch
32
+
33
+ # Launch specific project
34
+ uloop launch /path/to/project
35
+
36
+ # Restart Unity (kill existing and relaunch)
37
+ uloop launch -r
38
+
39
+ # Launch with build target
40
+ uloop launch -p Android
41
+
42
+ # Add project to Unity Hub without launching
43
+ uloop launch -a
44
+ ```
45
+
46
+ ## Output
47
+
48
+ - Prints detected Unity version
49
+ - Prints project path
50
+ - If Unity is already running, focuses the existing window
51
+ - If launching, opens Unity in background
package/src/tool-cache.ts CHANGED
@@ -33,6 +33,7 @@ export interface ToolDefinition {
33
33
 
34
34
  export interface ToolsCache {
35
35
  version: string;
36
+ serverVersion?: string;
36
37
  updatedAt?: string;
37
38
  tools: ToolDefinition[];
38
39
  }
@@ -105,3 +106,23 @@ export function hasCacheFile(): boolean {
105
106
  export function getCacheFilePath(): string {
106
107
  return getCachePath();
107
108
  }
109
+
110
+ /**
111
+ * Get the Unity server version from cache file.
112
+ * Returns undefined if cache doesn't exist, is corrupted, or serverVersion is missing.
113
+ */
114
+ export function getCachedServerVersion(): string | undefined {
115
+ const cachePath = getCachePath();
116
+
117
+ if (!existsSync(cachePath)) {
118
+ return undefined;
119
+ }
120
+
121
+ try {
122
+ const content = readFileSync(cachePath, 'utf-8');
123
+ const cache = JSON.parse(content) as Partial<ToolsCache>;
124
+ return typeof cache.serverVersion === 'string' ? cache.serverVersion : undefined;
125
+ } catch {
126
+ return undefined;
127
+ }
128
+ }
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.55.2'; // x-release-please-version
7
+ export const VERSION = '0.57.0'; // x-release-please-version