uloop-cli 0.44.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.
Files changed (39) hide show
  1. package/.prettierrc.json +28 -0
  2. package/CLAUDE.md +61 -0
  3. package/dist/cli.bundle.cjs +4761 -0
  4. package/dist/cli.bundle.cjs.map +7 -0
  5. package/eslint.config.mjs +72 -0
  6. package/jest.config.cjs +19 -0
  7. package/package.json +61 -0
  8. package/src/__tests__/cli-e2e.test.ts +349 -0
  9. package/src/__tests__/setup.ts +24 -0
  10. package/src/arg-parser.ts +128 -0
  11. package/src/cli.ts +489 -0
  12. package/src/default-tools.json +355 -0
  13. package/src/direct-unity-client.ts +125 -0
  14. package/src/execute-tool.ts +155 -0
  15. package/src/port-resolver.ts +60 -0
  16. package/src/project-root.ts +31 -0
  17. package/src/simple-framer.ts +97 -0
  18. package/src/skills/bundled-skills.ts +64 -0
  19. package/src/skills/markdown.d.ts +4 -0
  20. package/src/skills/skill-definitions/uloop-capture-gameview/SKILL.md +39 -0
  21. package/src/skills/skill-definitions/uloop-clear-console/SKILL.md +34 -0
  22. package/src/skills/skill-definitions/uloop-compile/SKILL.md +37 -0
  23. package/src/skills/skill-definitions/uloop-execute-dynamic-code/SKILL.md +79 -0
  24. package/src/skills/skill-definitions/uloop-execute-menu-item/SKILL.md +43 -0
  25. package/src/skills/skill-definitions/uloop-find-game-objects/SKILL.md +46 -0
  26. package/src/skills/skill-definitions/uloop-focus-window/SKILL.md +34 -0
  27. package/src/skills/skill-definitions/uloop-get-hierarchy/SKILL.md +44 -0
  28. package/src/skills/skill-definitions/uloop-get-logs/SKILL.md +45 -0
  29. package/src/skills/skill-definitions/uloop-get-menu-items/SKILL.md +44 -0
  30. package/src/skills/skill-definitions/uloop-get-project-info/SKILL.md +34 -0
  31. package/src/skills/skill-definitions/uloop-get-provider-details/SKILL.md +45 -0
  32. package/src/skills/skill-definitions/uloop-get-version/SKILL.md +31 -0
  33. package/src/skills/skill-definitions/uloop-run-tests/SKILL.md +43 -0
  34. package/src/skills/skill-definitions/uloop-unity-search/SKILL.md +44 -0
  35. package/src/skills/skills-command.ts +118 -0
  36. package/src/skills/skills-manager.ts +135 -0
  37. package/src/tool-cache.ts +104 -0
  38. package/src/version.ts +7 -0
  39. package/tsconfig.json +26 -0
