uloop-cli 0.45.0 → 0.46.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.45.0",
3
+ "version": "0.46.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",
@@ -41,12 +41,14 @@
41
41
  "provenance": true
42
42
  },
43
43
  "dependencies": {
44
- "commander": "^14.0.2"
44
+ "commander": "^14.0.2",
45
+ "semver": "^7.7.3"
45
46
  },
46
47
  "devDependencies": {
47
48
  "@eslint/js": "9.39.2",
48
49
  "@types/jest": "30.0.0",
49
50
  "@types/node": "25.0.2",
51
+ "@types/semver": "^7.7.1",
50
52
  "esbuild": "0.27.1",
51
53
  "eslint": "9.39.2",
52
54
  "eslint-config-prettier": "^10.1.8",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.45.0",
2
+ "version": "0.46.0",
3
3
  "tools": [
4
4
  {
5
5
  "name": "compile",
@@ -10,6 +10,7 @@
10
10
  import * as readline from 'readline';
11
11
  import { existsSync } from 'fs';
12
12
  import { join } from 'path';
13
+ import * as semver from 'semver';
13
14
  import { DirectUnityClient } from './direct-unity-client.js';
14
15
  import { resolveUnityPort } from './port-resolver.js';
15
16
  import { saveToolsCache, getCacheFilePath, ToolsCache, ToolDefinition } from './tool-cache.js';
@@ -70,6 +71,52 @@ function isRetryableError(error: unknown): boolean {
70
71
  return message.includes('ECONNREFUSED') || message === 'UNITY_NO_RESPONSE';
71
72
  }
72
73
 
74
+ /**
75
+ * Compare two semantic versions safely.
76
+ * Returns true if v1 < v2, false otherwise.
77
+ * Falls back to string comparison if versions are invalid.
78
+ */
79
+ function isVersionOlder(v1: string, v2: string): boolean {
80
+ const parsed1 = semver.valid(v1);
81
+ const parsed2 = semver.valid(v2);
82
+
83
+ if (parsed1 && parsed2) {
84
+ return semver.lt(parsed1, parsed2);
85
+ }
86
+
87
+ return v1 < v2;
88
+ }
89
+
90
+ /**
91
+ * Print version mismatch warning to stderr.
92
+ * Does not block execution - just warns the user.
93
+ */
94
+ function printVersionWarning(cliVersion: string, serverVersion: string): void {
95
+ const isCliOlder = isVersionOlder(cliVersion, serverVersion);
96
+ const updateCommand = isCliOlder
97
+ ? `npm install -g uloop-cli@${serverVersion}`
98
+ : `Update uLoopMCP package to ${cliVersion} via Unity Package Manager`;
99
+
100
+ console.error('\x1b[33m⚠️ Version mismatch detected!\x1b[0m');
101
+ console.error(` uloop-cli version: ${cliVersion}`);
102
+ console.error(` uloop server version: ${serverVersion}`);
103
+ console.error('');
104
+ console.error(' This may cause unexpected behavior or errors.');
105
+ console.error('');
106
+ console.error(` ${isCliOlder ? 'To update CLI:' : 'To update server:'} ${updateCommand}`);
107
+ console.error('');
108
+ }
109
+
110
+ /**
111
+ * Check server version from response and print warning if mismatched.
112
+ */
113
+ function checkServerVersion(result: Record<string, unknown>): void {
114
+ const serverVersion = result['ULoopServerVersion'] as string | undefined;
115
+ if (serverVersion && serverVersion !== VERSION) {
116
+ printVersionWarning(VERSION, serverVersion);
117
+ }
118
+ }
119
+
73
120
  /**
74
121
  * Check if Unity is in a busy state (compiling, reloading, or server starting).
75
122
  * Throws an error with appropriate message if busy.
@@ -123,7 +170,7 @@ export async function executeToolCommand(
123
170
  await client.connect();
124
171
 
125
172
  spinner.update(`Executing ${toolName}...`);
126
- const result = await client.sendRequest(toolName, params);
173
+ const result = await client.sendRequest<Record<string, unknown>>(toolName, params);
127
174
 
128
175
  if (result === undefined || result === null) {
129
176
  throw new Error('UNITY_NO_RESPONSE');
@@ -132,6 +179,10 @@ export async function executeToolCommand(
132
179
  // Success - stop spinner and output result
133
180
  spinner.stop();
134
181
  restoreStdin();
182
+
183
+ // Check server version and warn if mismatched
184
+ checkServerVersion(result);
185
+
135
186
  console.log(JSON.stringify(result, null, 2));
136
187
  return;
137
188
  } catch (error) {
@@ -13,53 +13,114 @@ import {
13
13
  getInstallDir,
14
14
  getTotalSkillCount,
15
15
  } from './skills-manager.js';
16
+ import { TargetConfig, ALL_TARGET_IDS, getTargetConfig } from './target-config.js';
17
+
18
+ interface SkillsOptions {
19
+ global?: boolean;
20
+ claude?: boolean;
21
+ codex?: boolean;
22
+ }
16
23
 
17
24
  export function registerSkillsCommand(program: Command): void {
18
- const skillsCmd = program.command('skills').description('Manage uloop skills for Claude Code');
25
+ const skillsCmd = program
26
+ .command('skills')
27
+ .description('Manage uloop skills for AI coding tools');
19
28
 
20
29
  skillsCmd
21
30
  .command('list')
22
31
  .description('List all uloop skills and their installation status')
23
- .option('-g, --global', 'Check global installation (~/.claude/skills/)')
24
- .action((options: { global?: boolean }) => {
25
- listSkills(options.global ?? false);
32
+ .option('-g, --global', 'Check global installation')
33
+ .option('--claude', 'Check Claude Code installation')
34
+ .option('--codex', 'Check Codex CLI installation')
35
+ .action((options: SkillsOptions) => {
36
+ const targets = resolveTargets(options);
37
+ const global = options.global ?? false;
38
+ listSkills(targets, global);
26
39
  });
27
40
 
28
41
  skillsCmd
29
42
  .command('install')
30
43
  .description('Install all uloop skills')
31
- .option('-g, --global', 'Install to global location (~/.claude/skills/)')
32
- .action((options: { global?: boolean }) => {
33
- installSkills(options.global ?? false);
44
+ .option('-g, --global', 'Install to global location')
45
+ .option('--claude', 'Install to Claude Code')
46
+ .option('--codex', 'Install to Codex CLI')
47
+ .action((options: SkillsOptions) => {
48
+ const targets = resolveTargets(options);
49
+ if (targets.length === 0) {
50
+ showTargetGuidance('install');
51
+ return;
52
+ }
53
+ installSkills(targets, options.global ?? false);
34
54
  });
35
55
 
36
56
  skillsCmd
37
57
  .command('uninstall')
38
58
  .description('Uninstall all uloop skills')
39
- .option('-g, --global', 'Uninstall from global location (~/.claude/skills/)')
40
- .action((options: { global?: boolean }) => {
41
- uninstallSkills(options.global ?? false);
59
+ .option('-g, --global', 'Uninstall from global location')
60
+ .option('--claude', 'Uninstall from Claude Code')
61
+ .option('--codex', 'Uninstall from Codex CLI')
62
+ .action((options: SkillsOptions) => {
63
+ const targets = resolveTargets(options);
64
+ if (targets.length === 0) {
65
+ showTargetGuidance('uninstall');
66
+ return;
67
+ }
68
+ uninstallSkills(targets, options.global ?? false);
42
69
  });
43
70
  }
44
71
 
45
- function listSkills(global: boolean): void {
72
+ function resolveTargets(options: SkillsOptions): TargetConfig[] {
73
+ const targets: TargetConfig[] = [];
74
+ if (options.claude) {
75
+ targets.push(getTargetConfig('claude'));
76
+ }
77
+ if (options.codex) {
78
+ targets.push(getTargetConfig('codex'));
79
+ }
80
+ return targets;
81
+ }
82
+
83
+ function showTargetGuidance(command: string): void {
84
+ console.log(`\nPlease specify at least one target for '${command}':`);
85
+ console.log('');
86
+ console.log('Available targets:');
87
+ console.log(' --claude Claude Code (.claude/skills/)');
88
+ console.log(' --codex Codex CLI (.codex/skills/)');
89
+ console.log('');
90
+ console.log('Options:');
91
+ console.log(' -g, --global Use global location (~/.claude/ or ~/.codex/)');
92
+ console.log('');
93
+ console.log('Examples:');
94
+ console.log(` uloop skills ${command} --claude`);
95
+ console.log(` uloop skills ${command} --codex --global`);
96
+ console.log(` uloop skills ${command} --claude --codex`);
97
+ }
98
+
99
+ function listSkills(targets: TargetConfig[], global: boolean): void {
46
100
  const location = global ? 'Global' : 'Project';
47
- const dir = getInstallDir(global);
101
+ const targetConfigs = targets.length > 0 ? targets : ALL_TARGET_IDS.map(getTargetConfig);
48
102
 
49
- console.log(`\nuloop Skills Status (${location}):`);
50
- console.log(`Location: ${dir}`);
51
- console.log('='.repeat(50));
103
+ console.log(`\nuloop Skills Status:`);
52
104
  console.log('');
53
105
 
54
- const statuses = getAllSkillStatuses(global);
106
+ for (const target of targetConfigs) {
107
+ const dir = getInstallDir(target, global);
108
+
109
+ console.log(`${target.displayName} (${location}):`);
110
+ console.log(`Location: ${dir}`);
111
+ console.log('='.repeat(50));
112
+
113
+ const statuses = getAllSkillStatuses(target, global);
55
114
 
56
- for (const skill of statuses) {
57
- const icon = getStatusIcon(skill.status);
58
- const statusText = getStatusText(skill.status);
59
- console.log(` ${icon} ${skill.name} (${statusText})`);
115
+ for (const skill of statuses) {
116
+ const icon = getStatusIcon(skill.status);
117
+ const statusText = getStatusText(skill.status);
118
+ console.log(` ${icon} ${skill.name} (${statusText})`);
119
+ }
120
+
121
+ console.log('');
60
122
  }
61
123
 
62
- console.log('');
63
124
  console.log(`Total: ${getTotalSkillCount()} bundled skills`);
64
125
  }
65
126
 
@@ -89,33 +150,39 @@ function getStatusText(status: string): string {
89
150
  }
90
151
  }
91
152
 
92
- function installSkills(global: boolean): void {
153
+ function installSkills(targets: TargetConfig[], global: boolean): void {
93
154
  const location = global ? 'global' : 'project';
94
- const dir = getInstallDir(global);
95
155
 
96
156
  console.log(`\nInstalling uloop skills (${location})...`);
97
157
  console.log('');
98
158
 
99
- const result = installAllSkills(global);
159
+ for (const target of targets) {
160
+ const dir = getInstallDir(target, global);
161
+ const result = installAllSkills(target, global);
100
162
 
101
- console.log(`\x1b[32m✓\x1b[0m Installed: ${result.installed}`);
102
- console.log(`\x1b[33m↑\x1b[0m Updated: ${result.updated}`);
103
- console.log(`\x1b[90m-\x1b[0m Skipped (up-to-date): ${result.skipped}`);
104
- console.log('');
105
- console.log(`Skills installed to ${dir}`);
163
+ console.log(`${target.displayName}:`);
164
+ console.log(` \x1b[32m✓\x1b[0m Installed: ${result.installed}`);
165
+ console.log(` \x1b[33m↑\x1b[0m Updated: ${result.updated}`);
166
+ console.log(` \x1b[90m-\x1b[0m Skipped (up-to-date): ${result.skipped}`);
167
+ console.log(` Location: ${dir}`);
168
+ console.log('');
169
+ }
106
170
  }
107
171
 
108
- function uninstallSkills(global: boolean): void {
172
+ function uninstallSkills(targets: TargetConfig[], global: boolean): void {
109
173
  const location = global ? 'global' : 'project';
110
- const dir = getInstallDir(global);
111
174
 
112
175
  console.log(`\nUninstalling uloop skills (${location})...`);
113
176
  console.log('');
114
177
 
115
- const result = uninstallAllSkills(global);
178
+ for (const target of targets) {
179
+ const dir = getInstallDir(target, global);
180
+ const result = uninstallAllSkills(target, global);
116
181
 
117
- console.log(`\x1b[31m✗\x1b[0m Removed: ${result.removed}`);
118
- console.log(`\x1b[90m-\x1b[0m Not found: ${result.notFound}`);
119
- console.log('');
120
- console.log(`Skills removed from ${dir}`);
182
+ console.log(`${target.displayName}:`);
183
+ console.log(` \x1b[31m✗\x1b[0m Removed: ${result.removed}`);
184
+ console.log(` \x1b[90m-\x1b[0m Not found: ${result.notFound}`);
185
+ console.log(` Location: ${dir}`);
186
+ console.log('');
187
+ }
121
188
  }
@@ -9,6 +9,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'fs';
9
9
  import { join } from 'path';
10
10
  import { homedir } from 'os';
11
11
  import { BUNDLED_SKILLS, BundledSkill } from './bundled-skills.js';
12
+ import { TargetConfig } from './target-config.js';
12
13
 
13
14
  export type SkillStatus = 'installed' | 'not_installed' | 'outdated';
14
15
 
@@ -18,26 +19,26 @@ export interface SkillInfo {
18
19
  path?: string;
19
20
  }
20
21
 
21
- function getGlobalSkillsDir(): string {
22
- return join(homedir(), '.claude', 'skills');
22
+ function getGlobalSkillsDir(target: TargetConfig): string {
23
+ return join(homedir(), target.projectDir, 'skills');
23
24
  }
24
25
 
25
- function getProjectSkillsDir(): string {
26
- return join(process.cwd(), '.claude', 'skills');
26
+ function getProjectSkillsDir(target: TargetConfig): string {
27
+ return join(process.cwd(), target.projectDir, 'skills');
27
28
  }
28
29
 
29
- function getSkillPath(skillDirName: string, global: boolean): string {
30
- const baseDir = global ? getGlobalSkillsDir() : getProjectSkillsDir();
31
- return join(baseDir, skillDirName, 'SKILL.md');
30
+ function getSkillPath(skillDirName: string, target: TargetConfig, global: boolean): string {
31
+ const baseDir = global ? getGlobalSkillsDir(target) : getProjectSkillsDir(target);
32
+ return join(baseDir, skillDirName, target.skillFileName);
32
33
  }
33
34
 
34
- function isSkillInstalled(skill: BundledSkill, global: boolean): boolean {
35
- const skillPath = getSkillPath(skill.dirName, global);
35
+ function isSkillInstalled(skill: BundledSkill, target: TargetConfig, global: boolean): boolean {
36
+ const skillPath = getSkillPath(skill.dirName, target, global);
36
37
  return existsSync(skillPath);
37
38
  }
38
39
 
39
- function isSkillOutdated(skill: BundledSkill, global: boolean): boolean {
40
- const skillPath = getSkillPath(skill.dirName, global);
40
+ function isSkillOutdated(skill: BundledSkill, target: TargetConfig, global: boolean): boolean {
41
+ const skillPath = getSkillPath(skill.dirName, target, global);
41
42
  if (!existsSync(skillPath)) {
42
43
  return false;
43
44
  }
@@ -45,35 +46,45 @@ function isSkillOutdated(skill: BundledSkill, global: boolean): boolean {
45
46
  return installedContent !== skill.content;
46
47
  }
47
48
 
48
- export function getSkillStatus(skill: BundledSkill, global: boolean): SkillStatus {
49
- if (!isSkillInstalled(skill, global)) {
49
+ export function getSkillStatus(
50
+ skill: BundledSkill,
51
+ target: TargetConfig,
52
+ global: boolean,
53
+ ): SkillStatus {
54
+ if (!isSkillInstalled(skill, target, global)) {
50
55
  return 'not_installed';
51
56
  }
52
- if (isSkillOutdated(skill, global)) {
57
+ if (isSkillOutdated(skill, target, global)) {
53
58
  return 'outdated';
54
59
  }
55
60
  return 'installed';
56
61
  }
57
62
 
58
- export function getAllSkillStatuses(global: boolean): SkillInfo[] {
63
+ export function getAllSkillStatuses(target: TargetConfig, global: boolean): SkillInfo[] {
59
64
  return BUNDLED_SKILLS.map((skill) => ({
60
65
  name: skill.name,
61
- status: getSkillStatus(skill, global),
62
- path: isSkillInstalled(skill, global) ? getSkillPath(skill.dirName, global) : undefined,
66
+ status: getSkillStatus(skill, target, global),
67
+ path: isSkillInstalled(skill, target, global)
68
+ ? getSkillPath(skill.dirName, target, global)
69
+ : undefined,
63
70
  }));
64
71
  }
65
72
 
66
- export function installSkill(skill: BundledSkill, global: boolean): void {
67
- const baseDir = global ? getGlobalSkillsDir() : getProjectSkillsDir();
73
+ export function installSkill(skill: BundledSkill, target: TargetConfig, global: boolean): void {
74
+ const baseDir = global ? getGlobalSkillsDir(target) : getProjectSkillsDir(target);
68
75
  const skillDir = join(baseDir, skill.dirName);
69
- const skillPath = join(skillDir, 'SKILL.md');
76
+ const skillPath = join(skillDir, target.skillFileName);
70
77
 
71
78
  mkdirSync(skillDir, { recursive: true });
72
79
  writeFileSync(skillPath, skill.content, 'utf-8');
73
80
  }
74
81
 
75
- export function uninstallSkill(skill: BundledSkill, global: boolean): boolean {
76
- const baseDir = global ? getGlobalSkillsDir() : getProjectSkillsDir();
82
+ export function uninstallSkill(
83
+ skill: BundledSkill,
84
+ target: TargetConfig,
85
+ global: boolean,
86
+ ): boolean {
87
+ const baseDir = global ? getGlobalSkillsDir(target) : getProjectSkillsDir(target);
77
88
  const skillDir = join(baseDir, skill.dirName);
78
89
 
79
90
  if (!existsSync(skillDir)) {
@@ -90,17 +101,17 @@ export interface InstallResult {
90
101
  skipped: number;
91
102
  }
92
103
 
93
- export function installAllSkills(global: boolean): InstallResult {
104
+ export function installAllSkills(target: TargetConfig, global: boolean): InstallResult {
94
105
  const result: InstallResult = { installed: 0, updated: 0, skipped: 0 };
95
106
 
96
107
  for (const skill of BUNDLED_SKILLS) {
97
- const status = getSkillStatus(skill, global);
108
+ const status = getSkillStatus(skill, target, global);
98
109
 
99
110
  if (status === 'not_installed') {
100
- installSkill(skill, global);
111
+ installSkill(skill, target, global);
101
112
  result.installed++;
102
113
  } else if (status === 'outdated') {
103
- installSkill(skill, global);
114
+ installSkill(skill, target, global);
104
115
  result.updated++;
105
116
  } else {
106
117
  result.skipped++;
@@ -115,11 +126,11 @@ export interface UninstallResult {
115
126
  notFound: number;
116
127
  }
117
128
 
118
- export function uninstallAllSkills(global: boolean): UninstallResult {
129
+ export function uninstallAllSkills(target: TargetConfig, global: boolean): UninstallResult {
119
130
  const result: UninstallResult = { removed: 0, notFound: 0 };
120
131
 
121
132
  for (const skill of BUNDLED_SKILLS) {
122
- if (uninstallSkill(skill, global)) {
133
+ if (uninstallSkill(skill, target, global)) {
123
134
  result.removed++;
124
135
  } else {
125
136
  result.notFound++;
@@ -129,8 +140,8 @@ export function uninstallAllSkills(global: boolean): UninstallResult {
129
140
  return result;
130
141
  }
131
142
 
132
- export function getInstallDir(global: boolean): string {
133
- return global ? getGlobalSkillsDir() : getProjectSkillsDir();
143
+ export function getInstallDir(target: TargetConfig, global: boolean): string {
144
+ return global ? getGlobalSkillsDir(target) : getProjectSkillsDir(target);
134
145
  }
135
146
 
136
147
  export function getTotalSkillCount(): number {
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Target configuration for multi-AI tool support.
3
+ * Supports Claude Code and Codex CLI, with extensibility for future targets.
4
+ */
5
+
6
+ export type TargetId = 'claude' | 'codex';
7
+
8
+ export interface TargetConfig {
9
+ id: TargetId;
10
+ displayName: string;
11
+ projectDir: string;
12
+ skillFileName: string;
13
+ }
14
+
15
+ export const TARGET_CONFIGS: Record<TargetId, TargetConfig> = {
16
+ claude: {
17
+ id: 'claude',
18
+ displayName: 'Claude Code',
19
+ projectDir: '.claude',
20
+ skillFileName: 'SKILL.md',
21
+ },
22
+ codex: {
23
+ id: 'codex',
24
+ displayName: 'Codex CLI',
25
+ projectDir: '.codex',
26
+ skillFileName: 'SKILL.md',
27
+ },
28
+ };
29
+
30
+ export const ALL_TARGET_IDS: TargetId[] = ['claude', 'codex'];
31
+
32
+ export function getTargetConfig(id: TargetId): TargetConfig {
33
+ return TARGET_CONFIGS[id];
34
+ }
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.45.0'; // x-release-please-version
7
+ export const VERSION = '0.46.0'; // x-release-please-version