Skip to content

Commit 6b9fb60

Browse files
authored
Task: expose unity-mcp-cli's open command as a callable library function (#731)
Added openProject() lib export to unity-mcp-cli, extracted shared launch logic into src/lib/open.ts, made the CLI command delegate to it; library-safe (no process.exit / stdout / stderr). Closes #730
1 parent c49ff85 commit 6b9fb60

6 files changed

Lines changed: 1134 additions & 137 deletions

File tree

cli/src/commands/open.ts

Lines changed: 188 additions & 131 deletions
Original file line numberDiff line numberDiff line change
@@ -1,50 +1,82 @@
11
import { Command } from 'commander';
22
import * as path from 'path';
3-
import * as fs from 'fs';
4-
import { findEditorPath, getProjectEditorVersion, launchEditor, printEditorNotFoundHelp } from '../utils/unity-editor.js';
3+
import { findUnityHub, listInstalledEditors } from '../utils/unity-hub.js';
54
import { findUnityProcess } from '../utils/unity-process.js';
65
import * as ui from '../utils/ui.js';
76
import { verbose } from '../utils/ui.js';
8-
import { readConfig, isCloudMode, writeConfig } from '../utils/config.js';
7+
import {
8+
openProject,
9+
resolveProjectPath as libResolveProjectPath,
10+
isUnityProjectDir as libIsUnityProjectDir,
11+
} from '../lib/open.js';
12+
import type { OpenProjectAuthOption, OpenProjectTransport } from '../lib/types.js';
913

14+
/**
15+
* Resolve the project path from the positional argument, `--path` option, or
16+
* the current working directory when neither is provided.
17+
*
18+
* Re-exported from the library helper so existing tests continue to import
19+
* `resolveOpenProjectPath` from this module.
20+
*/
1021
export interface ResolveProjectPathResult {
1122
/** Absolute, resolved path to the project directory. */
1223
projectPath: string;
1324
/** True if no path was supplied and we fell back to `process.cwd()`. */
1425
usedCwdFallback: boolean;
1526
}
1627

17-
/**
18-
* Resolve the project path from the positional argument, `--path` option, or
19-
* the current working directory when neither is provided.
20-
*
21-
* Exported for unit testing.
22-
*/
2328
export function resolveOpenProjectPath(
2429
positionalPath: string | undefined,
2530
optionPath: string | undefined,
2631
cwd: string,
2732
): ResolveProjectPathResult {
33+
// The CLI takes both a positional `[path]` AND an `--path` flag; the
34+
// library helper only takes a single `optionPath`. Collapse the two
35+
// CLI inputs (positional wins) before delegating.
2836
const explicit = positionalPath ?? optionPath;
29-
const resolvedPath = explicit ?? cwd;
30-
return {
31-
projectPath: path.resolve(resolvedPath),
32-
usedCwdFallback: explicit === undefined,
33-
};
37+
return libResolveProjectPath(explicit, cwd);
3438
}
3539

