uloop-cli 0.68.2 → 0.68.3

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.68.2",
3
+ "version": "0.68.3",
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",
@@ -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
 
package/src/cli.ts CHANGED
@@ -12,7 +12,7 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync } from '
12
12
  import { join, basename, dirname } from 'path';
13
13
  import { homedir } from 'os';
14
14
  import { spawn } from 'child_process';
15
- import { Command } from 'commander';
15
+ import { Command, Option } from 'commander';
16
16
  import {
17
17
  executeToolCommand,
18
18
  listAvailableTools,
@@ -24,6 +24,7 @@ import {
24
24
  loadToolsCache,
25
25
  hasCacheFile,
26
26
  getDefaultTools,
27
+ getDefaultToolNames,
27
28
  ToolDefinition,
28
29
  ToolProperty,
29
30
  getCachedServerVersion,
@@ -45,6 +46,16 @@ const FOCUS_WINDOW_COMMAND = 'focus-window' as const;
45
46
  const LAUNCH_COMMAND = 'launch' as const;
46
47
  const UPDATE_COMMAND = 'update' as const;
47
48
 
49
+ const HELP_GROUP_BUILTIN_TOOLS = 'Built-in Tools:' as const;
50
+ const HELP_GROUP_THIRD_PARTY_TOOLS = 'Third-party Tools:' as const;
51
+ const HELP_GROUP_CLI_COMMANDS = 'CLI Commands:' as const;
52
+
53
+ const HELP_GROUP_ORDER = [
54
+ HELP_GROUP_CLI_COMMANDS,
55
+ HELP_GROUP_BUILTIN_TOOLS,
56
+ HELP_GROUP_THIRD_PARTY_TOOLS,
57
+ ] as const;
58
+
48
59
  // commander.js built-in flags that exit immediately without needing Unity
49
60
  const NO_SYNC_FLAGS = ['-v', '--version', '-h', '--help'] as const;
50
61
 
@@ -66,7 +77,44 @@ program
66
77
  .description('Unity MCP CLI - Direct communication with Unity Editor')
67
78
  .version(VERSION, '-v, --version', 'Output the version number')
68
79
  .showHelpAfterError('(run with -h for available options)')
69
- .configureHelp({ sortSubcommands: true });
80
+ .configureHelp({
81
+ sortSubcommands: true,
82
+ // commander.js default groupItems determines group display order by registration order,
83
+ // but CLI commands are registered at module level (before tools), so the default order
84
+ // would be wrong. We re-implement to enforce HELP_GROUP_ORDER.
85
+ groupItems<T extends Command | Option>(
86
+ unsortedItems: T[],
87
+ visibleItems: T[],
88
+ getGroup: (item: T) => string,
89
+ ): Map<string, T[]> {
90
+ const groupMap = new Map<string, T[]>();
91
+ for (const item of unsortedItems) {
92
+ const group: string = getGroup(item);
93
+ if (!groupMap.has(group)) {
94
+ groupMap.set(group, []);
95
+ }
96
+ }
97
+ for (const item of visibleItems) {
98
+ const group: string = getGroup(item);
99
+ if (!groupMap.has(group)) {
100
+ groupMap.set(group, []);
101
+ }
102
+ groupMap.get(group)!.push(item);
103
+ }
104
+ const ordered = new Map<string, T[]>();
105
+ for (const key of HELP_GROUP_ORDER) {
106
+ const items: T[] | undefined = groupMap.get(key);
107
+ if (items !== undefined) {
108
+ ordered.set(key, items);
109
+ groupMap.delete(key);
110
+ }
111
+ }
112
+ for (const [key, value] of groupMap) {
113
+ ordered.set(key, value);
114
+ }
115
+ return ordered;
116
+ },
117
+ });
70
118
 
71
119
  // --list-commands: Output command names for shell completion
72
120
  program.option('--list-commands', 'List all command names (for shell completion)');
@@ -74,6 +122,12 @@ program.option('--list-commands', 'List all command names (for shell completion)
74
122
  // --list-options <cmd>: Output options for a specific command (for shell completion)
75
123
  program.option('--list-options <cmd>', 'List options for a command (for shell completion)');
76
124
 
125
+ // Set default help group for CLI commands registered at module level
126
+ program.commandsGroup(HELP_GROUP_CLI_COMMANDS);
127
+ // Eagerly initialize the implicit help command so it inherits the CLI Commands group.
128
+ // Without this, the lazy-created help command skips _initCommandGroup and falls into "Commands:" group.
129
+ program.helpCommand(true);
130
+
77
131
  // Built-in commands (not from tools.json)
78
132
  program
79
133
  .command('list')
@@ -132,12 +186,13 @@ registerLaunchCommand(program);
132
186
  /**
133
187
  * Register a tool as a CLI command dynamically.
134
188
  */
135
- function registerToolCommand(tool: ToolDefinition): void {
189
+ function registerToolCommand(tool: ToolDefinition, helpGroup: string): void {
136
190
  // Skip if already registered as a built-in command
137
191
  if (BUILTIN_COMMANDS.includes(tool.name as (typeof BUILTIN_COMMANDS)[number])) {
138
192
  return;
139
193
  }
140
- const cmd = program.command(tool.name).description(tool.description);
194
+ const firstLine: string = tool.description.split('\n')[0];
195
+ const cmd = program.command(tool.name).description(firstLine).helpGroup(helpGroup);
141
196
 
142
197
  // Add options from inputSchema.properties
143
198
  const properties = tool.inputSchema.properties;
@@ -304,6 +359,10 @@ function convertValue(value: unknown, propInfo: ToolProperty): unknown {
304
359
  return value;
305
360
  }
306
361
 
362
+ function getToolHelpGroup(toolName: string, defaultToolNames: ReadonlySet<string>): string {
363
+ return defaultToolNames.has(toolName) ? HELP_GROUP_BUILTIN_TOOLS : HELP_GROUP_THIRD_PARTY_TOOLS;
364
+ }
365
+
307
366
  function extractGlobalOptions(options: Record<string, unknown>): GlobalOptions {
308
367
  return {
309
368
  port: options['port'] as string | undefined,
@@ -788,6 +847,26 @@ function shouldSkipAutoSync(cmdName: string | undefined, args: string[]): boolea
788
847
  return args.some((arg) => (NO_SYNC_FLAGS as readonly string[]).includes(arg));
789
848
  }
790
849
 
850
+ // Options that consume the next argument as a value
851
+ const OPTIONS_WITH_VALUE: ReadonlySet<string> = new Set(['--port', '-p', '--project-path']);
852
+
853
+ /**
854
+ * Find the first non-option argument that is not a value of a known option.
855
+ */
856
+ function findCommandName(args: readonly string[]): string | undefined {
857
+ for (let i = 0; i < args.length; i++) {
858
+ const arg: string = args[i];
859
+ if (arg.startsWith('-')) {
860
+ if (OPTIONS_WITH_VALUE.has(arg)) {
861
+ i++; // skip the next arg (option value)
862
+ }
863
+ continue;
864
+ }
865
+ return arg;
866
+ }
867
+ return undefined;
868
+ }
869
+
791
870
  function extractSyncGlobalOptions(args: string[]): GlobalOptions {
792
871
  const options: GlobalOptions = {};
793
872
 
@@ -832,8 +911,8 @@ async function main(): Promise<void> {
832
911
  }
833
912
 
834
913
  const args = process.argv.slice(2);
835
- const cmdName = args.find((arg) => !arg.startsWith('-'));
836
914
  const syncGlobalOptions = extractSyncGlobalOptions(args);
915
+ const cmdName = findCommandName(args);
837
916
 
838
917
  // No command name = no Unity operation; skip project detection
839
918
  const NO_PROJECT_COMMANDS = [UPDATE_COMMAND, 'completion'] as const;
@@ -841,20 +920,24 @@ async function main(): Promise<void> {
841
920
  cmdName === undefined || (NO_PROJECT_COMMANDS as readonly string[]).includes(cmdName);
842
921
 
843
922
  if (skipProjectDetection) {
844
- const defaultTools = getDefaultTools();
923
+ const defaultToolNames: ReadonlySet<string> = getDefaultToolNames();
845
924
  // Only filter disabled tools for top-level help (uloop --help); subcommand help
846
925
  // (e.g. uloop completion --help) does not list dynamic tools, so scanning is unnecessary
847
926
  const isTopLevelHelp: boolean =
848
927
  cmdName === undefined && (args.includes('-h') || args.includes('--help'));
849
928
  const shouldFilter: boolean = syncGlobalOptions.projectPath !== undefined || isTopLevelHelp;
929
+ // Use cache to include third-party tools in help output; falls back to defaults when no cache exists
930
+ const sourceTools: ToolDefinition[] = shouldFilter
931
+ ? loadToolsCache(syncGlobalOptions.projectPath).tools
932
+ : getDefaultTools().tools;
850
933
  const tools: ToolDefinition[] = shouldFilter
851
- ? filterEnabledTools(defaultTools.tools, syncGlobalOptions.projectPath)
852
- : defaultTools.tools;
934
+ ? filterEnabledTools(sourceTools, syncGlobalOptions.projectPath)
935
+ : sourceTools;
853
936
  if (!shouldFilter || isToolEnabled(FOCUS_WINDOW_COMMAND, syncGlobalOptions.projectPath)) {
854
- registerFocusWindowCommand(program);
937
+ registerFocusWindowCommand(program, HELP_GROUP_BUILTIN_TOOLS);
855
938
  }
856
939
  for (const tool of tools) {
857
- registerToolCommand(tool);
940
+ registerToolCommand(tool, getToolHelpGroup(tool.name, defaultToolNames));
858
941
  }
859
942
  program.parse();
860
943
  return;
@@ -887,11 +970,12 @@ async function main(): Promise<void> {
887
970
  // Register tool commands from cache (after potential auto-sync)
888
971
  const toolsCache = loadToolsCache();
889
972
  const projectPath: string | undefined = syncGlobalOptions.projectPath;
973
+ const defaultToolNames: ReadonlySet<string> = getDefaultToolNames();
890
974
  if (isToolEnabled(FOCUS_WINDOW_COMMAND, projectPath)) {
891
- registerFocusWindowCommand(program);
975
+ registerFocusWindowCommand(program, HELP_GROUP_BUILTIN_TOOLS);
892
976
  }
893
977
  for (const tool of filterEnabledTools(toolsCache.tools, projectPath)) {
894
- registerToolCommand(tool);
978
+ registerToolCommand(tool, getToolHelpGroup(tool.name, defaultToolNames));
895
979
  }
896
980
 
897
981
  if (cmdName && !commandExists(cmdName, projectPath)) {
@@ -906,7 +990,7 @@ async function main(): Promise<void> {
906
990
  const newCache = loadToolsCache();
907
991
  const tool = filterEnabledTools(newCache.tools, projectPath).find((t) => t.name === cmdName);
908
992
  if (tool) {
909
- registerToolCommand(tool);
993
+ registerToolCommand(tool, getToolHelpGroup(tool.name, defaultToolNames));
910
994
  console.log(`\x1b[32m✓ Found '${cmdName}' after sync.\x1b[0m\n`);
911
995
  } else {
912
996
  console.error(`\x1b[31mError: Command '${cmdName}' not found even after sync.\x1b[0m`);
@@ -12,66 +12,71 @@ import { findRunningUnityProcess, focusUnityProcess } from 'launch-unity';
12
12
  import { findUnityProjectRoot } from '../project-root.js';
13
13
  import { validateProjectPath } from '../port-resolver.js';
14
14
 
15
- export function registerFocusWindowCommand(program: Command): void {
16
- program
15
+ export function registerFocusWindowCommand(program: Command, helpGroup?: string): void {
16
+ const cmd = program
17
17
  .command('focus-window')
18
18
  .description('Bring Unity Editor window to front using OS-level commands')
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
- }
38
- if (projectRoot === null) {
39
- console.error(
40
- JSON.stringify({
41
- Success: false,
42
- Message: 'Unity project not found',
43
- }),
44
- );
45
- process.exit(1);
46
- }
19
+ .option('--project-path <path>', 'Unity project path');
47
20
 
48
- const runningProcess = await findRunningUnityProcess(projectRoot);
49
- if (!runningProcess) {
50
- console.error(
51
- JSON.stringify({
52
- Success: false,
53
- Message: 'No running Unity process found for this project',
54
- }),
55
- );
56
- process.exit(1);
57
- }
21
+ if (helpGroup !== undefined) {
22
+ cmd.helpGroup(helpGroup);
23
+ }
58
24
 
25
+ cmd.action(async (options: { projectPath?: string }) => {
26
+ let projectRoot: string | null;
27
+ if (options.projectPath !== undefined) {
59
28
  try {
60
- await focusUnityProcess(runningProcess.pid);
61
- console.log(
62
- JSON.stringify({
63
- Success: true,
64
- Message: `Unity Editor window focused (PID: ${runningProcess.pid})`,
65
- }),
66
- );
29
+ projectRoot = validateProjectPath(options.projectPath);
67
30
  } catch (error) {
68
31
  console.error(
69
32
  JSON.stringify({
70
33
  Success: false,
71
- Message: `Failed to focus Unity window: ${error instanceof Error ? error.message : String(error)}`,
34
+ Message: error instanceof Error ? error.message : String(error),
72
35
  }),
73
36
  );
74
37
  process.exit(1);
38
+ return;
75
39
  }
76
- });
40
+ } else {
41
+ projectRoot = findUnityProjectRoot();
42
+ }
43
+ if (projectRoot === null) {
44
+ console.error(
45
+ JSON.stringify({
46
+ Success: false,
47
+ Message: 'Unity project not found',
48
+ }),
49
+ );
50
+ process.exit(1);
51
+ }
52
+
53
+ const runningProcess = await findRunningUnityProcess(projectRoot);
54
+ if (!runningProcess) {
55
+ console.error(
56
+ JSON.stringify({
57
+ Success: false,
58
+ Message: 'No running Unity process found for this project',
59
+ }),
60
+ );
61
+ process.exit(1);
62
+ }
63
+
64
+ try {
65
+ await focusUnityProcess(runningProcess.pid);
66
+ console.log(
67
+ JSON.stringify({
68
+ Success: true,
69
+ Message: `Unity Editor window focused (PID: ${runningProcess.pid})`,
70
+ }),
71
+ );
72
+ } catch (error) {
73
+ console.error(
74
+ JSON.stringify({
75
+ Success: false,
76
+ Message: `Failed to focus Unity window: ${error instanceof Error ? error.message : String(error)}`,
77
+ }),
78
+ );
79
+ process.exit(1);
80
+ }
81
+ });
77
82
  }
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.68.2",
2
+ "version": "0.68.3",
3
3
  "tools": [
4
4
  {
5
5
  "name": "compile",
@@ -239,6 +239,34 @@
239
239
  }
240
240
  }
241
241
  },
242
+ {
243
+ "name": "get-unity-search-providers",
244
+ "description": "Get detailed information about Unity Search providers including display names, descriptions, active status, and capabilities",
245
+ "inputSchema": {
246
+ "type": "object",
247
+ "properties": {
248
+ "ProviderId": {
249
+ "type": "string",
250
+ "description": "Specific provider ID to get details for (empty = all providers). Examples: 'asset', 'scene', 'menu', 'settings'"
251
+ },
252
+ "ActiveOnly": {
253
+ "type": "boolean",
254
+ "description": "Whether to include only active providers",
255
+ "default": false
256
+ },
257
+ "SortByPriority": {
258
+ "type": "boolean",
259
+ "description": "Sort providers by priority (lower number = higher priority)",
260
+ "default": true
261
+ },
262
+ "IncludeDescriptions": {
263
+ "type": "boolean",
264
+ "description": "Include detailed descriptions for each provider",
265
+ "default": true
266
+ }
267
+ }
268
+ }
269
+ },
242
270
  {
243
271
  "name": "get-menu-items",
244
272
  "description": "Retrieve Unity MenuItems",
@@ -6,5 +6,5 @@
6
6
  */
7
7
  export const DEPRECATED_SKILLS: string[] = [
8
8
  'uloop-capture-window', // renamed to uloop-screenshot in v0.54.0
9
- 'uloop-get-provider-details', // internal development-only tool, not for end users
9
+ 'uloop-get-provider-details', // renamed to uloop-get-unity-search-providers
10
10
  ];
package/src/tool-cache.ts CHANGED
@@ -41,7 +41,10 @@ export interface ToolsCache {
41
41
  const CACHE_DIR = '.uloop';
42
42
  const CACHE_FILE = 'tools.json';
43
43
 
44
- function getCacheDir(): string {
44
+ function getCacheDir(projectPath?: string): string {
45
+ if (projectPath !== undefined) {
46
+ return join(projectPath, CACHE_DIR);
47
+ }
45
48
  const projectRoot = findUnityProjectRoot();
46
49
  if (projectRoot === null) {
47
50
  return join(process.cwd(), CACHE_DIR);
@@ -49,8 +52,8 @@ function getCacheDir(): string {
49
52
  return join(projectRoot, CACHE_DIR);
50
53
  }
51
54
 
52
- function getCachePath(): string {
53
- return join(getCacheDir(), CACHE_FILE);
55
+ function getCachePath(projectPath?: string): string {
56
+ return join(getCacheDir(projectPath), CACHE_FILE);
54
57
  }
55
58
 
56
59
  /**
@@ -62,9 +65,10 @@ export function getDefaultTools(): ToolsCache {
62
65
 
63
66
  /**
64
67
  * Load tools from cache file, falling back to default tools if cache doesn't exist.
68
+ * When projectPath is specified, reads cache from that project directory.
65
69
  */
66
- export function loadToolsCache(): ToolsCache {
67
- const cachePath = getCachePath();
70
+ export function loadToolsCache(projectPath?: string): ToolsCache {
71
+ const cachePath = getCachePath(projectPath);
68
72
 
69
73
  if (existsSync(cachePath)) {
70
74
  try {
@@ -107,6 +111,15 @@ export function getCacheFilePath(): string {
107
111
  return getCachePath();
108
112
  }
109
113
 
114
+ /**
115
+ * Get the set of default tool names bundled with npm package.
116
+ * Used to distinguish built-in tools from third-party tools.
117
+ */
118
+ export function getDefaultToolNames(): ReadonlySet<string> {
119
+ const defaultTools: ToolsCache = getDefaultTools();
120
+ return new Set(defaultTools.tools.map((tool: ToolDefinition) => tool.name));
121
+ }
122
+
110
123
  /**
111
124
  * Get the Unity server version from cache file.
112
125
  * Returns undefined if cache doesn't exist, is corrupted, or serverVersion is missing.
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.68.2'; // x-release-please-version
7
+ export const VERSION = '0.68.3'; // x-release-please-version