uloop-cli 0.66.1 → 0.67.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.66.1",
3
+ "version": "0.67.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",
@@ -51,7 +51,7 @@
51
51
  "@types/node": "25.3.0",
52
52
  "@types/semver": "7.7.1",
53
53
  "esbuild": "0.27.3",
54
- "eslint": "10.0.1",
54
+ "eslint": "10.0.2",
55
55
  "eslint-config-prettier": "10.1.8",
56
56
  "eslint-plugin-prettier": "5.5.5",
57
57
  "eslint-plugin-security": "4.0.0",
@@ -60,7 +60,7 @@
60
60
  "ts-jest": "29.4.6",
61
61
  "tsx": "4.21.0",
62
62
  "typescript": "5.9.3",
63
- "typescript-eslint": "8.56.0"
63
+ "typescript-eslint": "8.56.1"
64
64
  },
65
65
  "overrides": {
66
66
  "minimatch": "10.2.2"
@@ -6,51 +6,36 @@ import {
6
6
  } from '../port-resolver.js';
7
7
 
8
8
  describe('resolvePortFromUnitySettings', () => {
9
- it('returns serverPort when server is running and serverPort is valid', () => {
9
+ it('returns customPort when valid', () => {
10
10
  const port = resolvePortFromUnitySettings({
11
11
  isServerRunning: true,
12
- serverPort: 8711,
13
- customPort: 8700,
14
- });
15
-
16
- expect(port).toBe(8711);
17
- });
18
-
19
- it('returns customPort when server is not running', () => {
20
- const port = resolvePortFromUnitySettings({
21
- isServerRunning: false,
22
- serverPort: 8711,
23
12
  customPort: 8700,
24
13
  });
25
14
 
26
15
  expect(port).toBe(8700);
27
16
  });
28
17
 
29
- it('returns customPort when serverPort is invalid', () => {
18
+ it('returns customPort regardless of isServerRunning flag', () => {
30
19
  const port = resolvePortFromUnitySettings({
31
20
  isServerRunning: false,
32
- serverPort: 0,
33
21
  customPort: 8711,
34
22
  });
35
23
 
36
24
  expect(port).toBe(8711);
37
25
  });
38
26
 
39
- it('falls back to serverPort when customPort is invalid', () => {
27
+ it('returns null when customPort is invalid', () => {
40
28
  const port = resolvePortFromUnitySettings({
41
- isServerRunning: false,
42
- serverPort: 8711,
29
+ isServerRunning: true,
43
30
  customPort: 0,
44
31
  });
45
32
 
46
- expect(port).toBe(8711);
33
+ expect(port).toBeNull();
47
34
  });
48
35
 
49
- it('returns null when both ports are invalid', () => {
36
+ it('returns null when customPort is missing', () => {
50
37
  const port = resolvePortFromUnitySettings({
51
- isServerRunning: false,
52
- serverPort: 0,
53
- customPort: 0,
38
+ isServerRunning: true,
54
39
  });
55
40
 
56
41
  expect(port).toBeNull();
@@ -59,7 +44,6 @@ describe('resolvePortFromUnitySettings', () => {
59
44
  it('returns null when port is not an integer', () => {
60
45
  const port = resolvePortFromUnitySettings({
61
46
  isServerRunning: true,
62
- serverPort: 8711.5,
63
47
  customPort: 8700.1,
64
48
  });
65
49
 
@@ -0,0 +1,97 @@
1
+ // Test helpers use dynamic paths for temp directories and console assertions
2
+ /* eslint-disable security/detect-non-literal-fs-filename, no-console */
3
+
4
+ import { mkdirSync, writeFileSync, rmSync } from 'fs';
5
+ import { join } from 'path';
6
+ import { tmpdir } from 'os';
7
+ import { findUnityProjectRoot, resetMultipleProjectsWarning } from '../project-root.js';
8
+
9
+ function createUnityProject(basePath: string, name: string): string {
10
+ const projectPath = join(basePath, name);
11
+ mkdirSync(join(projectPath, 'Assets'), { recursive: true });
12
+ mkdirSync(join(projectPath, 'ProjectSettings'), { recursive: true });
13
+ mkdirSync(join(projectPath, 'UserSettings'), { recursive: true });
14
+ writeFileSync(join(projectPath, 'UserSettings/UnityMcpSettings.json'), '{}');
15
+ return projectPath;
16
+ }
17
+
18
+ describe('findUnityProjectRoot', () => {
19
+ let testDir: string;
20
+
21
+ beforeEach(() => {
22
+ resetMultipleProjectsWarning();
23
+ testDir = join(tmpdir(), `uloop-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
24
+ mkdirSync(testDir, { recursive: true });
25
+ });
26
+
27
+ afterEach(() => {
28
+ rmSync(testDir, { recursive: true, force: true });
29
+ });
30
+
31
+ it('returns null when no Unity project is found', () => {
32
+ const result = findUnityProjectRoot(testDir);
33
+
34
+ expect(result).toBeNull();
35
+ expect(console.error).not.toHaveBeenCalled();
36
+ });
37
+
38
+ it('returns project path without warning when single project found', () => {
39
+ const projectPath = createUnityProject(testDir, 'MyProject');
40
+
41
+ const result = findUnityProjectRoot(testDir);
42
+
43
+ expect(result).toBe(projectPath);
44
+ expect(console.error).not.toHaveBeenCalled();
45
+ });
46
+
47
+ it('warns once when multiple projects found', () => {
48
+ createUnityProject(testDir, 'ProjectA');
49
+ createUnityProject(testDir, 'ProjectB');
50
+
51
+ findUnityProjectRoot(testDir);
52
+
53
+ expect(console.error).toHaveBeenCalled();
54
+ const calls = (console.error as jest.Mock).mock.calls.map((c: unknown[]) => c[0] as string);
55
+ const warningLine = calls.find((msg: string) => msg.includes('Multiple Unity projects'));
56
+ expect(warningLine).toBeDefined();
57
+ const actionLine = calls.find((msg: string) => msg.includes('--project-path'));
58
+ expect(actionLine).toBeDefined();
59
+ });
60
+
61
+ it('does not warn on second call (once-per-process)', () => {
62
+ createUnityProject(testDir, 'ProjectA');
63
+ createUnityProject(testDir, 'ProjectB');
64
+
65
+ findUnityProjectRoot(testDir);
66
+ (console.error as jest.Mock).mockClear();
67
+
68
+ findUnityProjectRoot(testDir);
69
+
70
+ expect(console.error).not.toHaveBeenCalled();
71
+ });
72
+
73
+ it('warns again after resetMultipleProjectsWarning()', () => {
74
+ createUnityProject(testDir, 'ProjectA');
75
+ createUnityProject(testDir, 'ProjectB');
76
+
77
+ findUnityProjectRoot(testDir);
78
+ (console.error as jest.Mock).mockClear();
79
+
80
+ resetMultipleProjectsWarning();
81
+ findUnityProjectRoot(testDir);
82
+
83
+ expect(console.error).toHaveBeenCalled();
84
+ const calls = (console.error as jest.Mock).mock.calls.map((c: unknown[]) => c[0] as string);
85
+ const warningLine = calls.find((msg: string) => msg.includes('Multiple Unity projects'));
86
+ expect(warningLine).toBeDefined();
87
+ });
88
+
89
+ it('returns first project alphabetically when multiple found', () => {
90
+ createUnityProject(testDir, 'Zebra');
91
+ createUnityProject(testDir, 'Alpha');
92
+
93
+ const result = findUnityProjectRoot(testDir);
94
+
95
+ expect(result).toBe(join(testDir, 'Alpha'));
96
+ });
97
+ });
package/src/cli.ts CHANGED
@@ -23,6 +23,7 @@ import {
23
23
  import {
24
24
  loadToolsCache,
25
25
  hasCacheFile,
26
+ getDefaultTools,
26
27
  ToolDefinition,
27
28
  ToolProperty,
28
29
  getCachedServerVersion,
@@ -61,7 +62,8 @@ const program = new Command();
61
62
  program
62
63
  .name('uloop')
63
64
  .description('Unity MCP CLI - Direct communication with Unity Editor')
64
- .version(VERSION, '-v, --version', 'Output the version number');
65
+ .version(VERSION, '-v, --version', 'Output the version number')
66
+ .showHelpAfterError('(run with -h for available options)');
65
67
 
66
68
  // --list-commands: Output command names for shell completion
67
69
  program.option('--list-commands', 'List all command names (for shell completion)');
@@ -813,6 +815,20 @@ async function main(): Promise<void> {
813
815
  const cmdName = args.find((arg) => !arg.startsWith('-'));
814
816
  const syncGlobalOptions = extractSyncGlobalOptions(args);
815
817
 
818
+ // No command name = no Unity operation; skip project detection
819
+ const NO_PROJECT_COMMANDS = [UPDATE_COMMAND, 'completion'] as const;
820
+ const skipProjectDetection =
821
+ cmdName === undefined || (NO_PROJECT_COMMANDS as readonly string[]).includes(cmdName);
822
+
823
+ if (skipProjectDetection) {
824
+ const defaultTools = getDefaultTools();
825
+ for (const tool of defaultTools.tools) {
826
+ registerToolCommand(tool);
827
+ }
828
+ program.parse();
829
+ return;
830
+ }
831
+
816
832
  if (!shouldSkipAutoSync(cmdName, args)) {
817
833
  // Check if cache version is outdated and auto-sync if needed
818
834
  const cachedVersion = loadToolsCache().version;
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.66.1",
2
+ "version": "0.67.0",
3
3
  "tools": [
4
4
  {
5
5
  "name": "compile",
@@ -84,7 +84,7 @@ export class DirectUnityClient {
84
84
  cleanup();
85
85
  reject(
86
86
  new Error(
87
- `Request timed out after ${NETWORK_TIMEOUT_MS}ms. Unity may be frozen or busy. [For AI] Run 'uloop focus-window' to bring Unity to the front, then retry the tool. If the issue persists, report this to the user and ask how to proceed. Do NOT kill Unity processes without user permission.`,
87
+ `Request timed out after ${NETWORK_TIMEOUT_MS}ms. Unity may be frozen or busy. [For AI] Report this to the user and ask how to proceed.`,
88
88
  ),
89
89
  );
90
90
  }, NETWORK_TIMEOUT_MS);
@@ -15,7 +15,6 @@ const DEFAULT_PORT = 8700;
15
15
 
16
16
  interface UnityMcpSettings {
17
17
  isServerRunning?: boolean;
18
- serverPort?: number;
19
18
  customPort?: number;
20
19
  }
21
20
 
@@ -36,21 +35,12 @@ function normalizePort(port: unknown): number | null {
36
35
  }
37
36
 
38
37
  export function resolvePortFromUnitySettings(settings: UnityMcpSettings): number | null {
39
- const serverPort = normalizePort(settings.serverPort);
40
38
  const customPort = normalizePort(settings.customPort);
41
39
 
42
- if (settings.isServerRunning === true && serverPort !== null) {
43
- return serverPort;
44
- }
45
-
46
40
  if (customPort !== null) {
47
41
  return customPort;
48
42
  }
49
43
 
50
- if (serverPort !== null) {
51
- return serverPort;
52
- }
53
-
54
44
  return null;
55
45
  }
56
46
 
@@ -113,17 +113,27 @@ function findUnityProjectInParents(startPath: string): string | null {
113
113
  *
114
114
  * Returns null if no Unity project is found.
115
115
  */
116
+ let hasWarnedMultipleProjects = false;
117
+
118
+ /** @internal Reset warning state for testing */
119
+ export function resetMultipleProjectsWarning(): void {
120
+ hasWarnedMultipleProjects = false;
121
+ }
122
+
116
123
  export function findUnityProjectRoot(startPath: string = process.cwd()): string | null {
117
124
  const childProjects = findUnityProjectsInChildren(startPath, CHILD_SEARCH_MAX_DEPTH);
118
125
 
119
126
  if (childProjects.length > 0) {
120
- if (childProjects.length > 1) {
127
+ if (childProjects.length > 1 && !hasWarnedMultipleProjects) {
128
+ hasWarnedMultipleProjects = true;
121
129
  /* eslint-disable no-console -- CLI user-facing warning output */
122
130
  console.error('\x1b[33mWarning: Multiple Unity projects found in child directories:\x1b[0m');
123
131
  for (const project of childProjects) {
124
132
  console.error(` - ${project}`);
125
133
  }
126
- console.error(`\x1b[33mUsing: ${childProjects[0]}\x1b[0m`);
134
+ console.error(
135
+ '\x1b[33mRun from a Unity project root or use --project-path to specify one.\x1b[0m',
136
+ );
127
137
  console.error('');
128
138
  /* eslint-enable no-console */
129
139
  }
@@ -19,6 +19,9 @@ interface SkillsOptions {
19
19
  global?: boolean;
20
20
  claude?: boolean;
21
21
  codex?: boolean;
22
+ cursor?: boolean;
23
+ gemini?: boolean;
24
+ windsurf?: boolean;
22
25
  }
23
26
 
24
27
  export function registerSkillsCommand(program: Command): void {
@@ -32,6 +35,9 @@ export function registerSkillsCommand(program: Command): void {
32
35
  .option('-g, --global', 'Check global installation')
33
36
  .option('--claude', 'Check Claude Code installation')
34
37
  .option('--codex', 'Check Codex CLI installation')
38
+ .option('--cursor', 'Check Cursor installation')
39
+ .option('--gemini', 'Check Gemini CLI installation')
40
+ .option('--windsurf', 'Check Windsurf installation')
35
41
  .action((options: SkillsOptions) => {
36
42
  const targets = resolveTargets(options);
37
43
  const global = options.global ?? false;
@@ -44,6 +50,9 @@ export function registerSkillsCommand(program: Command): void {
44
50
  .option('-g, --global', 'Install to global location')
45
51
  .option('--claude', 'Install to Claude Code')
46
52
  .option('--codex', 'Install to Codex CLI')
53
+ .option('--cursor', 'Install to Cursor')
54
+ .option('--gemini', 'Install to Gemini CLI')
55
+ .option('--windsurf', 'Install to Windsurf')
47
56
  .action((options: SkillsOptions) => {
48
57
  const targets = resolveTargets(options);
49
58
  if (targets.length === 0) {
@@ -59,6 +68,9 @@ export function registerSkillsCommand(program: Command): void {
59
68
  .option('-g, --global', 'Uninstall from global location')
60
69
  .option('--claude', 'Uninstall from Claude Code')
61
70
  .option('--codex', 'Uninstall from Codex CLI')
71
+ .option('--cursor', 'Uninstall from Cursor')
72
+ .option('--gemini', 'Uninstall from Gemini CLI')
73
+ .option('--windsurf', 'Uninstall from Windsurf')
62
74
  .action((options: SkillsOptions) => {
63
75
  const targets = resolveTargets(options);
64
76
  if (targets.length === 0) {
@@ -77,6 +89,15 @@ function resolveTargets(options: SkillsOptions): TargetConfig[] {
77
89
  if (options.codex) {
78
90
  targets.push(getTargetConfig('codex'));
79
91
  }
92
+ if (options.cursor) {
93
+ targets.push(getTargetConfig('cursor'));
94
+ }
95
+ if (options.gemini) {
96
+ targets.push(getTargetConfig('gemini'));
97
+ }
98
+ if (options.windsurf) {
99
+ targets.push(getTargetConfig('windsurf'));
100
+ }
80
101
  return targets;
81
102
  }
82
103
 
@@ -86,14 +107,17 @@ function showTargetGuidance(command: string): void {
86
107
  console.log('Available targets:');
87
108
  console.log(' --claude Claude Code (.claude/skills/)');
88
109
  console.log(' --codex Codex CLI (.codex/skills/)');
110
+ console.log(' --cursor Cursor (.cursor/skills/)');
111
+ console.log(' --gemini Gemini CLI (.gemini/skills/)');
112
+ console.log(' --windsurf Windsurf (.windsurf/skills/)');
89
113
  console.log('');
90
114
  console.log('Options:');
91
- console.log(' -g, --global Use global location (~/.claude/ or ~/.codex/)');
115
+ console.log(' -g, --global Use global location');
92
116
  console.log('');
93
117
  console.log('Examples:');
94
118
  console.log(` uloop skills ${command} --claude`);
95
- console.log(` uloop skills ${command} --codex --global`);
96
- console.log(` uloop skills ${command} --claude --codex`);
119
+ console.log(` uloop skills ${command} --cursor --global`);
120
+ console.log(` uloop skills ${command} --claude --codex --cursor --gemini`);
97
121
  }
98
122
 
99
123
  function listSkills(targets: TargetConfig[], global: boolean): void {
@@ -3,7 +3,7 @@
3
3
  * Supports Claude Code and Codex CLI, with extensibility for future targets.
4
4
  */
5
5
 
6
- export type TargetId = 'claude' | 'codex';
6
+ export type TargetId = 'claude' | 'codex' | 'cursor' | 'gemini' | 'windsurf';
7
7
 
8
8
  export interface TargetConfig {
9
9
  id: TargetId;
@@ -25,9 +25,27 @@ export const TARGET_CONFIGS: Record<TargetId, TargetConfig> = {
25
25
  projectDir: '.codex',
26
26
  skillFileName: 'SKILL.md',
27
27
  },
28
+ cursor: {
29
+ id: 'cursor',
30
+ displayName: 'Cursor',
31
+ projectDir: '.cursor',
32
+ skillFileName: 'SKILL.md',
33
+ },
34
+ gemini: {
35
+ id: 'gemini',
36
+ displayName: 'Gemini CLI',
37
+ projectDir: '.gemini',
38
+ skillFileName: 'SKILL.md',
39
+ },
40
+ windsurf: {
41
+ id: 'windsurf',
42
+ displayName: 'Windsurf',
43
+ projectDir: '.windsurf',
44
+ skillFileName: 'SKILL.md',
45
+ },
28
46
  };
29
47
 
30
- export const ALL_TARGET_IDS: TargetId[] = ['claude', 'codex'];
48
+ export const ALL_TARGET_IDS: TargetId[] = ['claude', 'codex', 'cursor', 'gemini', 'windsurf'];
31
49
 
32
50
  export function getTargetConfig(id: TargetId): TargetConfig {
33
51
  // eslint-disable-next-line security/detect-object-injection -- id is type-constrained to TargetId union type
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.66.1'; // x-release-please-version
7
+ export const VERSION = '0.67.0'; // x-release-please-version