uloop-cli 0.68.2 → 0.69.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/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.2",
3
+ "version": "0.69.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",
@@ -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
  },
@@ -460,16 +460,16 @@ describe('CLI E2E Tests (requires running Unity)', () => {
460
460
  });
461
461
  });
462
462
 
463
- describe('get-provider-details', () => {
463
+ describe('get-unity-search-providers', () => {
464
464
  it('should retrieve search providers', () => {
465
- const result = runCliJson<{ Providers: unknown[] }>('get-provider-details');
465
+ const result = runCliJson<{ Providers: unknown[] }>('get-unity-search-providers');
466
466
 
467
467
  expect(Array.isArray(result.Providers)).toBe(true);
468
468
  });
469
469
 
470
470
  it('should support --include-descriptions false to exclude descriptions', () => {
471
471
  const result = runCliJson<{ Providers: unknown[] }>(
472
- 'get-provider-details --include-descriptions false',
472
+ 'get-unity-search-providers --include-descriptions false',
473
473
  );
474
474
 
475
475
  expect(Array.isArray(result.Providers)).toBe(true);
@@ -477,7 +477,7 @@ describe('CLI E2E Tests (requires running Unity)', () => {
477
477
 
478
478
  it('should support --sort-by-priority false to disable priority sorting', () => {
479
479
  const result = runCliJson<{ Providers: unknown[] }>(
480
- 'get-provider-details --sort-by-priority false',
480
+ 'get-unity-search-providers --sort-by-priority false',
481
481
  );
482
482
 
483
483
  expect(Array.isArray(result.Providers)).toBe(true);
@@ -501,6 +501,48 @@ describe('CLI E2E Tests (requires running Unity)', () => {
501
501
  expect(stdout).toContain('--force-recompile');
502
502
  });
503
503
 
504
+ it('should display grouped help with category headings', () => {
505
+ const { stdout, exitCode } = runCli('--help');
506
+
507
+ expect(exitCode).toBe(0);
508
+ expect(stdout).toContain('Built-in Tools:');
509
+ expect(stdout).toContain('CLI Commands:');
510
+ // CLI Commands should appear before Built-in Tools
511
+ const cliIndex: number = stdout.indexOf('CLI Commands:');
512
+ const builtInIndex: number = stdout.indexOf('Built-in Tools:');
513
+ expect(cliIndex).toBeLessThan(builtInIndex);
514
+ });
515
+
516
+ it('should display Third-party Tools section when cache contains third-party tools', () => {
517
+ const { stdout, exitCode } = runCli('--help');
518
+
519
+ expect(exitCode).toBe(0);
520
+ // hello-world is a third-party tool present in the local cache but not in default-tools.json
521
+ if (stdout.includes('hello-world')) {
522
+ expect(stdout).toContain('Third-party Tools:');
523
+ const builtInIndex: number = stdout.indexOf('Built-in Tools:');
524
+ const thirdPartyIndex: number = stdout.indexOf('Third-party Tools:');
525
+ expect(builtInIndex).toBeLessThan(thirdPartyIndex);
526
+ }
527
+ });
528
+
529
+ it('should resolve tool cache via --project-path', () => {
530
+ const withProjectPath = runCli(`--help --project-path "${UNITY_PROJECT_ROOT}"`);
531
+ const withoutProjectPath = runCli('--help');
532
+
533
+ expect(withProjectPath.exitCode).toBe(0);
534
+ expect(withoutProjectPath.exitCode).toBe(0);
535
+
536
+ // Both should show the same category headings
537
+ expect(withProjectPath.stdout).toContain('Built-in Tools:');
538
+ expect(withProjectPath.stdout).toContain('CLI Commands:');
539
+
540
+ // Third-party tools visible in normal help should also appear with --project-path
541
+ if (withoutProjectPath.stdout.includes('Third-party Tools:')) {
542
+ expect(withProjectPath.stdout).toContain('Third-party Tools:');
543
+ }
544
+ });
545
+
504
546
  it('should display boolean options with value format in get-hierarchy help', () => {
505
547
  const { stdout, exitCode } = runCli('get-hierarchy --help');
506
548
 
@@ -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,107 @@
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('re-throws non-Method-not-found errors', async () => {
69
+ const client = createMockClient(undefined, new Error('Unity error: some other error'));
70
+
71
+ await expect(validateConnectedProject(client, tempDirA)).rejects.toThrow(
72
+ 'Unity error: some other error',
73
+ );
74
+ });
75
+
76
+ it('logs warning and continues when DataPath is missing from response', async () => {
77
+ const client = createMockClient({});
78
+ const stderrSpy = jest.spyOn(console, 'error').mockImplementation();
79
+
80
+ await expect(validateConnectedProject(client, tempDirA)).resolves.toBeUndefined();
81
+
82
+ expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining('invalid get-version response'));
83
+ });
84
+
85
+ it('logs warning and continues when DataPath is empty string', async () => {
86
+ const client = createMockClient({ DataPath: '' });
87
+ const stderrSpy = jest.spyOn(console, 'error').mockImplementation();
88
+
89
+ await expect(validateConnectedProject(client, tempDirA)).resolves.toBeUndefined();
90
+
91
+ expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining('invalid get-version response'));
92
+ });
93
+ });
94
+
95
+ describe('ProjectMismatchError', () => {
96
+ it('is not a transport disconnect error', () => {
97
+ const error = new ProjectMismatchError('/project/a', '/project/b');
98
+ expect(isTransportDisconnectError(error)).toBe(false);
99
+ });
100
+
101
+ it('stores expected and connected project roots', () => {
102
+ const error = new ProjectMismatchError('/expected/path', '/connected/path');
103
+ expect(error.expectedProjectRoot).toBe('/expected/path');
104
+ expect(error.connectedProjectRoot).toBe('/connected/path');
105
+ expect(error.message).toBe('PROJECT_MISMATCH');
106
+ });
107
+ });
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
- }