11import { Command } from 'commander' ;
22import * 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' ;
54import { findUnityProcess } from '../utils/unity-process.js' ;
65import * as ui from '../utils/ui.js' ;
76import { 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+ */
1021export 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- */
2328export 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
5082export 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