40+
/** Re-export for backward compatibility with existing tests. */
41+
export const isUnityProjectDir = libIsUnityProjectDir;
42+
3643
/**
37-
* Returns true if `projectPath` looks like a Unity project — i.e. it contains
38-
* an `Assets/` directory and a `ProjectSettings/ProjectVersion.txt` file.
39-
*
40-
* Exported for unit testing.
44+
* Print actionable help when a required Unity Editor version is not found.
45+
* Lists installed editors and suggests install or override commands. Lives
46+
* here (next to the CLI command) instead of `unity-editor.ts` because it
47+
* writes to the chalk-styled `ui` and we want to keep the `unity-editor.ts`
48+
* helpers library-safe.
4149
*/
42-
export function isUnityProjectDir(projectPath: string): boolean {
43-
const hasAssets = fs.existsSync(path.join(projectPath, 'Assets'));
44-
const hasProjectVersion = fs.existsSync(
45-
path.join(projectPath, 'ProjectSettings', 'ProjectVersion.txt'),
46-
);
47-
return hasAssets && hasProjectVersion;
50+
function printEditorNotFoundHelp(requestedVersion: string | undefined, commandName: string): void {
51+
if (requestedVersion) {
52+
ui.error(`Unity Editor ${requestedVersion} is not installed.`);
53+
} else {
54+
ui.error('No Unity Editor found.');
55+
}
56+
57+
ui.heading('Options:');
58+
59+
if (requestedVersion) {
60+
ui.info(`Install it: npx unity-mcp-cli install-unity ${requestedVersion}`);
61+
}
62+
ui.info('Install latest stable: npx unity-mcp-cli install-unity');
63+
64+
const hubPath = findUnityHub();
65+
if (hubPath) {
66+
const editors = listInstalledEditors(hubPath);
67+
if (editors.length > 0) {
68+
ui.heading('Installed editors:');
69+
for (const editor of editors) {
70+
ui.label(editor.version, editor.path);
71+
}
72+
if (requestedVersion) {
73+
const hint = commandName === 'connect'
74+
? `npx unity-mcp-cli ${commandName} --unity ${editors[0].version} --path <path> --url <url>`
75+
: `npx unity-mcp-cli ${commandName} <path> --unity ${editors[0].version}`;
76+
ui.info(`Use a different version: ${hint}`);
77+
}
78+
}
79+
}
4880
}
4981

5082
export const openCommand = new Command('open')
@@ -72,22 +104,25 @@ export const openCommand = new Command('open')
72104
transport?: string;
73105
startServer?: string;
74106
}) => {
107+
// Resolve the path the same way the library does, but also
108+
// validate the existence + Unity-project shape up front so we
109+
// can emit the historical, friendlier "Current directory is
110+
// not a Unity project" message when the cwd-fallback was used.
111+
// The library always reports the underlying error string, but
112+
// the CLI is allowed to render two different variants.
75113
const { projectPath, usedCwdFallback } = resolveOpenProjectPath(
76114
positionalPath,
77115
options.path,
78116
process.cwd(),
79117
);
80118

119+
const fs = await import('fs');
81120
if (!fs.existsSync(projectPath)) {
82121
ui.error(`Project path does not exist: ${projectPath}`);
83122
process.exit(1);
84123
}
85124

86-
// Validate the directory looks like a Unity project. We require the
87-
// presence of both `Assets/` and `ProjectSettings/ProjectVersion.txt`
88-
// to avoid launching the Editor against an unrelated folder — this
89-
// matters most when the user omits the path and we fall back to cwd.
90-
if (!isUnityProjectDir(projectPath)) {
125+
if (!libIsUnityProjectDir(projectPath)) {
91126
if (usedCwdFallback) {
92127
ui.error(`Current directory is not a Unity project: ${projectPath}`);
93128
ui.info('Run this command from a Unity project folder, or pass a path: unity-mcp-cli open <path>');
@@ -103,121 +138,143 @@ export const openCommand = new Command('open')
103138
verbose(`open invoked for project: ${projectPath}`);
104139
verbose(`--no-connect: ${options.connect === false}`);
105140

106-
// Check if Unity is already running with this project
107-
const existingProcess = findUnityProcess(projectPath);
108-
if (existingProcess) {
109-
ui.success(`Unity is already running with this project (PID: ${existingProcess.pid})`);
110-
ui.info('Skipping launch. Use the running instance or close it first.');
111-
process.exit(0);
112-
}
113-
114-
// Determine editor version
115-
let version = options.unity;
116-
if (!version) {
117-
version = getProjectEditorVersion(projectPath) ?? undefined;
118-
if (version) {
119-
ui.info(`Detected editor version from project: ${version}`);
120-
verbose(`Resolved editor version from ProjectVersion.txt: ${version}`);
121-
}
141+
// Validate auth/transport/startServer here — keeps the historical
142+
// CLI error messages and exit codes (the library reports these
143+
// as `kind: 'failure'` instead).
144+
if (options.auth !== undefined && options.auth !== 'none' && options.auth !== 'required') {
145+
ui.error('--auth must be "none" or "required"');
146+
process.exit(1);
122147
}
123-
124-
const spinner = ui.startSpinner('Locating Unity Editor...');
125-
const editorPath = await findEditorPath(version);
126-
if (!editorPath) {
127-
spinner.stop();
128-
printEditorNotFoundHelp(version, 'open');
148+
if (options.transport !== undefined && options.transport !== 'streamableHttp' && options.transport !== 'stdio') {
149+
ui.error('--transport must be "streamableHttp" or "stdio"');
129150
process.exit(1);
130151
}
131-
spinner.success('Unity Editor located');
132-
verbose(`Editor path: ${editorPath}`);
133-
134-
// Auto-detect Cloud mode: if project has cloudToken, ensure keep-connected
135-
// so the Unity plugin connects to the cloud server on startup.
136-
// Also enable auto-generate skills for claude-code by default.
137-
{
138-
const config = readConfig(projectPath);
139-
if (config && isCloudMode(config) && config.cloudToken) {
140-
if (!options.keepConnected) {
141-
options.keepConnected = true;
142-
verbose('Cloud mode with token detected — auto-enabling keep-connected');
143-
}
144-
145-
const skillAutoGenerate = { ...(config.skillAutoGenerate ?? {}) } as Record<string, boolean>;
146-
if (!skillAutoGenerate['claude-code']) {
147-
skillAutoGenerate['claude-code'] = true;
148-
writeConfig(projectPath, { ...config, skillAutoGenerate });
149-
verbose('Auto-enabled skill generation for claude-code');
150-
}
152+
let startServerBool: boolean | undefined;
153+
if (options.startServer !== undefined) {
154+
const val = options.startServer.toLowerCase();
155+
if (val !== 'true' && val !== 'false') {
156+
ui.error('--start-server must be "true" or "false"');
157+
process.exit(1);
151158
}
159+
startServerBool = val === 'true';
152160
}
153161

154-
// Build environment variables for MCP connection (unless --no-connect)
155-
const useConnect = options.connect !== false;
156-
let env: Record<string, string> | undefined;
162+
// Pre-flight already-running check so we don't flash the
163+
// "Locating Unity Editor..." spinner when Unity is already open
164+
// for this project. The lib does its own check too (single
165+
// source of truth for the result shape); this one only suppresses
166+
// spinner spam in the common already-open case.
167+
const alreadyRunningPid = findUnityProcess(projectPath)?.pid;
157168

158-
if (useConnect) {
159-
const envVars: Record<string, string> = {};
169+
// Spinner around editor location for parity with the legacy UX.
170+
let spinner: ReturnType<typeof ui.startSpinner> | undefined =
171+
alreadyRunningPid === undefined
172+
? ui.startSpinner('Locating Unity Editor...')
173+
: undefined;
160174

161-
if (options.url) {
162-
envVars['UNITY_MCP_HOST'] = options.url;
163-
}
164-
165-
if (options.keepConnected) {
166-
envVars['UNITY_MCP_KEEP_CONNECTED'] = 'true';
167-
}
168-
169-
if (options.tools) {
170-
envVars['UNITY_MCP_TOOLS'] = options.tools;
171-
}
172-
173-
if (options.token) {
174-
envVars['UNITY_MCP_TOKEN'] = options.token;
175-
}
176-
177-
if (options.auth) {
178-
if (options.auth !== 'none' && options.auth !== 'required') {
179-
ui.error('--auth must be "none" or "required"');
180-
process.exit(1);
175+
const result = await openProject({
176+
projectPath,
177+
unityVersion: options.unity,
178+
noConnect: options.connect === false,
179+
url: options.url,
180+
token: options.token,
181+
auth: options.auth as OpenProjectAuthOption | undefined,
182+
tools: options.tools,
183+
keepConnected: options.keepConnected,
184+
transport: options.transport as OpenProjectTransport | undefined,
185+
startServer: startServerBool,
186+
onProgress: (event) => {
187+
switch (event.phase) {
188+
case 'detecting-editor-version': {
189+
// unity-version detection is fast — no UI noise needed.
190+
break;
191+
}
192+
case 'editors-located': {
193+
// Cover the case where editor discovery succeeded — the
194+
// spinner success message lands on `editor-resolved`,
195+
// not here, so we don't overwrite the "Locating…" line.
196+
break;
197+
}
198+
case 'editor-resolved': {
199+
if (spinner) {
200+
spinner.success('Unity Editor located');
201+
spinner = undefined;
202+
}
203+
verbose(`Editor path: ${event.editorPath}`);
204+
if (event.version) {
205+
ui.info(`Detected editor version from project: ${event.version}`);
206+
verbose(`Resolved editor version from ProjectVersion.txt: ${event.version}`);
207+
}
208+
break;
209+
}
210+
case 'connection-details': {
211+
const envVars = event.envVars;
212+
if (Object.keys(envVars).length > 0) {
213+
ui.heading('Connection Details');
214+
ui.label('Project', event.projectPath);
215+
ui.label('Editor', event.editorPath);
216+
ui.heading('Environment Variables');
217+
for (const [key, value] of Object.entries(envVars)) {
218+
const display = key === 'UNITY_MCP_TOKEN' ? '***' : value;
219+
ui.label(key, display);
220+
verbose(`Setting ${key}=${display}`);
221+
}
222+
ui.divider();
223+
} else {
224+
verbose('MCP connection disabled via --no-connect or no options');
225+
}
226+
break;
227+
}
228+
case 'launching-editor': {
229+
ui.label('Project', event.projectPath);
230+
ui.label('Editor', event.editorPath);
231+
break;
232+
}
233+
case 'editor-launched': {
234+
ui.success(`Launched Unity Editor (PID: ${event.pid ?? 'unknown'})`);
235+
break;
236+
}
237+
default:
238+
break;
181239
}
182-
envVars['UNITY_MCP_AUTH_OPTION'] = options.auth;
183-
}
240+
},
241+
});
184242

185-
if (options.transport) {
186-
if (options.transport !== 'streamableHttp' && options.transport !== 'stdio') {
187-
ui.error('--transport must be "streamableHttp" or "stdio"');
188-
process.exit(1);
189-
}
190-
envVars['UNITY_MCP_TRANSPORT'] = options.transport;
191-
}
243+
if (spinner) {
244+
// Library returned before emitting `editor-resolved` — most
245+
// likely failed to locate an editor. Stop the spinner so the
246+
// error path renders cleanly.
247+
spinner.stop();
248+
spinner = undefined;
249+
}
192250

193-
if (options.startServer !== undefined) {
194-
const val = options.startServer.toLowerCase();
195-
if (val !== 'true' && val !== 'false') {
196-
ui.error('--start-server must be "true" or "false"');
197-
process.exit(1);
198-
}
199-
envVars['UNITY_MCP_START_SERVER'] = val;
251+
if (result.kind === 'failure') {
252+
// Render the failure with the original CLI surface. Two cases
253+
// get the rich "no editor found" help; everything else is a
254+
// plain ui.error.
255+
if (
256+
result.errorMessage.startsWith('Unity Editor') &&
257+
result.errorMessage.endsWith('is not installed.')
258+
) {
259+
printEditorNotFoundHelp(options.unity, 'open');
260+
} else if (result.errorMessage === 'No Unity Editor found.') {
261+
printEditorNotFoundHelp(undefined, 'open');
262+
} else {
263+
ui.error(result.errorMessage);
200264
}
265+
process.exit(1);
266+
}
201267

202-
if (Object.keys(envVars).length > 0) {
203-
env = envVars;
204-
ui.heading('Connection Details');
205-
ui.label('Project', projectPath);
206-
ui.label('Editor', editorPath);
207-
208-
ui.heading('Environment Variables');
209-
for (const [key, value] of Object.entries(envVars)) {
210-
const display = key === 'UNITY_MCP_TOKEN' ? '***' : value;
211-
ui.label(key, display);
212-
verbose(`Setting ${key}=${display}`);
213-
}
214-
ui.divider();
215-
}
216-
} else {
217-
verbose('MCP connection disabled via --no-connect');
268+
// Already-running short-circuit: the library returns success
269+
// with `alreadyRunning: true`. Preserve the original exit-0 +
270+
// friendly message.
271+
if (result.alreadyRunning) {
272+
ui.success(`Unity is already running with this project (PID: ${result.editorPid ?? 'unknown'})`);
273+
ui.info('Skipping launch. Use the running instance or close it first.');
274+
process.exit(0);
218275
}
219276

220-
ui.label('Project', projectPath);
221-
ui.label('Editor', editorPath);
222-
launchEditor(editorPath, projectPath, env);
277+
// Path is normally absolute already; resolve defensively for
278+
// verbose log parity with the legacy CLI.
279+
verbose(`Resolved project path: ${path.resolve(result.projectPath)}`);
223280
});

0 commit comments

Comments
 (0)