uloop-cli 0.65.0 → 0.66.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uloop-cli",
3
- "version": "0.65.0",
3
+ "version": "0.66.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",
@@ -34,7 +34,7 @@
34
34
  },
35
35
  "homepage": "https://github.com/hatayama/uLoopMCP#readme",
36
36
  "engines": {
37
- "node": ">=20.0.0"
37
+ "node": ">=20.19.0"
38
38
  },
39
39
  "publishConfig": {
40
40
  "access": "public",
@@ -46,20 +46,23 @@
46
46
  "semver": "7.7.4"
47
47
  },
48
48
  "devDependencies": {
49
- "@eslint/js": "9.39.2",
49
+ "@eslint/js": "10.0.1",
50
50
  "@types/jest": "30.0.0",
51
51
  "@types/node": "25.3.0",
52
52
  "@types/semver": "7.7.1",
53
53
  "esbuild": "0.27.3",
54
- "eslint": "9.39.2",
54
+ "eslint": "10.0.1",
55
55
  "eslint-config-prettier": "10.1.8",
56
56
  "eslint-plugin-prettier": "5.5.5",
57
- "eslint-plugin-security": "3.0.1",
57
+ "eslint-plugin-security": "4.0.0",
58
58
  "jest": "30.2.0",
59
59
  "prettier": "3.8.1",
60
60
  "ts-jest": "29.4.6",
61
61
  "tsx": "4.21.0",
62
62
  "typescript": "5.9.3",
63
63
  "typescript-eslint": "8.56.0"
64
+ },
65
+ "overrides": {
66
+ "minimatch": "10.2.2"
64
67
  }
65
68
  }
@@ -1,4 +1,9 @@
1
- import { resolvePortFromUnitySettings } from '../port-resolver.js';
1
+ import { tmpdir } from 'os';
2
+ import {
3
+ resolvePortFromUnitySettings,
4
+ validateProjectPath,
5
+ resolveUnityPort,
6
+ } from '../port-resolver.js';
2
7
 