package/src/cli.ts ADDED
@@ -0,0 +1,489 @@
1
+ /**
2
+ * CLI entry point for uloop command.
3
+ * Provides direct Unity communication without MCP server.
4
+ * Commands are dynamically registered from tools.json cache.
5
+ */
6
+
7
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
8
+ import { join, basename, dirname } from 'path';
9
+ import { homedir } from 'os';
10
+ import { spawn } from 'child_process';
11
+ import { Command } from 'commander';
12
+ import {
13
+ executeToolCommand,
14
+ listAvailableTools,
15
+ GlobalOptions,
16
+ syncTools,
17
+ } from './execute-tool.js';
18
+ import { loadToolsCache, ToolDefinition, ToolProperty } from './tool-cache.js';
19
+ import { pascalToKebabCase } from './arg-parser.js';
20
+ import { registerSkillsCommand } from './skills/skills-command.js';
21
+ import { VERSION } from './version.js';
22
+ import { findUnityProjectRoot } from './project-root.js';
23
+
24
+ interface CliOptions extends GlobalOptions {
25
+ [key: string]: unknown;
26
+ }
27
+
28
+ const BUILTIN_COMMANDS = ['list', 'sync', 'completion', 'update', 'skills'] as const;
29
+
30
+ const program = new Command();
31
+
32
+ program
33
+ .name('uloop')
34
+ .description('Unity MCP CLI - Direct communication with Unity Editor')
35
+ .version(VERSION, '-v, --version', 'Output the version number');
36
+
37
+ // --list-commands: Output command names for shell completion
38
+ program.option('--list-commands', 'List all command names (for shell completion)');
39
+
40
+ // --list-options <cmd>: Output options for a specific command (for shell completion)
41
+ program.option('--list-options <cmd>', 'List options for a command (for shell completion)');
42
+
43
+ // Built-in commands (not from tools.json)
44
+ program
45
+ .command('list')
46
+ .description('List all available tools from Unity')
47
+ .option('-p, --port <port>', 'Unity TCP port')
48
+ .action(async (options: CliOptions) => {
49
+ await runWithErrorHandling(() => listAvailableTools(options));
50
+ });
51
+
52
+ program
53
+ .command('sync')
54
+ .description('Sync tool definitions from Unity to local cache')
55
+ .option('-p, --port <port>', 'Unity TCP port')
56
+ .action(async (options: CliOptions) => {
57
+ await runWithErrorHandling(() => syncTools(options));
58
+ });
59
+
60
+ program
61
+ .command('completion')
62
+ .description('Setup shell completion')
63
+ .option('--install', 'Install completion to shell config file')
64
+ .option('--shell <type>', 'Shell type: bash, zsh, or powershell')
65
+ .action((options: { install?: boolean; shell?: string }) => {
66
+ handleCompletion(options.install ?? false, options.shell);
67
+ });
68
+
69
+ program
70
+ .command('update')
71
+ .description('Update uloop CLI to the latest version')
72
+ .action(() => {
73
+ updateCli();
74
+ });
75
+
76
+ // Register skills subcommand
77
+ registerSkillsCommand(program);
78
+
79
+ // Load tools from cache and register commands dynamically
80
+ const toolsCache = loadToolsCache();
81
+ for (const tool of toolsCache.tools) {
82
+ registerToolCommand(tool);
83
+ }
84
+
85
+ /**
86
+ * Register a tool as a CLI command dynamically.
87
+ */
88
+ function registerToolCommand(tool: ToolDefinition): void {
89
+ const cmd = program.command(tool.name).description(tool.description);
90
+
91
+ // Add options from inputSchema.properties
92
+ const properties = tool.inputSchema.properties;
93
+ for (const [propName, propInfo] of Object.entries(properties)) {
94
+ const optionStr = generateOptionString(propName, propInfo);
95
+ const description = buildOptionDescription(propInfo);
96
+ const defaultValue = propInfo.default as string | boolean | undefined;
97
+ if (defaultValue !== undefined) {
98
+ cmd.option(optionStr, description, defaultValue);
99
+ } else {
100
+ cmd.option(optionStr, description);
101
+ }
102
+ }
103
+
104
+ // Add global options
105
+ cmd.option('-p, --port <port>', 'Unity TCP port');
106
+
107
+ cmd.action(async (options: CliOptions) => {
108
+ const params = buildParams(options, properties);
109
+
110
+ // Unescape \! to ! for execute-dynamic-code
111
+ // Some shells (e.g., Claude Code's bash wrapper) escape ! as \!
112
+ if (tool.name === 'execute-dynamic-code' && params['Code']) {
113
+ const code = params['Code'] as string;
114
+ params['Code'] = code.replace(/\\!/g, '!');
115
+ }
116
+
117
+ await runWithErrorHandling(() =>
118
+ executeToolCommand(tool.name, params, extractGlobalOptions(options)),
119
+ );
120
+ });
121
+ }
122
+
123
+ /**
124
+ * Generate commander.js option string from property info.
125
+ */
126
+ function generateOptionString(propName: string, propInfo: ToolProperty): string {
127
+ const kebabName = pascalToKebabCase(propName);
128
+ const lowerType = propInfo.type.toLowerCase();
129
+
130
+ if (lowerType === 'boolean') {
131
+ return `--${kebabName}`;
132
+ }
133
+
134
+ return `--${kebabName} <value>`;
135
+ }
136
+
137
+ /**
138
+ * Build option description with enum values if present.
139
+ */
140
+ function buildOptionDescription(propInfo: ToolProperty): string {
141
+ let desc = propInfo.description || '';
142
+ if (propInfo.enum && propInfo.enum.length > 0) {
143
+ desc += ` (${propInfo.enum.join(', ')})`;
144
+ }
145
+ return desc;
146
+ }
147
+
148
+ /**
149
+ * Build parameters from CLI options.
150
+ */
151
+ function buildParams(
152
+ options: Record<string, unknown>,
153
+ properties: Record<string, ToolProperty>,
154
+ ): Record<string, unknown> {
155
+ const params: Record<string, unknown> = {};
156
+
157
+ for (const propName of Object.keys(properties)) {
158
+ const camelName = propName.charAt(0).toLowerCase() + propName.slice(1);
159
+ const value = options[camelName];
160
+
161
+ if (value !== undefined) {
162
+ const propInfo = properties[propName];
163
+ params[propName] = convertValue(value, propInfo);
164
+ }
165
+ }
166
+
167
+ return params;
168
+ }
169
+
170
+ /**
171
+ * Convert CLI value to appropriate type based on property info.
172
+ */
173
+ function convertValue(value: unknown, propInfo: ToolProperty): unknown {
174
+ const lowerType = propInfo.type.toLowerCase();
175
+
176
+ if (lowerType === 'array' && typeof value === 'string') {
177
+ return value.split(',').map((s) => s.trim());
178
+ }
179
+
180
+ if (lowerType === 'integer' && typeof value === 'string') {
181
+ const parsed = parseInt(value, 10);
182
+ if (isNaN(parsed)) {
183
+ throw new Error(`Invalid integer value: ${value}`);
184
+ }
185
+ return parsed;
186
+ }
187
+
188
+ if (lowerType === 'number' && typeof value === 'string') {
189
+ const parsed = parseFloat(value);
190
+ if (isNaN(parsed)) {
191
+ throw new Error(`Invalid number value: ${value}`);
192
+ }
193
+ return parsed;
194
+ }
195
+
196
+ return value;
197
+ }
198
+
199
+ function extractGlobalOptions(options: Record<string, unknown>): GlobalOptions {
200
+ return {
201
+ port: options['port'] as string | undefined,
202
+ };
203
+ }
204
+
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
+ async function runWithErrorHandling(fn: () => Promise<void>): Promise<void> {
215
+ try {
216
+ await fn();
217
+ } catch (error) {
218
+ const message = error instanceof Error ? error.message : String(error);
219
+
220
+ 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
+ }
228
+ process.exit(1);
229
+ }
230
+
231
+ console.error(`\x1b[31mError: ${message}\x1b[0m`);
232
+ process.exit(1);
233
+ }
234
+ }
235
+
236
+ /**
237
+ * Detect shell type from environment.
238
+ */
239
+ function detectShell(): 'bash' | 'zsh' | 'powershell' | null {
240
+ // Check $SHELL first (works for bash/zsh including MINGW64)
241
+ const shell = process.env['SHELL'] || '';
242
+ const shellName = basename(shell).replace(/\.exe$/i, ''); // Remove .exe for Windows
243
+ if (shellName === 'zsh') {
244
+ return 'zsh';
245
+ }
246
+ if (shellName === 'bash') {
247
+ return 'bash';
248
+ }
249
+
250
+ // Check for PowerShell (only if $SHELL is not set)
251
+ if (process.env['PSModulePath']) {
252
+ return 'powershell';
253
+ }
254
+
255
+ return null;
256
+ }
257
+
258
+ /**
259
+ * Get shell config file path.
260
+ */
261
+ function getShellConfigPath(shell: 'bash' | 'zsh' | 'powershell'): string {
262
+ const home = homedir();
263
+ if (shell === 'zsh') {
264
+ return join(home, '.zshrc');
265
+ }
266
+ if (shell === 'powershell') {
267
+ // PowerShell profile path
268
+ return join(home, 'Documents', 'WindowsPowerShell', 'Microsoft.PowerShell_profile.ps1');
269
+ }
270
+ return join(home, '.bashrc');
271
+ }
272
+
273
+ /**
274
+ * Get completion script for a shell.
275
+ */
276
+ function getCompletionScript(shell: 'bash' | 'zsh' | 'powershell'): string {
277
+ if (shell === 'bash') {
278
+ return `# uloop bash completion
279
+ _uloop_completions() {
280
+ local cur="\${COMP_WORDS[COMP_CWORD]}"
281
+ local cmd="\${COMP_WORDS[1]}"
282
+
283
+ if [[ \${COMP_CWORD} -eq 1 ]]; then
284
+ COMPREPLY=($(compgen -W "$(uloop --list-commands 2>/dev/null)" -- "\${cur}"))
285
+ elif [[ \${COMP_CWORD} -ge 2 ]]; then
286
+ COMPREPLY=($(compgen -W "$(uloop --list-options \${cmd} 2>/dev/null)" -- "\${cur}"))
287
+ fi
288
+ }
289
+ complete -F _uloop_completions uloop`;
290
+ }
291
+
292
+ if (shell === 'powershell') {
293
+ return `# uloop PowerShell completion
294
+ Register-ArgumentCompleter -Native -CommandName uloop -ScriptBlock {
295
+ param($wordToComplete, $commandAst, $cursorPosition)
296
+ $commands = $commandAst.CommandElements
297
+ if ($commands.Count -eq 1) {
298
+ uloop --list-commands 2>$null | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object {
299
+ [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_)
300
+ }
301
+ } elseif ($commands.Count -ge 2) {
302
+ $cmd = $commands[1].ToString()
303
+ uloop --list-options $cmd 2>$null | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object {
304
+ [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_)
305
+ }
306
+ }
307
+ }`;
308
+ }
309
+
310
+ /* eslint-disable no-useless-escape */
311
+ return `# uloop zsh completion
312
+ _uloop() {
313
+ local -a commands
314
+ local -a options
315
+ local -a used_options
316
+
317
+ if (( CURRENT == 2 )); then
318
+ commands=(\${(f)"$(uloop --list-commands 2>/dev/null)"})
319
+ _describe 'command' commands
320
+ elif (( CURRENT >= 3 )); then
321
+ options=(\${(f)"$(uloop --list-options \${words[2]} 2>/dev/null)"})
322
+ used_options=(\${words:2})
323
+ for opt in \${used_options}; do
324
+ options=(\${options:#\$opt})
325
+ done
326
+ _describe 'option' options
327
+ fi
328
+ }
329
+ compdef _uloop uloop`;
330
+ /* eslint-enable no-useless-escape */
331
+ }
332
+
333
+ /**
334
+ * Update uloop CLI to the latest version using npm.
335
+ */
336
+ function updateCli(): void {
337
+ // eslint-disable-next-line no-console
338
+ console.log('Updating uloop-cli to the latest version...');
339
+
340
+ const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
341
+ const child = spawn(npmCommand, ['install', '-g', 'uloop-cli@latest'], {
342
+ stdio: 'inherit',
343
+ shell: true,
344
+ });
345
+
346
+ child.on('close', (code) => {
347
+ if (code === 0) {
348
+ // eslint-disable-next-line no-console
349
+ console.log('\n✅ uloop-cli has been updated successfully!');
350
+ // eslint-disable-next-line no-console
351
+ console.log('Run "uloop --version" to check the new version.');
352
+ } else {
353
+ // eslint-disable-next-line no-console
354
+ console.error(`\n❌ Update failed with exit code ${code}`);
355
+ process.exit(1);
356
+ }
357
+ });
358
+
359
+ child.on('error', (err) => {
360
+ // eslint-disable-next-line no-console
361
+ console.error(`❌ Failed to run npm: ${err.message}`);
362
+ process.exit(1);
363
+ });
364
+ }
365
+
366
+ /**
367
+ * Handle completion command.
368
+ */
369
+ function handleCompletion(install: boolean, shellOverride?: string): void {
370
+ let shell: 'bash' | 'zsh' | 'powershell' | null;
371
+
372
+ if (shellOverride) {
373
+ const normalized = shellOverride.toLowerCase();
374
+ if (normalized === 'bash' || normalized === 'zsh' || normalized === 'powershell') {
375
+ shell = normalized;
376
+ } else {
377
+ console.error(`Unknown shell: ${shellOverride}. Supported: bash, zsh, powershell`);
378
+ process.exit(1);
379
+ }
380
+ } else {
381
+ shell = detectShell();
382
+ }
383
+
384
+ if (!shell) {
385
+ console.error('Could not detect shell. Use --shell option: bash, zsh, or powershell');
386
+ process.exit(1);
387
+ }
388
+
389
+ const script = getCompletionScript(shell);
390
+
391
+ if (!install) {
392
+ console.log(script);
393
+ return;
394
+ }
395
+
396
+ // Install to shell config file
397
+ const configPath = getShellConfigPath(shell);
398
+
399
+ // PowerShell profile directory may not exist on fresh installations
400
+ const configDir = dirname(configPath);
401
+ if (!existsSync(configDir)) {
402
+ mkdirSync(configDir, { recursive: true });
403
+ }
404
+
405
+ // Remove existing uloop completion and add new one
406
+ let content = '';
407
+ if (existsSync(configPath)) {
408
+ content = readFileSync(configPath, 'utf-8');
409
+ // Remove existing uloop completion block using markers
410
+ content = content.replace(
411
+ /\n?# >>> uloop completion >>>[\s\S]*?# <<< uloop completion <<<\n?/g,
412
+ '',
413
+ );
414
+ }
415
+
416
+ // Add new completion with markers
417
+ const startMarker = '# >>> uloop completion >>>';
418
+ const endMarker = '# <<< uloop completion <<<';
419
+
420
+ if (shell === 'powershell') {
421
+ const lineToAdd = `\n${startMarker}\n${script}\n${endMarker}\n`;
422
+ writeFileSync(configPath, content + lineToAdd, 'utf-8');
423
+ } else {
424
+ // Include --shell option to ensure correct shell detection
425
+ const evalLine = `eval "$(uloop completion --shell ${shell})"`;
426
+ const lineToAdd = `\n${startMarker}\n${evalLine}\n${endMarker}\n`;
427
+ writeFileSync(configPath, content + lineToAdd, 'utf-8');
428
+ }
429
+
430
+ console.log(`Completion installed to ${configPath}`);
431
+ if (shell === 'powershell') {
432
+ console.log('Restart PowerShell to enable completion.');
433
+ } else {
434
+ console.log(`Run 'source ${configPath}' or restart your shell to enable completion.`);
435
+ }
436
+ }
437
+
438
+ /**
439
+ * Handle --list-commands and --list-options before parsing.
440
+ */
441
+ function handleCompletionOptions(): boolean {
442
+ const args = process.argv.slice(2);
443
+
444
+ if (args.includes('--list-commands')) {
445
+ const tools = loadToolsCache();
446
+ const allCommands = [...BUILTIN_COMMANDS, ...tools.tools.map((t) => t.name)];
447
+ console.log(allCommands.join('\n'));
448
+ return true;
449
+ }
450
+
451
+ const listOptionsIdx = args.indexOf('--list-options');
452
+ if (listOptionsIdx !== -1 && args[listOptionsIdx + 1]) {
453
+ const cmdName = args[listOptionsIdx + 1];
454
+ listOptionsForCommand(cmdName);
455
+ return true;
456
+ }
457
+
458
+ return false;
459
+ }
460
+
461
+ /**
462
+ * List options for a specific command.
463
+ */
464
+ function listOptionsForCommand(cmdName: string): void {
465
+ // Built-in commands have no tool-specific options
466
+ if (BUILTIN_COMMANDS.includes(cmdName as (typeof BUILTIN_COMMANDS)[number])) {
467
+ return;
468
+ }
469
+
470
+ // Tool commands - only output tool-specific options
471
+ const tools = loadToolsCache();
472
+ const tool = tools.tools.find((t) => t.name === cmdName);
473
+ if (!tool) {
474
+ return;
475
+ }
476
+
477
+ const options: string[] = [];
478
+ for (const propName of Object.keys(tool.inputSchema.properties)) {
479
+ const kebabName = pascalToKebabCase(propName);
480
+ options.push(`--${kebabName}`);
481
+ }
482
+
483
+ console.log(options.join('\n'));
484
+ }
485
+
486
+ // Handle completion options first (before commander parsing)
487
+ if (!handleCompletionOptions()) {
488
+ program.parse();
489
+ }