@ljwei-stak/dsh-model-router 0.18.1 → 0.19.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.
@@ -95,6 +95,7 @@ export const Config = z.object({
95
95
  toolInstallMethodsJson: z.string().max(8_000).default('{}').volatile(),
96
96
  toolScriptUrlsJson: z.string().max(8_000).default('{}').volatile(),
97
97
  toolAllowScriptInstall: z.boolean().default(true).volatile(),
98
+ zcodeInstallDir: z.string().max(4_096).default('').volatile(),
98
99
  })
99
100
 
100
101
  const JSON_OUTPUT = {
@@ -1387,6 +1388,8 @@ export async function startWorkbenchRun(ctx, config, request = {}, options = {})
1387
1388
  export function createRouterTerminals(options = {}) {
1388
1389
  return createTerminalManager({
1389
1390
  prepare: () => ensureNpmPrefixOnPath(),
1391
+ preferences: () => installPreferences(),
1392
+ launchOptions: () => currentInstallOptions(),
1390
1393
  onSessionEnd: record => { void routerStateStore().appendTerminalSession(record).catch(() => {}) },
1391
1394
  ...options,
1392
1395
  })
@@ -1397,11 +1400,14 @@ async function terminalHistory(limit = 20) {
1397
1400
  return (saved.terminalSessions ?? []).slice(-limit).reverse()
1398
1401
  }
1399
1402
 
1400
- function terminalServices(terminals) {
1403
+ function terminalServices(terminals, assertPreferences = () => {}) {
1401
1404
  if (!terminals) return {}
1402
1405
  return {
1403
- terminalInfo: async () => ({ ...terminals.info(), history: await terminalHistory() }),
1404
- terminalStart: async request => terminals.start(parseTerminalStart(request)),
1406
+ terminalInfo: async () => {
1407
+ const tools = await probeAllTools({ fresh: true })
1408
+ return { ...terminals.info(), tools, toolsCheckedAt: Date.now(), history: await terminalHistory() }
1409
+ },
1410
+ terminalStart: async request => { assertPreferences(); return terminals.start(parseTerminalStart(request)) },
1405
1411
  terminalRead: async request => terminals.read(parseTerminalRead(request)),
1406
1412
  terminalWrite: async request => terminals.write(parseTerminalWrite(request)),
1407
1413
  terminalResize: async request => terminals.resize(parseTerminalResize(request)),
@@ -1409,9 +1415,9 @@ function terminalServices(terminals) {
1409
1415
  }
1410
1416
  }
1411
1417
 
1412
- export function routerRemoteServices(ctx, config, { terminals = null } = {}) {
1418
+ export function routerRemoteServices(ctx, config, { terminals = null, assertPreferences } = {}) {
1413
1419
  return {
1414
- ...terminalServices(terminals),
1420
+ ...terminalServices(terminals, assertPreferences),
1415
1421
  health: async fresh => {
1416
1422
  const report = await toolHealthReport({ fresh: fresh === true })
1417
1423
  const saved = await savedState()
@@ -1444,7 +1450,7 @@ export function apply(ctx, config = {}) {
1444
1450
  installPreferenceError = null
1445
1451
  } catch (error) { installPreferenceError = errorText(error) }
1446
1452
  }
1447
- const installFields = new Set(['toolInstallDir', 'toolNpmRegistry', 'toolInstallMethodsJson', 'toolScriptUrlsJson', 'toolAllowScriptInstall'])
1453
+ const installFields = new Set(['toolInstallDir', 'toolNpmRegistry', 'toolInstallMethodsJson', 'toolScriptUrlsJson', 'toolAllowScriptInstall', 'zcodeInstallDir'])
1448
1454
  syncInstallPreferences()
1449
1455
  // Loader scopes this event to the fiber whose refs were committed.
1450
1456
  ctx.on('loader/volatile-update', paths => {
@@ -1464,9 +1470,10 @@ export function apply(ctx, config = {}) {
1464
1470
  return () => { process.off('exit', onExit); terminals.disposeAll('dispose') }
1465
1471
  }, 'model-router-galgame: interactive terminals')
1466
1472
  registerOfficialToolsRemote(ctx, {
1467
- ...routerRemoteServices(ctx, config, { terminals }),
1473
+ ...routerRemoteServices(ctx, config, { terminals, assertPreferences: assertInstallPreferences }),
1468
1474
  installSettingsError: () => installPreferenceError,
1469
1475
  installRequestedDirectory: () => valueOf(config, 'toolInstallDir', ''),
1476
+ zcodeRequestedDirectory: () => valueOf(config, 'zcodeInstallDir', ''),
1470
1477
  })
1471
1478
  }
1472
1479
  ctx.on('tools/pre-execute', async (exec, next) => {
@@ -44,6 +44,7 @@ const unavailable = () => { throw new Error('模型路由工作台服务尚未
44
44
  export function installSummary(preferences = installPreferences(), options = currentInstallOptions()) {
45
45
  return {
46
46
  installDir: preferences.installDir ?? '',
47
+ zcodeInstallDir: preferences.zcodeInstallDir ?? '',
47
48
  registry: preferences.registry ?? '',
48
49
  allowScriptInstall: preferences.allowScriptInstall !== false,
49
50
  scriptUrls: { ...(preferences.scriptUrls ?? {}) },
@@ -61,12 +62,14 @@ export function installSummary(preferences = installPreferences(), options = cur
61
62
  }
62
63
  if (tool.manager === 'signed-windows-installer') {
63
64
  entry.methodId = 'signed-windows-installer'
65
+ entry.installDir = preferences.zcodeInstallDir ?? ''
64
66
  if (options.platform === 'win32') {
65
67
  entry.command = installCommandLine(tool)
66
68
  entry.updateCommand = entry.command
67
- entry.notices = [preferences.installDir
68
- ? `请在官方安装窗口手动选择 ${preferences.installDir},完成后重新体检。`
69
- : '请在官方安装窗口选择目录并完成安装,然后重新体检。']
69
+ entry.notices = [preferences.zcodeInstallDir
70
+ ? `TUI 使用已保存的 ZCode 桌面目录 ${preferences.zcodeInstallDir}。更新时请在官方安装窗口选择同一目录,完成后重新体检。`
71
+ : '请在下方单独配置 ZCode 桌面目录,或留空自动检测。内置 CLI 随桌面版更新,TUI 的登录、模型和插件在终端中自行配置。']
72
+ entry.notices.push('ZCode 桌面目录独立于统一 npm 安装根;不会把桌面资源放进其他工具的共享目录。')
70
73
  } else entry.installError = entry.error = 'ZCode 签名安装器仅支持 Windows。'
71
74
  entry.uninstallError = 'ZCode 是桌面安装版,请在系统“应用”中卸载它。'
72
75
  return entry
@@ -191,6 +194,7 @@ export class OfficialToolsRemoteService extends TypertRemoteService {
191
194
  // The saved input can contain Host-only home/environment expansions. Keep
192
195
  // it separate from the resolved directory used by the actual command.
193
196
  install.requestedInstallDir = this.services?.installRequestedDirectory?.() ?? install.installDir
197
+ install.requestedZCodeInstallDir = this.services?.zcodeRequestedDirectory?.() ?? install.zcodeInstallDir
194
198
  return { install, hostVersion: HOST_PLUGIN_VERSION }
195
199
  }
196
200
 
@@ -9,10 +9,12 @@
9
9
  * into that interactive process, exactly as in their own terminal, so a
10
10
  * session starts only after an explicit confirmation.
11
11
  */
12
- import { getOfficialTool } from './official-tool-registry.mjs'
12
+ import { getOfficialTool, OFFICIAL_TOOLS } from './official-tool-registry.mjs'
13
13
 
14
- /** Registry CLIs that have an interactive terminal UI (ZCode is a desktop app). */
15
- export const TERMINAL_TOOL_IDS = Object.freeze(['codex', 'claude-code', 'kimi-code', 'minimax-code', 'mimo-code', 'grok-build', 'gemini'])
14
+ /** Follow the tool registry, including the TUI bundled with ZCode desktop. */
15
+ export const TERMINAL_TOOL_IDS = Object.freeze(OFFICIAL_TOOLS
16
+ .filter(tool => !tool.unsupported && (tool.probeExecutables?.length || tool.manager === 'signed-windows-installer'))
17
+ .map(tool => tool.id))
16
18
  export const TERMINAL_SHELL_ID = 'shell'
17
19
  export const TERMINAL_TARGETS = Object.freeze([TERMINAL_SHELL_ID, ...TERMINAL_TOOL_IDS])
18
20
  export const TERMINAL_MODES = Object.freeze(['interactive', 'login'])
@@ -46,8 +48,16 @@ export const TERMINAL_LOGIN_ARGS = Object.freeze({
46
48
  'mimo-code': Object.freeze(['auth', 'login']),
47
49
  'grok-build': Object.freeze(['login']),
48
50
  gemini: Object.freeze([]),
51
+ opencode: Object.freeze(['auth', 'login']),
52
+ stepcode: Object.freeze(['login']),
53
+ zcode: Object.freeze([]),
49
54
  })
50
55
 
56
+ /** Tools without a separate login command authenticate from their TUI. */
57
+ export function terminalCanLogin(target) {
58
+ return Boolean(TERMINAL_LOGIN_ARGS[target]?.length)
59
+ }
60
+
51
61
  const SESSION_ID = /^term-[a-z0-9]{6,40}$/
52
62
 
53
63
  function plainObject(value, subject) {
@@ -82,7 +92,7 @@ export function terminalTargetLabel(target) {
82
92
  export function terminalCommandPreview(target, mode = 'interactive', platform = 'win32') {
83
93
  if (target === TERMINAL_SHELL_ID) return platform === 'win32' ? 'powershell.exe -NoLogo' : '$SHELL -l'
84
94
  const tool = getOfficialTool(target)
85
- const executable = tool?.probeExecutables?.[0] ?? String(target)
95
+ const executable = target === 'zcode' ? 'ZCode 桌面版内置 TUI' : tool?.probeExecutables?.[0] ?? String(target)
86
96
  const args = mode === 'login' ? TERMINAL_LOGIN_ARGS[target] ?? [] : []
87
97
  return [executable, ...args].join(' ')
88
98
  }
@@ -92,7 +102,7 @@ export function parseTerminalStart(value) {
92
102
  if (!TERMINAL_TARGETS.includes(request.target)) throw new TypeError('target must be the shell or a fixed official CLI')
93
103
  const mode = request.mode === undefined ? 'interactive' : request.mode
94
104
  if (!TERMINAL_MODES.includes(mode)) throw new TypeError('mode must be interactive or login')
95
- if (mode === 'login' && request.target === TERMINAL_SHELL_ID) throw new TypeError('the shell has no login mode')
105
+ if (mode === 'login' && !terminalCanLogin(request.target)) throw new TypeError('this target has no login mode')
96
106
  if (!isAbsoluteDirectoryText(request.cwd)) throw new TypeError('cwd must be an absolute directory path')
97
107
  if (request.confirmed !== true) throw new TypeError('starting a terminal requires the user\'s confirmation')
98
108
  return {
@@ -13,11 +13,14 @@
13
13
  */
14
14
  import { spawn } from 'node:child_process'
15
15
  import { randomBytes } from 'node:crypto'
16
- import { stat } from 'node:fs/promises'
16
+ import { readFile, readdir, realpath, stat } from 'node:fs/promises'
17
17
  import { createRequire } from 'node:module'
18
18
  import { StringDecoder } from 'node:string_decoder'
19
- import { extname } from 'node:path'
20
- import { getOfficialTool } from './official-tool-registry.mjs'
19
+ import { homedir } from 'node:os'
20
+ import { extname, isAbsolute, join, relative, sep } from 'node:path'
21
+ import { getOfficialTool, installMethodFor } from './official-tool-registry.mjs'
22
+ import { resolveInstallPlan } from './tool-install-preferences.mjs'
23
+ import { discoverZCodeBundle } from './zcode-bundle.mjs'
21
24
  import {
22
25
  TERMINAL_LIMITS,
23
26
  TERMINAL_LOGIN_ARGS,
@@ -84,6 +87,75 @@ export function pickWindowsExecutable(candidates) {
84
87
 
85
88
  const quoteWindows = path => `"${String(path).replace(/"/g, '')}"`
86
89
 
90
+ function within(root, child) {
91
+ const part = relative(root, child)
92
+ return Boolean(part) && part !== '..' && !part.startsWith(`..${sep}`) && !isAbsolute(part)
93
+ }
94
+
95
+ /** Bind a configured npm/pnpm tool to its declared package entry, not a shared shim. */
96
+ export async function findTerminalPackageEntry(tool, plan, { platform = process.platform } = {}) {
97
+ let roots = plan.packageModuleRoots
98
+ let boundary
99
+ if (!Array.isArray(roots)) {
100
+ if (!plan.installDir) return null
101
+ if (plan.manager === 'pnpm') {
102
+ const global = join(plan.installDir, '.pnpm', 'global')
103
+ boundary = await realpath(global).catch(() => null)
104
+ const versions = await readdir(global, { withFileTypes: true }).catch(() => [])
105
+ roots = versions.filter(item => item.isDirectory() && /^\d+$/.test(item.name))
106
+ .sort((left, right) => Number(left.name) - Number(right.name))
107
+ .map(item => join(global, item.name, 'node_modules'))
108
+ } else {
109
+ const modules = platform === 'win32' ? join(plan.installDir, 'node_modules') : join(plan.installDir, 'lib', 'node_modules')
110
+ boundary = await realpath(modules).catch(() => null)
111
+ roots = [modules]
112
+ }
113
+ } else {
114
+ boundary = await realpath(plan.globalDir || plan.installDir || roots[0]).catch(() => null)
115
+ }
116
+ if (!boundary) return null
117
+ const entries = []
118
+ for (const modules of roots) {
119
+ try {
120
+ const packageRoot = await realpath(join(modules, ...tool.package.split('/')))
121
+ if (!within(boundary, packageRoot)) continue
122
+ const manifest = JSON.parse(await readFile(join(packageRoot, 'package.json'), 'utf8'))
123
+ if (manifest.name !== tool.package) continue
124
+ for (const executable of tool.probeExecutables) {
125
+ const raw = typeof manifest.bin === 'string' ? manifest.bin : manifest.bin?.[executable]
126
+ const bin = typeof raw === 'string' ? raw.replace(/\\/g, '/').replace(/^\.\//, '') : ''
127
+ if (!bin || bin.split('/').some(part => !part || part === '..') || isAbsolute(bin)) continue
128
+ const entry = await realpath(join(packageRoot, ...bin.split('/')))
129
+ if (!within(packageRoot, entry) || !(await stat(entry)).isFile()) continue
130
+ entries.push({ entry, packageRoot, version: String(manifest.version ?? '') })
131
+ }
132
+ } catch { /* not the selected tool's usable package */ }
133
+ }
134
+ // pnpm normally has one active global tree. Prefer the highest numbered
135
+ // layout when multiple layouts survive an upgrade, following its directories.
136
+ return entries.at(-1) ?? null
137
+ }
138
+
139
+ async function terminalNode(workspace, minimum, acceptedVersion) {
140
+ const { findNodeExecutable } = await import('./official-tool-executor.mjs')
141
+ return findNodeExecutable(workspace, minimum, acceptedVersion)
142
+ }
143
+
144
+ function executableLaunch(file, args, display, { platform, env }) {
145
+ if (platform !== 'win32') return { file, args, verbatim: false, display }
146
+ const extension = extname(file).toLowerCase()
147
+ if (extension === '.cmd' || extension === '.bat') {
148
+ const comspec = env.ComSpec || env.COMSPEC || 'cmd.exe'
149
+ const tail = `/d /s /c "${[quoteWindows(file), ...args].join(' ')}"`
150
+ return { file: comspec, args: tail, verbatim: true, display }
151
+ }
152
+ if (extension === '.ps1') {
153
+ const root = env.SystemRoot || env.SYSTEMROOT || 'C:\\Windows'
154
+ return { file: `${root}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`, args: ['-NoLogo', '-NoProfile', '-File', file, ...args], verbatim: false, display }
155
+ }
156
+ return { file, args, verbatim: false, display }
157
+ }
158
+
87
159
  /**
88
160
  * Build the fixed launch for one target. `locate(executable)` returns the
89
161
  * candidate paths `where`/`which` printed. Returns
@@ -92,6 +164,9 @@ const quoteWindows = path => `"${String(path).replace(/"/g, '')}"`
92
164
  */
93
165
  export async function resolveTerminalLaunch(target, mode = 'interactive', {
94
166
  platform = process.platform, env = process.env, locate = defaultLocate,
167
+ preferences = {}, home = homedir(), cwd = process.cwd(),
168
+ packageEntry = findTerminalPackageEntry, fileExists = async path => (await stat(path).catch(() => null))?.isFile() === true,
169
+ discoverBundle = discoverZCodeBundle, findNode = terminalNode,
95
170
  } = {}) {
96
171
  if (target === TERMINAL_SHELL_ID) {
97
172
  if (platform === 'win32') {
@@ -104,9 +179,47 @@ export async function resolveTerminalLaunch(target, mode = 'interactive', {
104
179
  }
105
180
  if (!TERMINAL_TOOL_IDS.includes(target)) throw new Error('只能启动系统终端或固定注册表中的官方 CLI。')
106
181
  const tool = getOfficialTool(target)
182
+ if (target === 'zcode') {
183
+ if (platform !== 'win32') throw new Error('当前仅支持 Windows ZCode 桌面版内置 TUI。')
184
+ const bundle = await discoverBundle({ installDir: preferences.zcodeInstallDir, env })
185
+ if (!bundle) throw new Error('未找到通过智谱签名与组件核验的 ZCode 桌面版;请先配置 ZCode 独立安装目录并重新体检。')
186
+ const node = await findNode(cwd, [24, 14, 0], version => Number(String(version).split('.')[0]) === 24)
187
+ // The verified desktop's electron-node entry is usable independently of
188
+ // its GUI. It supplies its own runtime when external Node 24 is absent.
189
+ return { file: node || bundle.executable, args: [bundle.cliEntry], verbatim: false,
190
+ display: `ZCode TUI · ${bundle.cliEntry}`, ...(!node ? { env: { ELECTRON_RUN_AS_NODE: '1' } } : {}) }
191
+ }
107
192
  const executable = tool.probeExecutables[0]
108
193
  const args = mode === 'login' ? [...(TERMINAL_LOGIN_ARGS[target] ?? [])] : []
109
194
  const display = [executable, ...args].join(' ')
195
+ const selectedScript = installMethodFor(tool, preferences.methods?.[target])?.kind === 'script'
196
+ const selectedPreferences = selectedScript ? { ...preferences, allowScriptInstall: true } : preferences
197
+ const plan = resolveInstallPlan(tool, undefined, selectedPreferences, { platform, env, home })
198
+ const configured = Boolean(preferences.installDir || preferences.methods?.[target])
199
+ if (plan.kind === 'package-manager' && configured) {
200
+ const boundPlan = plan.installDir ? plan : await (await import('./official-tools-runtime.mjs')).resolvePackageJobPlan(tool, plan, selectedPreferences, { options: { platform, env, home } })
201
+ const found = await packageEntry(tool, boundPlan, { platform })
202
+ if (!found) throw new Error(`未在所选 ${boundPlan.manager} 安装目录中找到 ${tool.label} 的真实命令入口;请重新体检或修复。`)
203
+ const extension = extname(found.entry).toLowerCase()
204
+ if (['.js', '.mjs', '.cjs'].includes(extension) || (platform === 'win32' && !extension)) {
205
+ const node = await findNode(cwd)
206
+ if (!node) throw new Error(`未找到可核验的 Node.js 运行时,无法启动 ${tool.label}。`)
207
+ return { file: node, args: [found.entry, ...args], verbatim: false, display }
208
+ }
209
+ return executableLaunch(found.entry, args, display, { platform, env })
210
+ }
211
+ if (plan.kind === 'script' && plan.binDir) {
212
+ for (const name of tool.probeExecutables) {
213
+ const filenames = platform === 'win32' ? WINDOWS_PREFERENCE.map(extension => `${name}${extension}`) : [name]
214
+ for (const filename of filenames) {
215
+ const file = join(plan.binDir, filename)
216
+ if (await fileExists(file)) return executableLaunch(file, args, display, { platform, env })
217
+ }
218
+ }
219
+ // A saved method or directory is authoritative: an older PATH copy cannot
220
+ // satisfy a missing install in the user's selected tool directory.
221
+ if (configured || tool.manager === 'script-installer') throw new Error(`未在所选安装目录 ${plan.binDir} 中找到 ${tool.label};请重新体检或安装。`)
222
+ }
110
223
  const candidates = await locate(executable, { platform, env })
111
224
  if (platform !== 'win32') {
112
225
  const file = candidates.map(item => String(item).trim()).find(item => item.startsWith('/'))
@@ -115,18 +228,7 @@ export async function resolveTerminalLaunch(target, mode = 'interactive', {
115
228
  }
116
229
  const file = pickWindowsExecutable(candidates)
117
230
  if (!file) throw new Error(`未在 PATH 中找到 ${executable};请先在“官方工具 · 体检”中安装 ${tool.label}。`)
118
- const extension = extname(file).toLowerCase()
119
- if (extension === '.cmd' || extension === '.bat') {
120
- // npm shims only run under cmd.exe. Every token is fixed by the registry.
121
- const comspec = env.ComSpec || env.COMSPEC || 'cmd.exe'
122
- const tail = `/d /s /c "${[quoteWindows(file), ...args].join(' ')}"`
123
- return { file: comspec, args: tail, verbatim: true, display }
124
- }
125
- if (extension === '.ps1') {
126
- const root = env.SystemRoot || env.SYSTEMROOT || 'C:\\Windows'
127
- return { file: `${root}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`, args: ['-NoLogo', '-NoProfile', '-File', file, ...args], verbatim: false, display }
128
- }
129
- return { file, args, verbatim: false, display }
231
+ return executableLaunch(file, args, display, { platform, env })
130
232
  }
131
233
 
132
234
  /** Locate an executable with where.exe / which (fixed registry names only). */
@@ -161,7 +263,10 @@ function spawnPtyProcess(pty, launch, { cwd, cols, rows, env }) {
161
263
 
162
264
  /** Pipe fallback: no TTY; the Host echoes input and maps Enter/Ctrl+C. */
163
265
  export function spawnPipeProcess(launch, { cwd, env, platform = process.platform, spawnImpl = spawn }) {
164
- const child = spawnImpl(launch.file, launch.args, {
266
+ // node-pty accepts a Windows command-line string; node:child_process needs
267
+ // an array even with windowsVerbatimArguments. Keep the prepared tail intact.
268
+ const args = typeof launch.args === 'string' ? [launch.args] : launch.args
269
+ const child = spawnImpl(launch.file, args, {
165
270
  cwd, env, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true, shell: false,
166
271
  windowsVerbatimArguments: launch.verbatim === true,
167
272
  })
@@ -220,6 +325,8 @@ export function createTerminalManager({
220
325
  statPath = stat,
221
326
  prepare = async () => {},
222
327
  environment = () => terminalEnvironment(process.env),
328
+ preferences = () => ({}),
329
+ launchOptions = () => ({}),
223
330
  onSessionEnd = () => {},
224
331
  now = Date.now,
225
332
  platform = process.platform,
@@ -329,10 +436,11 @@ export function createTerminalManager({
329
436
  try { info = await statPath(request.cwd) } catch { info = null }
330
437
  if (!info?.isDirectory?.()) throw new Error(`工作目录不存在或不是目录:${request.cwd}`)
331
438
  await prepare()
332
- const launch = await resolveLaunch(request.target, request.mode, { platform })
333
439
  const env = environment()
440
+ const launch = await resolveLaunch(request.target, request.mode, { ...launchOptions(), platform, env,
441
+ preferences: preferences(), cwd: request.cwd })
334
442
  const backend = backendInfo()
335
- const options = { cwd: request.cwd, cols: request.cols, rows: request.rows, env, platform }
443
+ const options = { cwd: request.cwd, cols: request.cols, rows: request.rows, env: { ...env, ...launch.env }, platform }
336
444
  let proc
337
445
  try {
338
446
  proc = backend.backend === 'pty'
@@ -345,7 +345,7 @@ function nodeVersion(entry, pattern = /^v(\d+\.\d+\.\d+)/) {
345
345
  })
346
346
  }
347
347
 
348
- async function findNodeExecutable(workspace, minimum = [22, 19, 0], acceptedVersion = () => true) {
348
+ export async function findNodeExecutable(workspace, minimum = [22, 19, 0], acceptedVersion = () => true) {
349
349
  await ensureNpmPrefixOnPath()
350
350
  const candidates = [process.execPath, ...pathDirectories().map(directory => join(directory, IS_WINDOWS ? 'node.exe' : 'node'))]
351
351
  for (const candidate of candidates) {
@@ -586,7 +586,7 @@ async function launchSpec(toolId, workspace, mode, modelId, task = 'Check readin
586
586
  }
587
587
  }
588
588
  if (toolId === 'zcode') {
589
- const found = await discoverZCodeBundle()
589
+ const found = await discoverZCodeBundle({ installDir: (selectionOptions?.preferences ?? installPreferences()).zcodeInstallDir })
590
590
  if (!found) return { unsupported: '未找到具有智谱有效签名、且 CLI 脚本与首次核验记录一致的 ZCode 桌面版。' }
591
591
  const nodeExecutable = await findNodeExecutable(workspace, [24, 14, 0], version =>
592
592
  Number(String(version).split('.')[0]) === 24)
@@ -676,17 +676,17 @@ export function harnessSandboxBlocker(toolId, platform = process.platform) {
676
676
  /** Check the actual trusted launch entry without starting an account call. */
677
677
  export async function officialToolReadiness(toolId, workspace = process.cwd(), selectionOptions = {}) {
678
678
  const tool = getOfficialTool(toolId)
679
- if (!tool) return { id: String(toolId), ready: false, reason: '工具不在固定官方注册表中。' }
679
+ if (!tool) return { id: String(toolId), supported: false, ready: false, reason: '工具不在固定官方注册表中。' }
680
680
  const capability = CAPABILITIES[tool.id]
681
- if (!capability?.supported) return { id: tool.id, ready: false, reason: capability?.reason ?? '当前平台不支持托管执行。' }
681
+ if (!capability?.supported) return { id: tool.id, supported: false, ready: false, reason: capability?.reason ?? '当前平台不支持托管执行。' }
682
682
  try {
683
683
  const directory = await checkedWorkspace(workspace)
684
684
  const spec = await launchSpec(tool.id, directory, capability.modes[0], null, undefined, selectionOptions)
685
685
  return spec.unsupported
686
- ? { id: tool.id, ready: false, reason: spec.unsupported }
687
- : { id: tool.id, ready: true, source: spec.source, modes: capability.modes }
686
+ ? { id: tool.id, supported: true, ready: false, reason: spec.unsupported }
687
+ : { id: tool.id, supported: true, ready: true, source: spec.source, modes: capability.modes }
688
688
  } catch (error) {
689
- return { id: tool.id, ready: false, reason: String(error?.message ?? error).slice(0, 300) }
689
+ return { id: tool.id, supported: true, ready: false, reason: String(error?.message ?? error).slice(0, 300) }
690
690
  }
691
691
  }
692
692
 
@@ -45,7 +45,7 @@ function scriptMethod(id, label, {
45
45
  shell, scriptUrl, verify, installArg = [], env = [], platforms,
46
46
  supportsInstallDir = false, installDirEnv = '', installDirSuffix = '',
47
47
  defaultScriptInstallDir, defaultScriptInstallDirEnv, scriptBinaryNames,
48
- registryEnv, installDirSubdir, uninstallUnsupportedReason, scriptFallbackUrls,
48
+ registryEnv, installDirSubdir, legacySharedInstallDir = false, sharedDefaultBin = false, uninstallUnsupportedReason, scriptFallbackUrls,
49
49
  }) {
50
50
  return Object.freeze({
51
51
  id,
@@ -64,6 +64,8 @@ function scriptMethod(id, label, {
64
64
  installDirEnv,
65
65
  installDirSuffix,
66
66
  ...(installDirSubdir ? { installDirSubdir } : {}),
67
+ ...(legacySharedInstallDir ? { legacySharedInstallDir: true } : {}),
68
+ ...(sharedDefaultBin ? { sharedDefaultBin: true } : {}),
67
69
  ...(uninstallUnsupportedReason ? { uninstallUnsupportedReason } : {}),
68
70
  ...(defaultScriptInstallDir ? { defaultScriptInstallDir } : {}),
69
71
  ...(defaultScriptInstallDirEnv ? { defaultScriptInstallDirEnv } : {}),
@@ -96,6 +98,7 @@ export const OFFICIAL_TOOLS = Object.freeze([
96
98
  shell: 'bash', scriptUrl: 'https://code.kimi.com/kimi-code/install.sh',
97
99
  platforms: ['linux', 'darwin'], supportsInstallDir: true,
98
100
  installDirEnv: 'KIMI_INSTALL_DIR', installDirSuffix: 'bin',
101
+ installDirSubdir: 'kimi-code', legacySharedInstallDir: true,
99
102
  env: ['KIMI_INSTALL_DIR'],
100
103
  scriptBinaryNames: ['kimi'],
101
104
  verify: Object.freeze({ mustInclude: ['kimi-code', 'code.kimi.com/kimi-code'] }),
@@ -104,6 +107,7 @@ export const OFFICIAL_TOOLS = Object.freeze([
104
107
  shell: 'powershell', scriptUrl: 'https://code.kimi.com/kimi-code/install.ps1',
105
108
  platforms: ['win32'], supportsInstallDir: true,
106
109
  installDirEnv: 'KIMI_INSTALL_DIR', installDirSuffix: 'bin',
110
+ installDirSubdir: 'kimi-code', legacySharedInstallDir: true,
107
111
  env: ['KIMI_INSTALL_DIR'],
108
112
  scriptBinaryNames: ['kimi.exe'],
109
113
  verify: Object.freeze({ mustInclude: ['kimi-code', 'code.kimi.com/kimi-code'] }),
@@ -135,6 +139,7 @@ export const OFFICIAL_TOOLS = Object.freeze([
135
139
  scriptFallbackUrls: ['https://downloads.claude.ai/claude-code-releases/bootstrap.sh'],
136
140
  platforms: ['linux', 'darwin'],
137
141
  scriptBinaryNames: ['claude'],
142
+ sharedDefaultBin: true,
138
143
  verify: Object.freeze({ mustInclude: ['downloads.claude.ai/claude-code-releases', 'manifest.json'] }),
139
144
  }),
140
145
  scriptMethod('script-powershell', 'irm | iex', {
@@ -142,6 +147,7 @@ export const OFFICIAL_TOOLS = Object.freeze([
142
147
  scriptFallbackUrls: ['https://downloads.claude.ai/claude-code-releases/bootstrap.ps1'],
143
148
  platforms: ['win32'],
144
149
  scriptBinaryNames: ['claude.exe'],
150
+ sharedDefaultBin: true,
145
151
  verify: Object.freeze({ mustInclude: ['downloads.claude.ai/claude-code-releases', 'manifest.json'] }),
146
152
  }),
147
153
  ]),
@@ -167,6 +173,8 @@ export const OFFICIAL_TOOLS = Object.freeze([
167
173
  shell: 'sh', scriptUrl: 'https://chatgpt.com/codex/install.sh',
168
174
  platforms: ['linux', 'darwin'], supportsInstallDir: true,
169
175
  installDirEnv: 'CODEX_INSTALL_DIR', env: ['CODEX_INSTALL_DIR'],
176
+ installDirSubdir: 'codex/bin', legacySharedInstallDir: true,
177
+ sharedDefaultBin: true,
170
178
  scriptBinaryNames: ['codex'],
171
179
  verify: Object.freeze({ mustInclude: ['releases.openai.com/codex', 'CODEX_INSTALL_DIR'] }),
172
180
  }),
@@ -210,6 +218,7 @@ export const OFFICIAL_TOOLS = Object.freeze([
210
218
  shell: 'bash', scriptUrl: 'https://filecdn.minimax.chat/public/install.sh',
211
219
  platforms: ['linux', 'darwin'], supportsInstallDir: true,
212
220
  installDirEnv: 'MCODE_INSTALL_DIR', installDirSuffix: 'bin',
221
+ installDirSubdir: 'minimax-code', legacySharedInstallDir: true,
213
222
  registryEnv: 'MCODE_NPM_REGISTRY', env: ['MCODE_INSTALL_DIR', 'MCODE_NPM_REGISTRY'],
214
223
  verify: Object.freeze({ mustInclude: ['MiniMax Code CLI', '@minimax-ai/code'] }),
215
224
  }),
@@ -217,6 +226,7 @@ export const OFFICIAL_TOOLS = Object.freeze([
217
226
  shell: 'powershell', scriptUrl: 'https://filecdn.minimax.chat/public/install.ps1',
218
227
  platforms: ['win32'], supportsInstallDir: true,
219
228
  installDirEnv: 'MCODE_INSTALL_DIR',
229
+ installDirSubdir: 'minimax-code', legacySharedInstallDir: true,
220
230
  registryEnv: 'MCODE_NPM_REGISTRY', env: ['MCODE_INSTALL_DIR', 'MCODE_NPM_REGISTRY'],
221
231
  defaultScriptInstallDir: '.minimax-code',
222
232
  scriptBinaryNames: ['mcode.cmd', 'mcode.ps1', 'mcode-tools.cmd', 'mcode-tools.ps1'],
@@ -250,6 +260,7 @@ export const OFFICIAL_TOOLS = Object.freeze([
250
260
  shell: 'powershell', scriptUrl: 'https://mimo.xiaomi.com/install.ps1',
251
261
  platforms: ['win32'], supportsInstallDir: true,
252
262
  installDirEnv: 'MIMOCODE_INSTALL_DIR', env: ['MIMOCODE_INSTALL_DIR'],
263
+ installDirSubdir: 'mimo-code', legacySharedInstallDir: true,
253
264
  scriptBinaryNames: ['mimo.exe'],
254
265
  verify: Object.freeze({ mustInclude: ['mimo.xiaomi.com', '.mimocode'] }),
255
266
  }),
@@ -276,6 +287,7 @@ export const OFFICIAL_TOOLS = Object.freeze([
276
287
  shell: 'bash', scriptUrl: 'https://x.ai/cli/install.sh',
277
288
  platforms: ['linux', 'darwin'], supportsInstallDir: true,
278
289
  installDirEnv: 'GROK_BIN_DIR', env: ['GROK_BIN_DIR'],
290
+ installDirSubdir: 'grok-build', legacySharedInstallDir: true,
279
291
  scriptBinaryNames: ['grok', 'agent'],
280
292
  verify: Object.freeze({ mustInclude: ['Grok CLI installer', 'x.ai/cli'] }),
281
293
  }),
@@ -283,6 +295,7 @@ export const OFFICIAL_TOOLS = Object.freeze([
283
295
  shell: 'powershell', scriptUrl: 'https://x.ai/cli/install.ps1',
284
296
  platforms: ['win32'], supportsInstallDir: true,
285
297
  installDirEnv: 'GROK_BIN_DIR', env: ['GROK_BIN_DIR'],
298
+ installDirSubdir: 'grok-build', legacySharedInstallDir: true,
286
299
  scriptBinaryNames: ['grok.exe', 'agent.exe', 'grove.exe', 'grove-fsmonitor.exe', 'grove-credential.exe'],
287
300
  verify: Object.freeze({ mustInclude: ['Grok CLI installer', 'x.ai/cli'] }),
288
301
  }),
@@ -365,6 +378,9 @@ export const OFFICIAL_TOOLS = Object.freeze([
365
378
  installArg: ['--install-dir'],
366
379
  env: ['STEP_INSTALL_DIR', 'STEP_RELEASE_BASE_URL'],
367
380
  supportsInstallDir: true,
381
+ // The installer replaces its runtime directories, including the entire
382
+ // node_modules tree on Windows. It must own a dedicated directory.
383
+ installDirSubdir: 'stepcode', legacySharedInstallDir: true,
368
384
  verify: Object.freeze({
369
385
  mustInclude: ['static-openapi.stepfun.com', 'stepcode installer'],
370
386
  mustNotInclude: ['anomalyco/opencode'],
@@ -377,6 +393,7 @@ export const OFFICIAL_TOOLS = Object.freeze([
377
393
  installArg: ['-InstallDir'],
378
394
  env: ['STEP_INSTALL_DIR', 'STEP_RELEASE_BASE_URL'],
379
395
  supportsInstallDir: true,
396
+ installDirSubdir: 'stepcode', legacySharedInstallDir: true,
380
397
  verify: Object.freeze({
381
398
  mustInclude: ['static-openapi.stepfun.com', 'stepcode'],
382
399
  mustNotInclude: ['anomalyco/opencode'],
@@ -388,12 +405,12 @@ export const OFFICIAL_TOOLS = Object.freeze([
388
405
  id: 'zcode',
389
406
  label: 'ZCode',
390
407
  vendor: 'Z.ai',
391
- purpose: '智谱官方 ZCode 桌面版,内含 GLM 编程代理。Windows 安装器可选择 D 盘目录。',
408
+ purpose: '智谱官方 ZCode 桌面版,内含可独立运行的 GLM CLI/TUI。可单独配置桌面目录,内置 CLI 随桌面版更新。',
392
409
  manager: 'signed-windows-installer',
393
410
  installArgs: [],
394
411
  uninstallArgs: [],
395
412
  probeExecutables: [],
396
- probeNote: '检测经过有效签名的 ZCode.exe 和同目录 GLM 资源;桌面安装器需人工选择安装位置。',
413
+ probeNote: '检测经过有效签名的 ZCode.exe 和同目录 GLM CLI/TUI 资源;桌面安装器需人工选择安装位置。',
397
414
  providerHints: ['zai', 'z.ai', 'zcode', 'glm', 'zhipu', 'bigmodel'],
398
415
  }),
399
416
  ])