3
8
  describe('resolvePortFromUnitySettings', () => {
4
9
  it('returns serverPort when server is running and serverPort is valid', () => {
@@ -61,3 +66,28 @@ describe('resolvePortFromUnitySettings', () => {
61
66
  expect(port).toBeNull();
62
67
  });
63
68
  });
69
+
70
+ describe('validateProjectPath', () => {
71
+ it('throws when path does not exist', () => {
72
+ expect(() => validateProjectPath('/nonexistent/path/to/project')).toThrow(
73
+ 'Path does not exist: /nonexistent/path/to/project',
74
+ );
75
+ });
76
+
77
+ it('throws when path is not a Unity project', () => {
78
+ expect(() => validateProjectPath(tmpdir())).toThrow('Not a Unity project');
79
+ });
80
+ });
81
+
82
+ describe('resolveUnityPort', () => {
83
+ it('throws when both port and projectPath are specified', async () => {
84
+ await expect(resolveUnityPort(8700, '/some/path')).rejects.toThrow(
85
+ 'Cannot specify both --port and --project-path',
86
+ );
87
+ });
88
+
89
+ it('returns explicit port when only port is specified', async () => {
90
+ const port = await resolveUnityPort(8711);
91
+ expect(port).toBe(8711);
92
+ });
93
+ });
package/src/cli.ts CHANGED
@@ -33,6 +33,7 @@ import { registerLaunchCommand } from './commands/launch.js';
33
33
  import { registerFocusWindowCommand } from './commands/focus-window.js';
34
34
  import { VERSION } from './version.js';
35
35
  import { findUnityProjectRoot } from './project-root.js';
36
+ import { validateProjectPath } from './port-resolver.js';
36
37
 
37
38
  interface CliOptions extends GlobalOptions {
38
39
  [key: string]: unknown;
@@ -73,16 +74,18 @@ program
73
74
  .command('list')
74
75
  .description('List all available tools from Unity')
75
76
  .option('-p, --port <port>', 'Unity TCP port')
77
+ .option('--project-path <path>', 'Unity project path')
76
78
  .action(async (options: CliOptions) => {
77
- await runWithErrorHandling(() => listAvailableTools(options));
79
+ await runWithErrorHandling(() => listAvailableTools(extractGlobalOptions(options)));
78
80
  });
79
81
 
80
82
  program
81
83
  .command('sync')
82
84
  .description('Sync tool definitions from Unity to local cache')
83
85
  .option('-p, --port <port>', 'Unity TCP port')
86
+ .option('--project-path <path>', 'Unity project path')
84
87
  .action(async (options: CliOptions) => {
85
- await runWithErrorHandling(() => syncTools(options));
88
+ await runWithErrorHandling(() => syncTools(extractGlobalOptions(options)));
86
89
  });
87
90
 
88
91
  program
@@ -104,8 +107,12 @@ program
104
107
  program
105
108
  .command('fix')
106
109
  .description('Clean up stale lock files that may prevent CLI from connecting')
107
- .action(() => {
108
- cleanupLockFiles();
110
+ .option('--project-path <path>', 'Unity project path')
111
+ .action(async (options: { projectPath?: string }) => {
112
+ await runWithErrorHandling(() => {
113
+ cleanupLockFiles(options.projectPath);
114
+ return Promise.resolve();
115
+ });
109
116
  });
110
117
 
111
118
  // Register skills subcommand
@@ -144,6 +151,7 @@ function registerToolCommand(tool: ToolDefinition): void {
144
151
 
145
152
  // Add global options
146
153
  cmd.option('-p, --port <port>', 'Unity TCP port');
154
+ cmd.option('--project-path <path>', 'Unity project path');
147
155
 
148
156
  cmd.action(async (options: CliOptions) => {
149
157
  const params = buildParams(options, properties);
@@ -294,6 +302,7 @@ function convertValue(value: unknown, propInfo: ToolProperty): unknown {
294
302
  function extractGlobalOptions(options: Record<string, unknown>): GlobalOptions {
295
303
  return {
296
304
  port: options['port'] as string | undefined,
305
+ projectPath: options['projectPath'] as string | undefined,
297
306
  };
298
307
  }
299
308
 
@@ -592,8 +601,9 @@ const LOCK_FILES = ['compiling.lock', 'domainreload.lock', 'serverstarting.lock'
592
601
  /**
593
602
  * Clean up stale lock files that may prevent CLI from connecting to Unity.
594
603
  */
595
- function cleanupLockFiles(): void {
596
- const projectRoot = findUnityProjectRoot();
604
+ function cleanupLockFiles(projectPath?: string): void {
605
+ const projectRoot =
606
+ projectPath !== undefined ? validateProjectPath(projectPath) : findUnityProjectRoot();
597
607
  if (projectRoot === null) {
598
608
  console.error('Could not find Unity project root.');
599
609
  process.exit(1);
@@ -757,22 +767,38 @@ function shouldSkipAutoSync(cmdName: string | undefined, args: string[]): boolea
757
767
  }
758
768
 
759
769
  function extractSyncGlobalOptions(args: string[]): GlobalOptions {
770
+ const options: GlobalOptions = {};
771
+
760
772
  for (let i = 0; i < args.length; i++) {
761
773
  const arg = args[i];
762
774
  if (arg === '--port' || arg === '-p') {
763
775
  const nextArg = args[i + 1];
764
776
  if (nextArg !== undefined && !nextArg.startsWith('-')) {
765
- return { port: nextArg };
777
+ options.port = nextArg;
766
778
  }
767
779
  continue;
768
780
  }
769
781
 
770
782
  if (arg.startsWith('--port=')) {
771
- return { port: arg.slice('--port='.length) };
783
+ options.port = arg.slice('--port='.length);
784
+ continue;
785
+ }
786
+
787
+ if (arg === '--project-path') {
788
+ const nextArg = args[i + 1];
789
+ if (nextArg !== undefined && !nextArg.startsWith('-')) {
790
+ options.projectPath = nextArg;
791
+ }
792
+ continue;
793
+ }
794
+
795
+ if (arg.startsWith('--project-path=')) {
796
+ options.projectPath = arg.slice('--project-path='.length);
797
+ continue;
772
798
  }
773
799
  }
774
800
 
775
- return {};
801
+ return options;
776
802
  }
777
803
 
778
804
  /**
@@ -10,13 +10,31 @@
10
10
  import { Command } from 'commander';
11
11
  import { findRunningUnityProcess, focusUnityProcess } from 'launch-unity';
12
12
  import { findUnityProjectRoot } from '../project-root.js';
13
+ import { validateProjectPath } from '../port-resolver.js';
13
14
 
14
15
  export function registerFocusWindowCommand(program: Command): void {
15
16
  program
16
17
  .command('focus-window')
17
18
  .description('Bring Unity Editor window to front using OS-level commands')
18
- .action(async () => {
19
- const projectRoot = findUnityProjectRoot();
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
+ }
20
38
  if (projectRoot === null) {
21
39
  console.error(
22
40
  JSON.stringify({
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.65.0",
2
+ "version": "0.66.1",
3
3
  "tools": [
4
4
  {
5
5
  "name": "compile",
@@ -12,7 +12,7 @@ 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';
@@ -61,6 +61,7 @@ function suppressStdinEcho(): () => void {
61
61
 
62
62
  export interface GlobalOptions {
63
63
  port?: string;
64
+ projectPath?: string;
64
65
  }
65
66
 
66
67
  function stripInternalFields(result: Record<string, unknown>): Record<string, unknown> {
@@ -162,8 +163,9 @@ function checkServerVersion(result: Record<string, unknown>): void {
162
163
  * Check if Unity is in a busy state (compiling, reloading, or server starting).
163
164
  * Throws an error with appropriate message if busy.
164
165
  */
165
- function checkUnityBusyState(): void {
166
- const projectRoot = findUnityProjectRoot();
166
+ function checkUnityBusyState(projectPath?: string): void {
167
+ const projectRoot =
168
+ projectPath !== undefined ? validateProjectPath(projectPath) : findUnityProjectRoot();
167
169
  if (projectRoot === null) {
168
170
  return;
169
171
  }
@@ -197,7 +199,7 @@ export async function executeToolCommand(
197
199
  }
198
200
  portNumber = parsed;
199
201
  }
200
- const port = await resolveUnityPort(portNumber);
202
+ const port = await resolveUnityPort(portNumber, globalOptions.projectPath);
201
203
  const compileOptions = getCompileExecutionOptions(toolName, params);
202
204
  const shouldWaitForDomainReload = compileOptions.waitForDomainReload;
203
205
  const compileRequestId = shouldWaitForDomainReload ? ensureCompileRequestId(params) : undefined;
@@ -207,7 +209,10 @@ export async function executeToolCommand(
207
209
 
208
210
  let lastError: unknown;
209
211
  let immediateResult: Record<string, unknown> | undefined;
210
- const projectRoot = findUnityProjectRoot();
212
+ const projectRoot =
213
+ globalOptions.projectPath !== undefined
214
+ ? validateProjectPath(globalOptions.projectPath)
215
+ : findUnityProjectRoot();
211
216
 
212
217
  // Monotonically-increasing flag: once true, retries cannot reset it to false.
213
218
  // The retry loop overwrites `lastError` and `immediateResult` on each attempt,
@@ -218,7 +223,7 @@ export async function executeToolCommand(
218
223
  let requestDispatched = false;
219
224
 
220
225
  for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
221
- checkUnityBusyState();
226
+ checkUnityBusyState(globalOptions.projectPath);
222
227
 
223
228
  const client = new DirectUnityClient(port);
224
229
  try {
@@ -377,14 +382,14 @@ export async function listAvailableTools(globalOptions: GlobalOptions): Promise<
377
382
  }
378
383
  portNumber = parsed;
379
384
  }
380
- const port = await resolveUnityPort(portNumber);
385
+ const port = await resolveUnityPort(portNumber, globalOptions.projectPath);
381
386
 
382
387
  const restoreStdin = suppressStdinEcho();
383
388
  const spinner = createSpinner('Connecting to Unity...');
384
389
 
385
390
  let lastError: unknown;
386
391
  for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
387
- checkUnityBusyState();
392
+ checkUnityBusyState(globalOptions.projectPath);
388
393
 
389
394
  const client = new DirectUnityClient(port);
390
395
  try {
@@ -465,14 +470,14 @@ export async function syncTools(globalOptions: GlobalOptions): Promise<void> {
465
470
  }
466
471
  portNumber = parsed;
467
472
  }
468
- const port = await resolveUnityPort(portNumber);
473
+ const port = await resolveUnityPort(portNumber, globalOptions.projectPath);
469
474
 
470
475
  const restoreStdin = suppressStdinEcho();
471
476
  const spinner = createSpinner('Connecting to Unity...');
472
477
 
473
478
  let lastError: unknown;
474
479
  for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
475
- checkUnityBusyState();
480
+ checkUnityBusyState(globalOptions.projectPath);
476
481
 
477
482
  const client = new DirectUnityClient(port);
478
483
  try {
@@ -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 async function resolveUnityPort(explicitPort?: number): Promise<number> {
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('Unity project not found. Use --port option to specify the port explicitly.');
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);
@@ -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.65.0'; // x-release-please-version
7
+ export const VERSION = '0.66.1'; // x-release-please-version