zen-gitsync 2.18.20 → 2.18.22

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.
Files changed (88) hide show
  1. package/package.json +4 -1
  2. package/src/cli/ai/agent.js +9 -2
  3. package/src/cli/ai/extensions.js +1 -1
  4. package/src/cli/ai/runtime.test.js +57 -1
  5. package/src/cli/ai/termui.js +11 -2
  6. package/src/cli/ai/tools.js +266 -5
  7. package/src/cli/ai/tools.test.js +81 -0
  8. package/src/cli/ai/transport.js +1 -1
  9. package/src/cli/ai/turn.js +59 -3
  10. package/src/config.js +60 -4
  11. package/src/mcp/install.js +548 -529
  12. package/src/ui/public/assets/AgentView-DdiyBMDD.js +26 -0
  13. package/src/ui/public/assets/{AgentView-BE7GD8rc.css → AgentView-_DUYxFmp.css} +1 -1
  14. package/src/ui/public/assets/{AppVersionBadge-86tbKU20.js → AppVersionBadge-CSEM45L4.js} +2 -2
  15. package/src/ui/public/assets/{BranchSelector-CsrkmTCA.js → BranchSelector-cSx2ZxYM.js} +1 -1
  16. package/src/ui/public/assets/ChatSilenceBadge-Dtv23ZmV.js +16 -0
  17. package/src/ui/public/assets/{CommitForm-DBK0GXEh.js → CommitForm-CrAsbDkq.js} +2 -2
  18. package/src/ui/public/assets/{CommonDialog-BkuDIWro.js → CommonDialog-BtO3uKdw.js} +1 -1
  19. package/src/ui/public/assets/{EditorView-CnWsMdHg.css → EditorView-B-t-nuf0.css} +1 -1
  20. package/src/ui/public/assets/EditorView-BesiWTV2.js +10 -0
  21. package/src/ui/public/assets/{FlowExecutionViewer-ClUkAmA2.js → FlowExecutionViewer-CfU9DLFG.js} +1 -1
  22. package/src/ui/public/assets/{FlowOrchestrationWorkspace-B3sOK3fz.js → FlowOrchestrationWorkspace-D3CSRMvG.js} +1 -1
  23. package/src/ui/public/assets/{LogList-6aO0-2xh.js → LogList-CT-SRIS3.js} +4 -4
  24. package/src/ui/public/assets/MindmapView-BCGq2zdh.js +35 -0
  25. package/src/ui/public/assets/MindmapView-CoUbyadF.css +1 -0
  26. package/src/ui/public/assets/{MonitorView-fcNrgVaU.js → MonitorView-BuBJJVhR.js} +1 -1
  27. package/src/ui/public/assets/{ProjectManagerView-CZfrML6A.js → ProjectManagerView-DlSfRn9N.js} +2 -2
  28. package/src/ui/public/assets/{ProjectStartupButton-D5MREz6F.js → ProjectStartupButton-DKZttDb3.js} +1 -1
  29. package/src/ui/public/assets/RemoteManagerDialog-CVrhhUPg.js +4 -0
  30. package/src/ui/public/assets/{RemoteRepoCard-CTnmfv2y.js → RemoteRepoCard-CmdYDF24.js} +1 -1
  31. package/src/ui/public/assets/{SourceMapView-B4q5qiE8.js → SourceMapView-DClgTmda.js} +1 -1
  32. package/src/ui/public/assets/{SvgIcon-WpEiC1rI.js → SvgIcon-D5fhBiJr.js} +1 -1
  33. package/src/ui/public/assets/TaskExecutorPicker-CzvWdS_K.js +1 -0
  34. package/src/ui/public/assets/TaskExecutorPicker-Dn8SgrTZ.css +1 -0
  35. package/src/ui/public/assets/{UserInputNode-Deoc3UvQ.js → UserInputNode-CNuW_Dtu.js} +1 -1
  36. package/src/ui/public/assets/WorkbenchView-D61DxNnn.css +1 -0
  37. package/src/ui/public/assets/WorkbenchView-WKNPn-oz.js +341 -0
  38. package/src/ui/public/assets/_plugin-vue_export-helper-E2AaPXn7.js +19 -0
  39. package/src/ui/public/assets/configStore-JIX2MOKo.js +1 -0
  40. package/src/ui/public/assets/{dagre-CpKxpMNH.js → dagre-yiQDclZv.js} +3 -3
  41. package/src/ui/public/assets/{element-plus-Bz6lYur-.js → element-plus-BeZBQw4X.js} +1 -1
  42. package/src/ui/public/assets/{flow-mindmap-cTg78Krq.js → flow-mindmap-CGjPqCv0.js} +1 -1
  43. package/src/ui/public/assets/{index-cDiVh_ZL.js → index-Cjv2Z6nF.js} +134 -125
  44. package/src/ui/public/assets/index-IjYv2goK.css +1 -0
  45. package/src/ui/public/assets/{monaco-CvMddat7.js → monaco-CMQow_j-.js} +1 -1
  46. package/src/ui/public/assets/{office-docx-VOB-i4K2.js → office-docx-nuGPI15f.js} +1 -1
  47. package/src/ui/public/assets/{office-excel-CJ2JvfA8.js → office-excel-CK-HOYi8.js} +1 -1
  48. package/src/ui/public/assets/{office-pptx-Z88k9qnx.js → office-pptx-9onMeLux.js} +1 -1
  49. package/src/ui/public/assets/{relativeTime-CoXAs57j.js → relativeTime-DHhE9Ihn.js} +1 -1
  50. package/src/ui/public/assets/taskExecutionExport-DlBMELzX.js +15 -0
  51. package/src/ui/public/assets/toolsStore-h5iqPyST.js +1 -0
  52. package/src/ui/public/assets/{useWorkbenchProjects-BS59r5Rj.js → useWorkbenchProjects-DW3vBHpK.js} +1 -1
  53. package/src/ui/public/assets/{vendor-CSFGMcMB.css → vendor-CAhoE06w.css} +1 -1
  54. package/src/ui/public/assets/{vendor-C2k0GBzj.js → vendor-KxSgeiUX.js} +303 -303
  55. package/src/ui/public/assets/{vue-flow-Cnxq2qrs.js → vue-flow-BMMiguH1.js} +1 -1
  56. package/src/ui/public/index.html +14 -14
  57. package/src/ui/server/routes/config.js +13 -0
  58. package/src/ui/server/routes/fileOpen.js +1851 -1736
  59. package/src/ui/server/routes/fileOpen.test.js +39 -1
  60. package/src/ui/server/routes/workbench/agentChat.js +48 -8
  61. package/src/ui/server/routes/workbench/agentChatShared.test.js +584 -496
  62. package/src/ui/server/routes/workbench/agentEngines.js +7 -4
  63. package/src/ui/server/routes/workbench/executorModels.js +25 -4
  64. package/src/ui/server/routes/workbench/executorModels.test.js +557 -530
  65. package/src/ui/server/routes/workbench/jobActivity.js +2 -2
  66. package/src/ui/server/routes/workbench/taskRunner.codearts.test.js +252 -0
  67. package/src/ui/server/routes/workbench/taskRunner.codebuddy.test.js +317 -317
  68. package/src/ui/server/routes/workbench/taskRunner.js +216 -7
  69. package/src/ui/server/routes/workbench/taskRunner.opencode.test.js +130 -130
  70. package/src/ui/server/routes/workbench/taskRunner.pi.test.js +298 -298
  71. package/src/ui/public/assets/AgentView-Du9akUeZ.js +0 -12
  72. package/src/ui/public/assets/ChatSilenceBadge-BBoFeSqe.js +0 -21
  73. package/src/ui/public/assets/EditorAgentPanel-BMUphuG0.css +0 -1
  74. package/src/ui/public/assets/EditorAgentPanel-KiHbQPJP.js +0 -34
  75. package/src/ui/public/assets/EditorView-B18B4yNJ.js +0 -13
  76. package/src/ui/public/assets/MindmapView-BDI3D_1h.css +0 -1
  77. package/src/ui/public/assets/MindmapView-aJ9TTijE.js +0 -4
  78. package/src/ui/public/assets/RecentDirectoriesChat-DBGoUlkn.css +0 -1
  79. package/src/ui/public/assets/RecentDirectoriesChat-wfkSF6NU.js +0 -3
  80. package/src/ui/public/assets/RemoteManagerDialog-BBwNlK9J.js +0 -4
  81. package/src/ui/public/assets/WorkbenchView-CVqXb__l.css +0 -1
  82. package/src/ui/public/assets/WorkbenchView-CijzpDWN.js +0 -360
  83. package/src/ui/public/assets/_plugin-vue_export-helper-BhdsvhKo.js +0 -19
  84. package/src/ui/public/assets/agentConversations-DaAtF1v4.js +0 -10
  85. package/src/ui/public/assets/configStore-Drlw8KtR.js +0 -1
  86. package/src/ui/public/assets/index-DSQ6mp_N.css +0 -1
  87. package/src/ui/public/assets/toolsStore-CnX34C96.js +0 -1
  88. package/src/ui/public/assets/useOrchestrator-BXmxJsRl.js +0 -1
@@ -1,1736 +1,1851 @@
1
- // Copyright 2026 xz333221
2
- //
3
- // Licensed under the Apache License, Version 2.0 (the "License");
4
- // you may not use this file except in compliance with the License.
5
- // You may obtain a copy of the License at
6
- //
7
- // http://www.apache.org/licenses/LICENSE-2.0
8
- //
9
- // Unless required by applicable law or agreed to in writing, software
10
- // distributed under the License is distributed on an "AS IS" BASIS,
11
- // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
- // See the License for the specific language governing permissions and
13
- // limitations under the License.
14
- //
15
- import fs from 'fs/promises';
16
- import { asyncRoute, HttpError } from '../utils/asyncRoute.js';
17
- import path from 'path';
18
- import os from 'os';
19
- import open from 'open';
20
- import { spawn, spawnSync, execFile } from 'child_process';
21
- import { fileURLToPath } from 'node:url';
22
- import { promisify } from 'node:util';
23
- import { augmentEnvPath, pathValueOf, withPathValue } from '../../../utils/shellPath.js';
24
-
25
- const execFileAsync = promisify(execFile);
26
-
27
- const TOOL_INSTALL_PACKAGES = Object.freeze({
28
- claude: '@anthropic-ai/claude-code',
29
- codex: '@openai/codex',
30
- opencode: 'opencode-ai',
31
- codebuddy: '@tencent-ai/codebuddy-code',
32
- // Pi(earendil-works 开源终端编程 Agent,2026-10-13 随执行器接入一起补)。
33
- // npm 安装不带 postinstall(官方推荐 --ignore-scripts),但安装器走的是
34
- // `npm install -g` 原样命令,不为它单开一个变体 —— 多一个变体多一条要维护的路。
35
- pi: '@earendil-works/pi-coding-agent',
36
- dsh: '@deepseek-ai/dsh',
37
- });
38
-
39
- const TOOL_DOCS_URLS = Object.freeze({
40
- vscode: 'https://code.visualstudio.com/download',
41
- claude: 'https://docs.anthropic.com/en/docs/claude-code/getting-started',
42
- codex: 'https://help.openai.com/en/articles/11096431-openai-codex-cli-getting-started',
43
- opencode: 'https://opencode.ai/docs',
44
- codebuddy: 'https://www.codebuddy.cn/docs/cli/installation',
45
- pi: 'https://pi.dev',
46
- kimi: 'https://moonshotai.github.io/kimi-code/en/guides/getting-started',
47
- zcode: 'https://zcode.z.ai/',
48
- dsh: 'https://github.com/deepseek-ai/deepseek-harness',
49
- // 远程仓库列表页(RemoteReposList)用到的两个托管平台 CLI。它们不是"AI 工具",
50
- // 不参与 /api/check-tools 的 open-with 菜单,只借这里的白名单走 /api/install-tool。
51
- gh: 'https://cli.github.com/',
52
- gitee: 'https://gitee.com/oschina/gitee-cli',
53
- });
54
-
55
- // ── 官方桌面应用(顶栏「打开桌面端 / 下载桌面端」用)────────────────────
56
- // 顶栏这排工具大多只有 CLI;这里只收**官方有桌面版**的六个:
57
- // claude → Claude 桌面端(Code 标签页就是桌面版 Claude Code)
58
- // codex → Codex 桌面版(2026-07 起并入 ChatGPT 桌面应用,是其中一个模式)
59
- // opencode → OpenCode 桌面版
60
- // codebuddy → CodeBuddy IDE
61
- // kimi → Kimi 桌面端
62
- // dsh → DeepSeek Harness 桌面版(官方仓库 apps/desktop,Electron 壳)
63
- // 不收的:vscode(主按钮本身就是打开桌面应用,`code <目录>` 拉起的正是 GUI)、
64
- // zcode(整个就是桌面应用,主按钮直接启动)、pi(没有官方桌面版,
65
- // 社区壳不代表官方立场,不代收)。
66
- // downloadUrl 给没装桌面端的用户点(前端直接 window.open,不经服务端);
67
- // 可以是字符串,也可以是 (platform, arch) => string —— dsh 的官方固定下载
68
- // 地址按平台/架构分(win-x64 exe、mac arm64/x64 dmg,没有落地页),由服务端
69
- // 判(UI 与后端同机,process.platform/arch 就是用户机器的)。
70
- // name 是菜单展示名,刻意和 CLI 版的名字区分("Claude 桌面端" vs "Claude Code"),
71
- // 否则用户分不清点下去开的是哪个。
72
- const TOOL_DESKTOP_APPS = Object.freeze({
73
- claude: {
74
- name: 'Claude 桌面端',
75
- downloadUrl: 'https://claude.com/download',
76
- winExeName: 'Claude.exe',
77
- winExeRe: /Claude\.exe$/i,
78
- winPaths: ['AnthropicClaude/Claude.exe', 'Programs/Claude/Claude.exe', 'Claude/Claude.exe'],
79
- winLnkRe: /^Claude\.lnk$/i,
80
- macApp: 'Claude.app',
81
- linuxPaths: ['/opt/Claude/claude', '/usr/bin/claude-desktop'],
82
- },
83
- codex: {
84
- name: 'Codex 桌面版',
85
- downloadUrl: 'https://developers.openai.com/codex/app/',
86
- winExeName: 'ChatGPT.exe',
87
- winExeRe: /ChatGPT\.exe$/i,
88
- winPaths: ['Microsoft/WindowsApps/ChatGPT.exe', 'Programs/ChatGPT/ChatGPT.exe', 'OpenAI/ChatGPT/ChatGPT.exe'],
89
- winLnkRe: /^(ChatGPT|Codex)\.lnk$/i,
90
- macApp: 'ChatGPT.app',
91
- linuxPaths: [], // Codex 桌面版只有 macOS / Windows
92
- },
93
- opencode: {
94
- name: 'OpenCode 桌面版',
95
- downloadUrl: 'https://opencode.ai/download',
96
- winExeName: 'opencode.exe',
97
- winExeRe: /opencode.*\.exe$/i,
98
- winPaths: ['Programs/opencode/opencode.exe', 'Programs/OpenCode/OpenCode.exe', 'Programs/opencode-desktop/opencode-desktop.exe'],
99
- winLnkRe: /^OpenCode\.lnk$/i,
100
- macApp: 'OpenCode.app',
101
- linuxPaths: ['/usr/bin/opencode-desktop', '/usr/local/bin/opencode-desktop', '/opt/opencode-desktop/opencode-desktop'],
102
- },
103
- codebuddy: {
104
- name: 'CodeBuddy IDE',
105
- downloadUrl: 'https://www.codebuddy.cn/ide/',
106
- winExeName: 'CodeBuddy.exe',
107
- winExeRe: /CodeBuddy\.exe$/i,
108
- winPaths: ['Programs/CodeBuddy/CodeBuddy.exe', 'CodeBuddy/CodeBuddy.exe'],
109
- winLnkRe: /^CodeBuddy( IDE)?\.lnk$/i,
110
- macApp: 'CodeBuddy.app',
111
- linuxPaths: ['/usr/bin/codebuddy', '/usr/local/bin/codebuddy', '/opt/codebuddy/codebuddy'],
112
- },
113
- kimi: {
114
- name: 'Kimi 桌面端',
115
- downloadUrl: 'https://www.kimi.com/products/kimi-work',
116
- winExeName: 'Kimi.exe',
117
- winExeRe: /Kimi\.exe$/i,
118
- winPaths: ['Programs/Kimi/Kimi.exe', 'Kimi/Kimi.exe'],
119
- winLnkRe: /^Kimi\.lnk$/i,
120
- macApp: 'Kimi.app',
121
- linuxPaths: [], // Kimi 桌面端只有 Windows / macOS(Apple 芯片)
122
- },
123
- dsh: {
124
- name: 'DeepSeek Harness 桌面版',
125
- // 官方固定下载地址(apps/desktop/publishing/README:upload:latest:* 传到
126
- // download.deepseek.com/desktop/dsh-latest-*,就是安装包本身、没有落地页)。
127
- // 官方 targets 只有 win-x64 / mac-arm64 / mac-x64 —— Linux 没有官方桌面版,
128
- // 回落仓库里的 Desktop 文档。安装器是 per-user NSIS,卸载项在 HKCU 根
129
- // (scanWindowsUninstallPaths 本来就查 HKCU)。
130
- downloadUrl: (platform, arch) => {
131
- if (platform === 'win32') return 'https://download.deepseek.com/desktop/dsh-latest-windows-x64.exe';
132
- if (platform === 'darwin') {
133
- return `https://download.deepseek.com/desktop/dsh-latest-macos-${arch === 'arm64' ? 'arm64' : 'x64'}.dmg`;
134
- }
135
- return 'https://github.com/deepseek-ai/deepseek-harness/tree/master/apps/desktop';
136
- },
137
- // electron-builder productName 是 'DeepSeek Harness' → 装出来的 exe / .app /
138
- // 开始菜单快捷方式都叫这个名字(artifactName 的 deepseek-harness-* 只是安装包文件名)
139
- winExeName: 'DeepSeek Harness.exe',
140
- winExeRe: /DeepSeek Harness\.exe$/i,
141
- winPaths: ['Programs/DeepSeek Harness/DeepSeek Harness.exe'],
142
- winLnkRe: /^DeepSeek Harness\.lnk$/i,
143
- macApp: 'DeepSeek Harness.app',
144
- linuxPaths: [], // 官方没有 Linux 桌面版
145
- },
146
- });
147
-
148
- function commandExists(command, platform = process.platform) {
149
- const checker = platform === 'win32' ? 'where.exe' : 'which';
150
- const result = spawnSync(checker, [command], {
151
- stdio: 'ignore',
152
- windowsHide: true,
153
- });
154
- return result.status === 0;
155
- }
156
-
157
- /**
158
- * 从 `--version` 的输出里提取 semver 版本号。
159
- * 各工具输出格式五花八门:
160
- * dsh → "0.1.1-rc.2"
161
- * claude → "1.0.60 (Claude Code)"
162
- * code → "1.92.0 abc123def (commit ...)"
163
- * opencode → "opencode 0.1.30"
164
- * 策略:取第一个 semver 模式(含 prerelease 0.1.1-rc.2 / build 1.0.0+build.1)。
165
- * 提不出来返回 null(调用方按"未知版本"处理,不影响安装判定)。
166
- */
167
- export function parseVersionOutput(text) {
168
- if (!text) return null;
169
- const match = String(text).match(/\d+\.\d+\.\d+(?:[-+][\w.-]+)?/);
170
- return match ? match[0] : null;
171
- }
172
-
173
- // ── npm registry 最新版本查询(右键更新菜单的「当前 → 最新」提示用)──────────
174
- // 为什么走 HTTP 而不是 `npm view <pkg> version`:spawnSync('npm.cmd', ...) 在
175
- // server 子进程里不稳(prefix -g 曾返回 "…npm:3" 废地址,见 findDshExecutable
176
- // 的修复记录),HTTP 直查又快又可控。
177
- // registry 优先级:~/.npmrc 的 registry 字段(用户可能配了镜像)→ npmmirror 默认。
178
-
179
- let cachedRegistryBase = null;
180
-
181
- export function getRegistryBase(readFile = fs.readFile) {
182
- if (cachedRegistryBase) return cachedRegistryBase;
183
- return readFile(path.join(os.homedir(), '.npmrc'), 'utf8').then((npmrc) => {
184
- const match = String(npmrc).match(/^\s*registry\s*=\s*(\S+)/m);
185
- cachedRegistryBase = match
186
- ? match[1].replace(/\/+$/, '')
187
- : 'https://registry.npmmirror.com';
188
- return cachedRegistryBase;
189
- }).catch(() => (cachedRegistryBase = 'https://registry.npmmirror.com'));
190
- }
191
-
192
- export async function fetchLatestVersion(packageName, { timeoutMs = 8000, fetchImpl = fetch } = {}) {
193
- if (!packageName || !/^[\w@/.-]+$/.test(packageName)) return null;
194
- const base = await getRegistryBase();
195
- const controller = new AbortController();
196
- const timer = setTimeout(() => controller.abort(), timeoutMs);
197
- try {
198
- const resp = await fetchImpl(`${base}/${packageName}/latest`, {
199
- signal: controller.signal,
200
- headers: { accept: 'application/json' },
201
- });
202
- if (!resp.ok) return null;
203
- const data = await resp.json();
204
- return typeof data?.version === 'string' ? data.version : null;
205
- } catch {
206
- return null; // 网络失败/超时:菜单显示原文案,不影响任何功能
207
- } finally {
208
- clearTimeout(timer);
209
- }
210
- }
211
-
212
- /**
213
- * 返回当前平台可展示、可执行的安装方案。
214
- * executable/args 只在服务端使用;前端只能提交固定 tool id,不能提交命令。
215
- */
216
- export function getToolInstallers(platform = process.platform, hasCommand = commandExists) {
217
- const npmExecutable = platform === 'win32' ? 'npm.cmd' : 'npm';
218
- const npmAvailable = hasCommand('npm', platform);
219
- const installers = {};
220
-
221
- for (const [tool, packageName] of Object.entries(TOOL_INSTALL_PACKAGES)) {
222
- installers[tool] = {
223
- supported: npmAvailable,
224
- command: `npm install -g ${packageName}`,
225
- packageManager: 'npm',
226
- docsUrl: TOOL_DOCS_URLS[tool],
227
- note: npmAvailable
228
- ? '将在新终端中执行全局安装。安装过程中可能需要登录或确认权限。'
229
- : '未检测到 npm,请先安装 Node.js/npm,或按照官方文档手动安装。',
230
- executable: npmExecutable,
231
- args: ['install', '-g', packageName],
232
- // 升级变体:@latest 强制同步到最新版。已是最新时 npm 自然跳过。
233
- updateCommand: `npm install -g ${packageName}@latest`,
234
- updatePackageManager: 'npm',
235
- updateNote: '将在新终端中执行 `npm install -g <pkg>@latest`。已是最新时 npm 会跳过。',
236
- updateExecutable: npmExecutable,
237
- updateArgs: ['install', '-g', `${packageName}@latest`],
238
- };
239
- }
240
-
241
- if (platform === 'win32') {
242
- const wingetAvailable = hasCommand('winget', platform);
243
- installers.vscode = {
244
- supported: wingetAvailable,
245
- command: 'winget install --id Microsoft.VisualStudioCode -e --accept-package-agreements --accept-source-agreements',
246
- packageManager: 'winget',
247
- docsUrl: TOOL_DOCS_URLS.vscode,
248
- note: wingetAvailable
249
- ? '将在新终端中通过 winget 安装 Visual Studio Code。'
250
- : '未检测到 winget,请使用 VS Code 官方安装程序。',
251
- executable: 'winget',
252
- args: ['install', '--id', 'Microsoft.VisualStudioCode', '-e', '--accept-package-agreements', '--accept-source-agreements'],
253
- // winget 的升级动词是 upgrade(不是 install)
254
- updateCommand: wingetAvailable
255
- ? 'winget upgrade --id Microsoft.VisualStudioCode -e --accept-package-agreements --accept-source-agreements'
256
- : undefined,
257
- updatePackageManager: 'winget',
258
- updateNote: wingetAvailable
259
- ? '将在新终端中通过 winget upgrade 升级 VS Code。'
260
- : '未检测到 winget,请使用 VS Code 官方安装包。',
261
- updateExecutable: wingetAvailable ? 'winget' : undefined,
262
- updateArgs: wingetAvailable
263
- ? ['upgrade', '--id', 'Microsoft.VisualStudioCode', '-e', '--accept-package-agreements', '--accept-source-agreements']
264
- : undefined,
265
- };
266
- } else if (platform === 'darwin') {
267
- const brewAvailable = hasCommand('brew', platform);
268
- installers.vscode = {
269
- supported: brewAvailable,
270
- command: 'brew install --cask visual-studio-code',
271
- packageManager: 'Homebrew',
272
- docsUrl: TOOL_DOCS_URLS.vscode,
273
- note: brewAvailable
274
- ? '将在新终端中通过 Homebrew 安装 Visual Studio Code。'
275
- : '未检测到 Homebrew,请使用 VS Code 官方安装程序。',
276
- executable: 'brew',
277
- args: ['install', '--cask', 'visual-studio-code'],
278
- updateCommand: brewAvailable ? 'brew upgrade --cask visual-studio-code' : undefined,
279
- updatePackageManager: 'Homebrew',
280
- updateNote: brewAvailable ? '将在新终端中通过 brew upgrade 升级 VS Code。' : '未检测到 brew。',
281
- updateExecutable: brewAvailable ? 'brew' : undefined,
282
- updateArgs: brewAvailable ? ['upgrade', '--cask', 'visual-studio-code'] : undefined,
283
- };
284
- } else {
285
- const snapAvailable = hasCommand('snap', platform);
286
- installers.vscode = {
287
- supported: snapAvailable,
288
- command: 'sudo snap install code --classic',
289
- packageManager: 'snap',
290
- docsUrl: TOOL_DOCS_URLS.vscode,
291
- note: snapAvailable
292
- ? '将在新终端中通过 snap 安装 Visual Studio Code,可能需要输入系统密码。'
293
- : '当前 Linux 环境未检测到 snap,请按照发行版对应的官方说明安装。',
294
- executable: 'sudo',
295
- args: ['snap', 'install', 'code', '--classic'],
296
- // snap 的升级动词是 refresh
297
- updateCommand: snapAvailable ? 'sudo snap refresh code' : undefined,
298
- updatePackageManager: 'snap',
299
- updateNote: snapAvailable ? '将在新终端中通过 snap refresh 升级 VS Code。' : '未检测到 snap。',
300
- updateExecutable: snapAvailable ? 'sudo' : undefined,
301
- updateArgs: snapAvailable ? ['snap', 'refresh', 'code'] : undefined,
302
- };
303
- }
304
-
305
- installers.kimi = {
306
- supported: platform === 'win32' || platform === 'darwin' || platform === 'linux',
307
- command: platform === 'win32'
308
- ? 'irm https://code.kimi.com/kimi-code/install.ps1 | iex'
309
- : 'curl -fsSL https://code.kimi.com/kimi-code/install.sh | bash',
310
- packageManager: 'Kimi Code 官方安装脚本',
311
- docsUrl: TOOL_DOCS_URLS.kimi,
312
- note: '将打开终端执行官方安装脚本;Windows 首次使用还需要 Git for Windows。',
313
- kind: 'script',
314
- executionCommand: platform === 'win32'
315
- ? [
316
- "if (-not (Get-Command Get-FileHash -ErrorAction SilentlyContinue)) {",
317
- "function global:Get-FileHash { param([string]$Path, [string]$Algorithm = 'SHA256')",
318
- "$stream = [System.IO.File]::OpenRead($Path)",
319
- "try { $sha = [System.Security.Cryptography.SHA256]::Create(); try { $bytes = $sha.ComputeHash($stream) } finally { $sha.Dispose() } } finally { $stream.Dispose() }",
320
- "$hash = ([System.BitConverter]::ToString($bytes)).Replace('-', '')",
321
- "New-Object PSObject -Property @{ Algorithm = 'SHA256'; Hash = $hash; Path = $Path }",
322
- "}",
323
- "}",
324
- 'Invoke-RestMethod https://code.kimi.com/kimi-code/install.ps1 | Invoke-Expression',
325
- ].join('; ')
326
- : undefined,
327
- // 升级:kimi 官方脚本幂等,已安装时执行会自动升级到最新版,直接复用同一脚本
328
- updateCommand: platform === 'win32'
329
- ? 'irm https://code.kimi.com/kimi-code/install.ps1 | iex'
330
- : 'curl -fsSL https://code.kimi.com/kimi-code/install.sh | bash',
331
- updatePackageManager: 'Kimi Code 官方安装脚本',
332
- updateNote: '官方安装脚本幂等,已安装时执行会自动升级到最新版本。',
333
- updateKind: 'script',
334
- updateExecutionCommand: platform === 'win32'
335
- ? [
336
- "if (-not (Get-Command Get-FileHash -ErrorAction SilentlyContinue)) {",
337
- "function global:Get-FileHash { param([string]$Path, [string]$Algorithm = 'SHA256')",
338
- "$stream = [System.IO.File]::OpenRead($Path)",
339
- "try { $sha = [System.Security.Cryptography.SHA256]::Create(); try { $bytes = $sha.ComputeHash($stream) } finally { $sha.Dispose() } } finally { $stream.Dispose() }",
340
- "$hash = ([System.BitConverter]::ToString($bytes)).Replace('-', '')",
341
- "New-Object PSObject -Property @{ Algorithm = 'SHA256'; Hash = $hash; Path = $Path }",
342
- "}",
343
- "}",
344
- 'Invoke-RestMethod https://code.kimi.com/kimi-code/install.ps1 | Invoke-Expression',
345
- ].join('; ')
346
- : undefined,
347
- };
348
- installers.zcode = {
349
- supported: false,
350
- command: '打开 ZCode 官方下载页,选择当前系统安装包',
351
- packageManager: 'ZCode 官方安装包',
352
- docsUrl: TOOL_DOCS_URLS.zcode,
353
- note: 'ZCode 是桌面应用,官方提供 Windows、macOS 和 Linux 安装包,没有官方 npm CLI。',
354
- // ZCode 是桌面应用、没有 CLI 更新通道;/api/update-tool 会对它返回 400
355
- updateCommand: undefined,
356
- updatePackageManager: 'ZCode 官方安装包',
357
- updateNote: 'ZCode 是桌面应用,升级请到官网下载新版安装包覆盖安装。',
358
- };
359
-
360
- // ── 远程托管平台 CLI(远程仓库列表页用)──────────────────────────────────
361
- // 与上面的 AI 工具共用同一份白名单,好处是"前端只能提交 tool id、命令由服务端
362
- // 决定"这条安全边界不用再实现一遍。它们不出现在 open-with 菜单里 —— 那个菜单
363
- // 是 useDirectoryOpenActions.ts 里的硬编码数组,不看这张表。
364
- if (platform === 'win32') {
365
- const wingetAvailable = hasCommand('winget', platform);
366
- installers.gh = {
367
- supported: wingetAvailable,
368
- command: 'winget install --id GitHub.cli -e --accept-package-agreements --accept-source-agreements',
369
- packageManager: 'winget',
370
- docsUrl: TOOL_DOCS_URLS.gh,
371
- note: wingetAvailable
372
- ? '将在新终端中通过 winget 安装 GitHub CLI;装完请在新终端执行 gh auth login 登录。'
373
- : '未检测到 winget,请到 cli.github.com 下载安装包。',
374
- executable: 'winget',
375
- args: ['install', '--id', 'GitHub.cli', '-e', '--accept-package-agreements', '--accept-source-agreements'],
376
- updateCommand: wingetAvailable
377
- ? 'winget upgrade --id GitHub.cli -e --accept-package-agreements --accept-source-agreements'
378
- : undefined,
379
- updatePackageManager: 'winget',
380
- updateNote: wingetAvailable ? '将在新终端中通过 winget upgrade 升级 GitHub CLI。' : '未检测到 winget。',
381
- updateExecutable: wingetAvailable ? 'winget' : undefined,
382
- updateArgs: wingetAvailable
383
- ? ['upgrade', '--id', 'GitHub.cli', '-e', '--accept-package-agreements', '--accept-source-agreements']
384
- : undefined,
385
- };
386
- } else if (platform === 'darwin') {
387
- const brewAvailable = hasCommand('brew', platform);
388
- installers.gh = {
389
- supported: brewAvailable,
390
- command: 'brew install gh',
391
- packageManager: 'Homebrew',
392
- docsUrl: TOOL_DOCS_URLS.gh,
393
- note: brewAvailable
394
- ? '将在新终端中通过 Homebrew 安装 GitHub CLI;装完请执行 gh auth login 登录。'
395
- : '未检测到 Homebrew,请到 cli.github.com 下载安装包。',
396
- executable: 'brew',
397
- args: ['install', 'gh'],
398
- updateCommand: brewAvailable ? 'brew upgrade gh' : undefined,
399
- updatePackageManager: 'Homebrew',
400
- updateNote: brewAvailable ? '将在新终端中通过 brew upgrade 升级 GitHub CLI。' : '未检测到 brew。',
401
- updateExecutable: brewAvailable ? 'brew' : undefined,
402
- updateArgs: brewAvailable ? ['upgrade', 'gh'] : undefined,
403
- };
404
- } else {
405
- // Debian/Ubuntu 官方源里已有 gh;其它发行版请看 docsUrl(Arch: pacman -S github-cli)
406
- const aptAvailable = hasCommand('apt-get', platform);
407
- installers.gh = {
408
- supported: aptAvailable,
409
- command: 'sudo apt-get install -y gh',
410
- packageManager: 'apt',
411
- docsUrl: TOOL_DOCS_URLS.gh,
412
- note: aptAvailable
413
- ? '将在新终端中通过 apt 安装 GitHub CLI,可能需要输入系统密码;装完请执行 gh auth login 登录。'
414
- : '当前 Linux 环境未检测到 apt-get,请按 docsUrl 里对应发行版的说明安装。',
415
- executable: 'sudo',
416
- args: ['apt-get', 'install', '-y', 'gh'],
417
- // apt 没有"就地升级单个包"的稳定动词,不给一键更新,引导去官方文档
418
- updateCommand: undefined,
419
- updatePackageManager: 'apt',
420
- updateNote: '请按官方文档升级 GitHub CLI。',
421
- };
422
- }
423
- installers.gitee = {
424
- supported: npmAvailable,
425
- command: 'npm install -g @gitee/gitee-cli',
426
- packageManager: 'npm',
427
- docsUrl: TOOL_DOCS_URLS.gitee,
428
- note: npmAvailable
429
- ? '将在新终端中通过 npm 全局安装 Gitee CLI;装完请执行 gitee auth login 粘贴私人令牌。'
430
- : '未检测到 npm,请先安装 Node.js/npm,或按官方文档手动安装。',
431
- executable: npmExecutable,
432
- args: ['install', '-g', '@gitee/gitee-cli'],
433
- updateCommand: 'npm install -g @gitee/gitee-cli@latest',
434
- updatePackageManager: 'npm',
435
- updateNote: '将在新终端中执行 `npm install -g @gitee/gitee-cli@latest`。已是最新时 npm 会跳过。',
436
- updateExecutable: npmExecutable,
437
- updateArgs: ['install', '-g', '@gitee/gitee-cli@latest'],
438
- };
439
-
440
- return installers;
441
- }
442
-
443
- export function publicInstallerInfo(installers) {
444
- return Object.fromEntries(Object.entries(installers).map(([tool, installer]) => [tool, {
445
- supported: installer.supported,
446
- command: installer.command,
447
- packageManager: installer.packageManager,
448
- docsUrl: installer.docsUrl,
449
- note: installer.note,
450
- }]));
451
- }
452
-
453
- function spawnDetached(command, args, options = {}) {
454
- return new Promise((resolve, reject) => {
455
- const child = spawn(command, args, {
456
- detached: true,
457
- stdio: 'ignore',
458
- ...options
459
- });
460
-
461
- child.on('error', reject);
462
- child.on('spawn', () => {
463
- child.unref();
464
- resolve('success');
465
- });
466
- });
467
- }
468
-
469
- function shQuote(s) {
470
- return `'${String(s).replace(/'/g, `'\\''`)}'`;
471
- }
472
-
473
- // Linux 终端模拟器探测顺序:x-terminal-emulator 是 Debian/Ubuntu alternatives
474
- // 指向用户默认终端的通用入口,优先使用;其余为常见桌面终端的兜底
475
- const LINUX_TERMINALS = [
476
- { cmd: 'x-terminal-emulator', args: (shell) => ['-e', 'bash', '-c', shell] },
477
- { cmd: 'gnome-terminal', args: (shell) => ['--', 'bash', '-c', shell] },
478
- { cmd: 'konsole', args: (shell) => ['-e', 'bash', '-c', shell] },
479
- { cmd: 'xfce4-terminal', args: (shell) => ['-x', 'bash', '-c', shell] },
480
- { cmd: 'mate-terminal', args: (shell) => ['-e', `bash -c ${shQuote(shell)}`] },
481
- { cmd: 'alacritty', args: (shell) => ['-e', 'bash', '-c', shell] },
482
- { cmd: 'kitty', args: (shell) => ['bash', '-c', shell] },
483
- { cmd: 'xterm', args: (shell) => ['-e', 'bash', '-c', shell] },
484
- ];
485
-
486
- function findLinuxTerminal() {
487
- for (const term of LINUX_TERMINALS) {
488
- const r = spawnSync('which', [term.cmd], { stdio: 'pipe' });
489
- if (r.status === 0) return term;
490
- }
491
- return null;
492
- }
493
-
494
- // 拼一段在终端里执行的 shell:先 cd 到目标目录再启动 CLI;
495
- // 进程非零退出时停住窗口,否则 CLI 启动失败会秒关窗口,用户看不到任何报错
496
- function buildTerminalShell(dirPath, command, cliArgs) {
497
- const cmdStr = [command, ...cliArgs].map(shQuote).join(' ');
498
- return `cd ${shQuote(dirPath)} && ${cmdStr}; rc=$?; if [ $rc -ne 0 ]; then echo; echo "${command} 退出码 $rc,按回车关闭窗口"; read -r _; fi`;
499
- }
500
-
501
- // Linux/macOS 没有统一的"新开终端窗口"API:直接 spawn TUI 程序只会在后台
502
- // 跑一个无 TTY 的进程(stdio 被 ignore),用户屏幕上看不到任何东西,
503
- // 必须显式起终端模拟器把命令跑在窗口里
504
- async function launchInTerminal(dirPath, command, cliArgs = []) {
505
- const shell = buildTerminalShell(dirPath, command, cliArgs);
506
-
507
- if (process.platform === 'darwin') {
508
- const script = `tell application "Terminal"\nactivate\ndo script ${JSON.stringify(shell)}\nend tell`;
509
- return spawnDetached('osascript', ['-e', script]);
510
- }
511
-
512
- if (!process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) {
513
- throw new Error('当前会话没有图形环境(DISPLAY / WAYLAND_DISPLAY 均为空),无法打开终端窗口');
514
- }
515
- const term = findLinuxTerminal();
516
- if (!term) {
517
- throw new Error('未检测到可用的终端模拟器(gnome-terminal / konsole / xterm 等)');
518
- }
519
- return spawnDetached(term.cmd, term.args(shell));
520
- }
521
-
522
- async function launchClaudeCode(dirPath, { permissionMode } = {}) {
523
- // 透传可选的权限模式参数到 claude CLI(如 acceptEdits)
524
- // 注意:permissionMode 必须是一个 token 字符串,避免 shell 注入
525
- const SAFE_MODE = /^[a-zA-Z][a-zA-Z0-9_-]*$/;
526
- const cliArgs = [];
527
- if (permissionMode && typeof permissionMode === 'string' && SAFE_MODE.test(permissionMode)) {
528
- cliArgs.push('--permission-mode', permissionMode);
529
- }
530
-
531
- if (process.platform === 'win32') {
532
- return spawnDetached('cmd.exe', ['/c', 'start', '""', 'claude', ...cliArgs], {
533
- cwd: dirPath
534
- });
535
- }
536
-
537
- return launchInTerminal(dirPath, 'claude', cliArgs);
538
- }
539
-
540
- /**
541
- * codex 的启动参数(按权限档位)。与 claude / opencode 的档位对应关系:
542
- *
543
- * 默认(不带 token) → 一个 flag 都不加,跟随 `~/.codex/config.toml` 的
544
- * approval / sandbox 设置
545
- * 'sandboxed' → `-a never -s workspace-write`:**从不问人,但命令仍在沙箱里跑**
546
- * (写限工作区、默认无网络)。这是"不想被批准提示打断、又不想
547
- * 完全交出去"的那一档,语义上对应 claude 的「批准文件编辑」。
548
- * 'bypass' → `--dangerously-bypass-approvals-and-sandbox`:免批准 + 免沙箱。
549
- * 官方帮助原文 "Skip all confirmation prompts and execute commands
550
- * without sandboxing. EXTREMELY DANGEROUS."。**完全批准**就是它。
551
- * 'bypassPermissions'→ 同上。只为让调用方(顶栏菜单与编排台项目菜单)跟 claude 那档
552
- * 共用同一个字符串,不是第三种行为。
553
- *
554
- * ⚠️ codex 没有 claude 的 `--permission-mode`,两档都得自己拼 flag。
555
- * ⚠️ 别只看 sandbox:本机 config.toml 写的是 `sandbox_mode = "danger-full-access"`,
556
- * 也就是说这台机器的**默认档本来就没有沙箱**,完全批准档真正多出来的只是"不再弹批准"。
557
- *
558
- * 回归 `test/open-with-permission.test.mjs`(与 opencode 那份参数映射放一起验)。
559
- */
560
- const CODEX_MODE_FLAGS = new Map([
561
- ['sandboxed', ['-a', 'never', '-s', 'workspace-write']],
562
- ['bypass', ['--dangerously-bypass-approvals-and-sandbox']],
563
- ['bypassPermissions', ['--dangerously-bypass-approvals-and-sandbox']],
564
- ]);
565
-
566
- export function buildCodexArgs(permissionMode) {
567
- // Map.get 对非字符串返回 undefined → 未知 token 一律空数组,白名单式,不存在拼参数注入
568
- return [...(CODEX_MODE_FLAGS.get(permissionMode) || [])];
569
- }
570
-
571
- async function launchCodex(dirPath, { permissionMode } = {}) {
572
- // OpenAI Codex CLI - https://github.com/openai/codex
573
- const cliArgs = buildCodexArgs(permissionMode);
574
- if (process.platform === 'win32') {
575
- return spawnDetached('cmd.exe', ['/c', 'start', '""', 'codex', ...cliArgs], {
576
- cwd: dirPath
577
- });
578
- }
579
- return launchInTerminal(dirPath, 'codex', cliArgs);
580
- }
581
-
582
- /**
583
- * opencode 的「完全批准」= `--auto`(官方文档里的 auto mode):把**未被显式拒绝**的
584
- * 权限请求自动批准。
585
- *
586
- * 它不等于"什么都放行":配置里显式 `deny` 的仍然会被拦(opencode 默认就 deny 了
587
- * `read` 的 `*.env` / `*.env.*`,TUI 里 `--auto` 也一样读不到),这与 claude 的
588
- * bypassPermissions 受 `~/.claude/settings.json` deny 约束是同一个口径 —— 两边都是
589
- * "除非你明确禁止,否则全放"。
590
- *
591
- * 档位 token:`auto` 是本工具的正式叫法;`bypassPermissions` 只是为了让调用方
592
- * (顶栏菜单与编排台项目菜单)与 claude 那档共用同一个字符串。
593
- */
594
- const OPENCODE_AUTO_MODES = new Set(['auto', 'bypassPermissions']);
595
-
596
- /**
597
- * opencode 的启动参数(按权限档位)。
598
- *
599
- * 单独抽成纯函数是为了能**真的跑断言**而不用 spawn 一个终端窗口 —— 带 `--auto` 的
600
- * TUI 会弹出来并停在那里等人输入,验证不能有这种副作用。回归 `test/opencode-permission.test.mjs`。
601
- * 白名单式:非字符串 / 未知 token 一律返回空数组,不存在拼参数注入的面。
602
- */
603
- export function buildOpencodeArgs(permissionMode) {
604
- return OPENCODE_AUTO_MODES.has(permissionMode) ? ['--auto'] : [];
605
- }
606
-
607
- async function launchOpenCode(dirPath, { permissionMode } = {}) {
608
- // opencode (sst/opencode) CLI - https://opencode.ai
609
- const cliArgs = buildOpencodeArgs(permissionMode);
610
- if (process.platform === 'win32') {
611
- return spawnDetached('cmd.exe', ['/c', 'start', '""', 'opencode', ...cliArgs], {
612
- cwd: dirPath
613
- });
614
- }
615
- return launchInTerminal(dirPath, 'opencode', cliArgs);
616
- }
617
-
618
- /**
619
- * codebuddy 的权限档位(`--permission-mode`,用 CLI 自己的档位名当 token)。
620
- *
621
- * 档位名与 claude **逐字相同**(acceptEdits / bypassPermissions),所以直接沿用
622
- * claude 那套 token,不必再造一份 —— 前端两处菜单传的是同一个字符串。
623
- *
624
- * 'bypassPermissions' → `--permission-mode bypassPermissions`
625
- * 官方档位说明原文 "Skips all permission prompts",这就是它的**完全批准**档。
626
- * 实测(2026-10-14,本机 CLI)`codebuddy -p --output-format stream-json
627
- * --permission-mode bypassPermissions` 的 init 事件回传
628
- * `permissionMode: "bypassPermissions"` —— 档位真的生效,不是被静默忽略。
629
- * `-y / --dangerously-skip-permissions` 是同一档的别名(headless 执行器走的就是
630
- * -y,init 同样回传 bypassPermissions),这里用 `--permission-mode` 是因为它
631
- * 是 TUI 也认的正式写法(help: "Default permission mode for this process
632
- * (TUI, --serve Web, ACP)"),且与 claude 的启动参数形状一致。
633
- * 'acceptEdits' → `--permission-mode acceptEdits`(自动批准文件编辑,中间档)
634
- * 其余一切(undefined / 未知串 / 非字符串)→ 一个 flag 都不带,跟随 CLI 自己的配置
635
- *
636
- * 白名单式:只有表里这两个 token 能变成参数,不存在拼参数注入的面。
637
- * 回归 `test/open-with-permission.test.mjs`。
638
- */
639
- const CODEBUDDY_MODE_FLAGS = new Map([
640
- ['acceptEdits', ['--permission-mode', 'acceptEdits']],
641
- ['bypassPermissions', ['--permission-mode', 'bypassPermissions']],
642
- ]);
643
-
644
- /**
645
- * codebuddy 的启动参数(按权限档位)。
646
- *
647
- * `--port 0` 是**必须**的(与档位无关,恒定带上):CLI 每次运行都自带一个引擎服务器,
648
- * 默认端口写死 62443,而 WorkBuddy 桌面版常驻占着它 —— 实测裸跑 `codebuddy`
649
- * (交互也一样)直接 EADDRINUSE 退出。0 = 让 OS 选空闲端口,与 headless 执行器
650
- * (taskRunner.launchCodebuddyPrint)同一个口径。
651
- */
652
- export function buildCodebuddyArgs(permissionMode) {
653
- return ['--port', '0', ...(CODEBUDDY_MODE_FLAGS.get(permissionMode) || [])];
654
- }
655
-
656
- /**
657
- * 用 CodeBuddy Code 打开目录(交互 TUI,2026-10-12 随执行器接入一起补)。
658
- * 权限档位见 buildCodebuddyArgs。
659
- *
660
- * 登录态来自 ~/.codebuddy:没登录过的话 TUI 里会提示 /login 扫码。
661
- */
662
- async function launchCodebuddy(dirPath, { permissionMode } = {}) {
663
- // CodeBuddy Code CLI - https://www.codebuddy.cn/docs/cli
664
- const cliArgs = buildCodebuddyArgs(permissionMode);
665
- if (process.platform === 'win32') {
666
- return spawnDetached('cmd.exe', ['/c', 'start', '""', 'codebuddy', ...cliArgs], {
667
- cwd: dirPath
668
- });
669
- }
670
- return launchInTerminal(dirPath, 'codebuddy', cliArgs);
671
- }
672
-
673
- /**
674
- * 用 Pi 打开目录(交互 TUI,2026-10-13 随执行器接入一起补)。
675
- *
676
- * 与 codebuddy 的差别:pi **没有**引擎服务器、不占固定端口,所以裸起即可
677
- * (没有 --port 0 那一出)。登录态在 TUI 里 /login(订阅或 provider API key),
678
- * 凭据落 ~/.pi/agent。
679
- */
680
- async function launchPi(dirPath) {
681
- // Pi CLI - https://pi.dev
682
- if (process.platform === 'win32') {
683
- return spawnDetached('cmd.exe', ['/c', 'start', '""', 'pi'], {
684
- cwd: dirPath
685
- });
686
- }
687
- return launchInTerminal(dirPath, 'pi', []);
688
- }
689
-
690
- // Run the CLI shipped with this server, even when `g` is not in PATH or points
691
- // to another install. Directory paths remain spawn options, never CMD source.
692
- export async function launchGai(dirPath, {
693
- platform = process.platform,
694
- spawnFn = execFileAsync,
695
- terminalFn = launchInTerminal,
696
- } = {}) {
697
- const entry = fileURLToPath(new URL('../../../gitCommit.js', import.meta.url));
698
- if (platform !== 'win32') return terminalFn(dirPath, process.execPath, [entry, 'ai']);
699
-
700
- const psLiteral = value => `'${String(value).replace(/'/g, "''")}'`;
701
- const encode = value => Buffer.from(value, 'utf16le').toString('base64');
702
- const command = `& ${psLiteral(process.execPath)} ${psLiteral(entry)} 'ai'`;
703
- // The hidden launcher opens a visible interactive console. -NoExit leaves
704
- // startup errors readable; no model request is sent until the user types.
705
- const launcher = [
706
- "$ErrorActionPreference = 'Stop';",
707
- "Start-Process -FilePath (Join-Path $PSHOME 'powershell.exe')",
708
- `-ArgumentList '-NoLogo','-NoProfile','-NoExit','-EncodedCommand','${encode(command)}'`,
709
- '-WorkingDirectory (Get-Location).ProviderPath -WindowStyle Normal',
710
- ].join(' ');
711
- return spawnFn('powershell.exe', ['-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand', encode(launcher)], {
712
- cwd: dirPath,
713
- windowsHide: true,
714
- timeout: 10000,
715
- // 这个终端窗口以及里面跑起来的 g ai,会继承下面这份 env;把注册表里新装的 CLI
716
- // 目录补进去,否则窗口里 `gh repo list` 照样报"不是内部或外部命令"
717
- // (本进程的 PATH 是启动快照)。见 utils/shellPath.js 顶部。
718
- env: await augmentEnvPath(process.env),
719
- });
720
- }
721
-
722
- /**
723
- * kimi 的权限档位。
724
- *
725
- * 'auto' | 'bypassPermissions' → `--auto`
726
- * 官方 help 原文 "Start in Never Ask mode: never interrupts you; everything runs
727
- * and is decided automatically" —— 这才是它的**完全批准**档。
728
- * `bypassPermissions` 只是让调用方与 claude / codex / opencode 那档共用同一个
729
- * 字符串,不是另一种行为。
730
- * 其余一切(undefined / 未知串 / 非字符串)→ 一个 flag 都不带(默认档:该问的照问)
731
- *
732
- * ⚠️ 这个 `--auto` 与 opencode 的 `--auto` **同名但语义不同**:opencode 是"自动批准
733
- * 未被显式拒绝的权限"(配置里 deny 的仍拦),kimi 是"从不打断"。档位 token 各按各家
734
- * CLI 的叫法走,别硬凑一个公共词(同 codex 那段的取舍)。
735
- * ⚠️ kimi 还有一个中间档 `-y / --yolo`(Ask When Needed:常规编辑与命令自动放行,
736
- * 危险动作 / 提问 / 计划仍会问),菜单暂未暴露 —— 要加时在 KIMI_MODE_FLAGS 补一行。
737
- *
738
- * 白名单式:只有表里这两个 token 能变成参数。
739
- * 回归 `test/open-with-permission.test.mjs`。
740
- */
741
- const KIMI_MODE_FLAGS = new Map([
742
- ['auto', ['--auto']],
743
- ['bypassPermissions', ['--auto']],
744
- ]);
745
-
746
- export function buildKimiArgs(permissionMode) {
747
- return [...(KIMI_MODE_FLAGS.get(permissionMode) || [])];
748
- }
749
-
750
- async function launchKimiCode(dirPath, { permissionMode } = {}) {
751
- const cliArgs = buildKimiArgs(permissionMode);
752
- if (process.platform === 'win32') {
753
- const executable = await findKimiExecutable();
754
- if (!executable) throw new Error('未检测到 Kimi Code,请先安装 kimi CLI');
755
- return spawnDetached('cmd.exe', ['/c', 'start', '""', executable, ...cliArgs], { cwd: dirPath });
756
- }
757
- return launchInTerminal(dirPath, 'kimi', cliArgs);
758
- }
759
-
760
- async function launchDsh(dirPath) {
761
- const executable = await findDshExecutable();
762
- if (!executable) throw new Error('未检测到 DeepSeek Harness,请先全局安装 dsh CLI');
763
- if (process.platform === 'win32') {
764
- // 编码 PowerShell 命令,避免 cmd start 对 dsh.cmd 完整路径中的引号进行二次解析。
765
- const escapedExecutable = executable.replace(/'/g, "''");
766
- const script = `& '${escapedExecutable}' web`;
767
- const encoded = Buffer.from(script, 'utf16le').toString('base64');
768
- return spawnDetached('cmd.exe', [
769
- '/c', 'start', '""', 'powershell.exe', '-NoLogo', '-NoExit',
770
- '-ExecutionPolicy', 'Bypass', '-EncodedCommand', encoded,
771
- ], { cwd: dirPath });
772
- }
773
- return launchInTerminal(dirPath, executable, ['web']);
774
- }
775
-
776
- // 在 Windows 上找 dsh 可执行文件。
777
- // 优先用 where.exe dsh(覆盖 nvm4w 的 C:\nvm4w\nodejs\、默认的 %APPDATA%\npm、
778
- // pnpm/yarn 自定义 prefix、以及用户手摆到 PATH 里的任意位置),
779
- //再兜底 APPDATA\npm + npm prefix -g 两个写死路径(为 where.exe 漏检的边缘情况,
780
- //比如 PATH 里只有 junction/symlink 时)。
781
- export async function findDshExecutable() {
782
- if (process.platform !== 'win32') return commandExists('dsh') ? 'dsh' : null;
783
-
784
- const where = spawnSync('where.exe', ['dsh'], { encoding: 'utf8', windowsHide: true });
785
- if (where.status === 0 && where.stdout) {
786
- for (const line of where.stdout.split(/\r?\n/).map((s) => s.trim()).filter(Boolean)) {
787
- // 偶尔会输出 "PATH=..." 这种变量行,跳过
788
- if (/^[A-Z_]+=/i.test(line)) continue;
789
- try {
790
- const stat = await fs.stat(line);
791
- if (stat.isFile()) return line;
792
- } catch {}
793
- }
794
- }
795
-
796
- const fallback = [path.join(process.env.APPDATA || '', 'npm', 'dsh.cmd')];
797
- const npmPrefix = spawnSync('npm.cmd', ['prefix', '-g'], {
798
- encoding: 'utf8',
799
- windowsHide: true,
800
- }).stdout?.trim();
801
- if (npmPrefix) fallback.push(path.join(npmPrefix, 'dsh.cmd'));
802
-
803
- for (const candidate of fallback.filter(Boolean)) {
804
- try {
805
- const stat = await fs.stat(candidate);
806
- if (stat.isFile()) return candidate;
807
- } catch {}
808
- }
809
- return null;
810
- }
811
-
812
- async function findKimiExecutable() {
813
- if (process.platform !== 'win32') return commandExists('kimi') ? 'kimi' : null;
814
-
815
- const candidates = [
816
- path.join(process.env.USERPROFILE || '', '.kimi-code', 'bin', 'kimi.exe'),
817
- ];
818
- const registryPath = spawnSync('reg.exe', ['query', 'HKCU\\Environment', '/v', 'Path'], {
819
- encoding: 'utf8',
820
- windowsHide: true,
821
- }).stdout || '';
822
- const pathLine = registryPath.split(/\r?\n/).find(line => /\sPath\s+REG_(?:EXPAND_)?SZ\s+/i.test(line));
823
- if (pathLine) {
824
- const userPath = pathLine.replace(/^.*?REG_(?:EXPAND_)?SZ\s+/i, '').trim();
825
- for (const directory of userPath.split(';').map(value => value.trim()).filter(Boolean)) {
826
- candidates.push(path.join(directory, 'kimi.exe'));
827
- }
828
- }
829
-
830
- for (const candidate of candidates) {
831
- try {
832
- const stat = await fs.stat(candidate);
833
- if (stat.isFile()) return candidate;
834
- } catch {}
835
- }
836
- return null;
837
- }
838
-
839
- async function launchZCode(dirPath) {
840
- const executable = await findZCodeExecutable();
841
- if (!executable) throw new Error('未检测到 ZCode,请从官网下载桌面安装包');
842
- return spawnDetached(executable, [dirPath]);
843
- }
844
-
845
- async function findZCodeExecutable() {
846
- const candidates = process.platform === 'win32'
847
- ? [
848
- path.join(process.env.LOCALAPPDATA || '', 'Programs', 'ZCode', 'ZCode.exe'),
849
- path.join(process.env.LOCALAPPDATA || '', 'ZCode', 'ZCode.exe'),
850
- path.join(process.env.PROGRAMFILES || '', 'ZCode', 'ZCode.exe'),
851
- ]
852
- : process.platform === 'darwin'
853
- ? ['/Applications/ZCode.app/Contents/MacOS/ZCode']
854
- : ['/usr/bin/zcode', '/usr/local/bin/zcode'];
855
-
856
- if (process.platform === 'win32') {
857
- const registryRoots = [
858
- 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall',
859
- 'HKLM\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall',
860
- 'HKLM\\Software\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall',
861
- ];
862
- for (const root of registryRoots) {
863
- const output = spawnSync('reg.exe', ['query', root, '/s'], {
864
- encoding: 'utf8',
865
- windowsHide: true,
866
- }).stdout || '';
867
- for (const line of output.split(/\r?\n/)) {
868
- if (!/DisplayIcon|InstallLocation/i.test(line) || !/zcode/i.test(line)) continue;
869
- const value = line.replace(/^.*?REG_SZ\s+/i, '').trim().replace(/^"|"$/g, '').replace(/,\d+$/, '');
870
- if (value && !/\.(ico|png|jpg|jpeg)$/i.test(value)) candidates.push(value);
871
- }
872
- }
873
- const running = spawnSync('powershell.exe', [
874
- '-NoLogo', '-NoProfile', '-Command',
875
- "Get-Process -ErrorAction SilentlyContinue | Where-Object { $_.ProcessName -match 'ZCode' } | Select-Object -ExpandProperty Path",
876
- ], { encoding: 'utf8', windowsHide: true }).stdout || '';
877
- candidates.push(...running.split(/\r?\n/).map(value => value.trim()).filter(Boolean));
878
- }
879
-
880
- for (const candidate of candidates.filter(Boolean)) {
881
- const normalized = String(candidate).trim().replace(/^"|"$/g, '').replace(/,\d+$/, '');
882
- if (/\.(ico|png|jpg|jpeg)$/i.test(normalized)) continue;
883
- try {
884
- const stat = await fs.stat(normalized);
885
- if (stat.isFile()) return normalized;
886
- if (stat.isDirectory()) {
887
- for (const name of ['ZCode.exe', 'zcode.exe']) {
888
- const executable = path.join(normalized, name);
889
- try {
890
- const executableStat = await fs.stat(executable);
891
- if (executableStat.isFile()) return executable;
892
- } catch {}
893
- }
894
- }
895
- } catch {}
896
- }
897
- return null;
898
- }
899
-
900
- // ── 官方桌面应用探测 / 启动 ────────────────────────────────────────────
901
-
902
- async function isFileAt(p) {
903
- try { return (await fs.stat(p)).isFile(); } catch { return false; }
904
- }
905
-
906
- async function isDirAt(p) {
907
- try { return (await fs.stat(p)).isDirectory(); } catch { return false; }
908
- }
909
-
910
- /**
911
- * Windows 卸载注册表里所有 DisplayIcon / InstallLocation 的值(三个根各查一次,
912
- * 五个桌面应用共用这一遍 —— check-tools 每 10 分钟跑一轮,不能每个应用各查三遍)。
913
- * 为什么查注册表:NSIS / Inno 安装的桌面应用(Claude、CodeBuddy IDE、Kimi)
914
- * 安装路径随用户选择变,固定路径猜不中,注册表是唯一权威登记处。
915
- */
916
- function scanWindowsUninstallPaths() {
917
- const roots = [
918
- 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall',
919
- 'HKLM\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall',
920
- 'HKLM\\Software\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall',
921
- ];
922
- const values = [];
923
- for (const root of roots) {
924
- const output = spawnSync('reg.exe', ['query', root, '/s'], {
925
- encoding: 'utf8',
926
- windowsHide: true,
927
- }).stdout || '';
928
- for (const line of output.split(/\r?\n/)) {
929
- if (!/DisplayIcon|InstallLocation/i.test(line)) continue;
930
- const value = line.replace(/^.*?REG_(?:EXPAND_)?SZ\s+/i, '').trim().replace(/^"|"$/g, '').replace(/,\d+$/, '');
931
- if (value) values.push(value);
932
- }
933
- }
934
- return values;
935
- }
936
-
937
- /** 递归找一个名字匹配的项(开始菜单目录都很浅,递归开销可忽略) */
938
- async function walkFindFile(dir, nameRe) {
939
- let entries;
940
- try {
941
- entries = await fs.readdir(dir, { withFileTypes: true });
942
- } catch {
943
- return null;
944
- }
945
- for (const entry of entries) {
946
- const full = path.join(dir, entry.name);
947
- if (entry.isDirectory()) {
948
- const hit = await walkFindFile(full, nameRe);
949
- if (hit) return hit;
950
- } else if (nameRe.test(entry.name)) {
951
- return full;
952
- }
953
- }
954
- return null;
955
- }
956
-
957
- /**
958
- * 开始菜单两个 Programs 根里按文件名找快捷方式。
959
- * 为什么需要它:微软商店分发的桌面应用(Codex 桌面版所在的 ChatGPT 桌面)
960
- * 不进经典 Uninstall 注册表、安装路径也猜不中,但开始菜单一定有 .lnk;
961
- * 且 .lnk 本身就是可启动目标(ShellExecute 认快捷方式,见 launchDesktopAppTarget)。
962
- */
963
- async function findStartMenuShortcut(nameRe) {
964
- const roots = [
965
- path.join(process.env.APPDATA || '', 'Microsoft', 'Windows', 'Start Menu', 'Programs'),
966
- path.join(process.env.PROGRAMDATA || '', 'Microsoft', 'Windows', 'Start Menu', 'Programs'),
967
- ].filter(Boolean);
968
- for (const root of roots) {
969
- const hit = await walkFindFile(root, nameRe);
970
- if (hit) return hit;
971
- }
972
- return null;
973
- }
974
-
975
- /**
976
- * 探测某个工具的官方桌面应用:装没装、启动目标是什么。
977
- * target 是**可直接启动的东西**(exe / .app / .lnk / Linux 可执行文件);
978
- * 没装上 target 为 null —— 前端据此把菜单项翻成「下载 X」并打开官网地址。
979
- *
980
- * 三平台口径:
981
- * Windows 固定安装路径 → 卸载注册表(DisplayIcon 指向 exe / InstallLocation
982
- * 是目录时拼期望 exe 名)→ 开始菜单 .lnk(商店版应用走这条)
983
- * macOS /Applications/<App>.app
984
- * Linux 固定候选路径(桌面版分发最不稳的平台,猜不中就降级成"去官网下载")
985
- */
986
- async function findDesktopApp(tool) {
987
- const app = TOOL_DESKTOP_APPS[tool];
988
- if (!app) return { installed: false, target: null };
989
-
990
- if (process.platform === 'win32') {
991
- const roots = [process.env.LOCALAPPDATA, process.env.PROGRAMFILES].filter(Boolean);
992
- for (const rel of app.winPaths || []) {
993
- for (const root of roots) {
994
- const candidate = path.join(root, rel);
995
- if (await isFileAt(candidate)) return { installed: true, target: candidate };
996
- }
997
- }
998
- // 注册表值两种形态:DisplayIcon 多直接指向 exe;InstallLocation 是目录,
999
- // 在目录下拼期望的 exe 名。值与 exe 名对不上的一律不算(防误报)。
1000
- for (const value of scanWindowsUninstallPaths()) {
1001
- if (/\.(ico|png|jpg|jpeg|svg)$/i.test(value)) continue;
1002
- if (app.winExeRe.test(value) && (await isFileAt(value))) {
1003
- return { installed: true, target: value };
1004
- }
1005
- if (await isDirAt(value)) {
1006
- const exe = path.join(value, app.winExeName);
1007
- if (await isFileAt(exe)) return { installed: true, target: exe };
1008
- }
1009
- }
1010
- const lnk = await findStartMenuShortcut(app.winLnkRe);
1011
- if (lnk) return { installed: true, target: lnk };
1012
- return { installed: false, target: null };
1013
- }
1014
-
1015
- if (process.platform === 'darwin') {
1016
- const appDir = `/Applications/${app.macApp}`;
1017
- return (await isDirAt(appDir)) ? { installed: true, target: appDir } : { installed: false, target: null };
1018
- }
1019
-
1020
- for (const candidate of app.linuxPaths || []) {
1021
- if (await isFileAt(candidate)) return { installed: true, target: candidate };
1022
- }
1023
- return { installed: false, target: null };
1024
- }
1025
-
1026
- /**
1027
- * 启动桌面应用。target 的三种形态分开处理:
1028
- * .lnk(开始菜单快捷方式)不能直接 spawn(CreateProcess 不认快捷方式),
1029
- * 走 cmd /c start 让 ShellExecute 去解析;
1030
- * .app(macOS)是目录,交给 open 命令;
1031
- * 其余(Windows exe / Linux 可执行文件)直接 detached spawn,与 zcode 一致。
1032
- */
1033
- async function launchDesktopAppTarget(target) {
1034
- if (process.platform === 'win32') {
1035
- if (/\.lnk$/i.test(target)) {
1036
- return spawnDetached('cmd.exe', ['/c', 'start', '""', target]);
1037
- }
1038
- return spawnDetached(target, []);
1039
- }
1040
- if (/\.app$/i.test(target)) {
1041
- return spawnDetached('open', [target]);
1042
- }
1043
- return spawnDetached(target, []);
1044
- }
1045
-
1046
- async function launchToolInstaller(installer, dirPath = process.cwd(), { update = false } = {}) {
1047
- // update=true 时改用 updateXxx 字段(升级命令与安装命令动词不同:
1048
- // npm @latest / winget upgrade / brew upgrade / snap refresh / kimi 脚本幂等重跑)
1049
- const kindField = update ? 'updateKind' : 'kind';
1050
- const execCmdField = update ? 'updateExecutionCommand' : 'executionCommand';
1051
- const cmdField = update ? 'updateCommand' : 'command';
1052
- const executableField = update ? 'updateExecutable' : 'executable';
1053
- const argsField = update ? 'updateArgs' : 'args';
1054
-
1055
- if (installer[kindField] === 'script') {
1056
- if (process.platform === 'win32') {
1057
- // 显式创建新控制台;编码脚本可避免 cmd.exe 截获 PowerShell 管道符。
1058
- const encoded = Buffer.from(installer[execCmdField] || installer[cmdField], 'utf16le').toString('base64');
1059
- return spawnDetached('cmd.exe', [
1060
- '/c', 'start', '""', 'powershell.exe', '-NoLogo', '-NoExit',
1061
- '-ExecutionPolicy', 'Bypass', '-EncodedCommand', encoded,
1062
- ], { cwd: dirPath });
1063
- }
1064
- return launchInTerminal(dirPath, 'bash', ['-lc', installer[cmdField]]);
1065
- }
1066
- if (process.platform === 'win32') {
1067
- // 安装/更新命令完全来自服务端白名单。使用可见 cmd 窗口,让用户看到进度和错误。
1068
- const commandLine = [installer[executableField], ...installer[argsField]].join(' ');
1069
- return spawnDetached('cmd.exe', ['/c', 'start', '', 'cmd.exe', '/k', commandLine], {
1070
- cwd: dirPath,
1071
- });
1072
- }
1073
-
1074
- return launchInTerminal(dirPath, installer[executableField], installer[argsField]);
1075
- }
1076
-
1077
- /**
1078
- * 在新终端窗口里跑一条**服务端白名单内的**命令,跑完窗口不自动关。
1079
- *
1080
- * 与 launchToolInstaller 的区别:那个吃 installer 描述符(executable + args),
1081
- * 这个直接吃整条命令串。给「登录」这类**交互式** CLI 用 —— `gh auth login`
1082
- * 会走一串问答(选 GitHub.com / 协议 / 浏览器还是粘贴 token),必须在真终端里跑:
1083
- * 被 spawn 出去又没有 TTY 的话,它直接就失败了,用户屏幕上什么也看不到。
1084
- *
1085
- * Windows 用 `cmd /k`:登录完成后窗口留着,用户能看清结果;失败也能看到报错。
1086
- *
1087
- * ⚠️ command 必须是服务端自己拼出来的常量。永远不要把它接成请求体里的字符串 ——
1088
- * 那等于开了一个任意命令执行的口子(和 /api/install-tool 同一条原则)。
1089
- *
1090
- * `pathDirs` 是**必须**看的参数(2026-09-22 实测踩到):
1091
- * 本进程的 `process.env.PATH` 是"启动那一刻"的快照。用户在界面里刚装好 gh,
1092
- * 安装器改的是**注册表**里的 PATH,我们这个已经跑着的 node 进程看不见 ——
1093
- * 于是出现很割裂的一幕:**探测**能找到 gh(它走注册表 + 默认落点),界面显示
1094
- * "gh 2.101.0 已安装",可 `cmd /k gh auth login` 这个子进程按自己的 PATH 找,
1095
- * 回一句 `'gh' 不是内部或外部命令`;而用户自己新开一个 cmd 却是好的,
1096
- * 因为新进程拿到的是最新 PATH。
1097
- * 解法有两层,缺一不可:
1098
- * 1. **基线兜底**(`augmentEnvPath`):把注册表 Machine + User 的 PATH 里本进程
1099
- * 快照没有的目录补进子进程 —— 语义上等价于"这个进程是现在才启动的"。
1100
- * 所以**不传 `pathDirs` 也不会踩旧快照**,`launchGai` 这类入口不用各自记得传。
1101
- * 2. `pathDirs`:调用方**已经知道 CLI 落点**时显式**前置**,抢在别的同名命令之前。
1102
- * 注意两层语义不同:兜底是追加(不动原有优先级),pathDirs 是前置(要优先)。
1103
- * 比起"把可执行文件全路径拼进命令行",前置 PATH 还顺带绕开了 cmd.exe 那套
1104
- * "路径带空格时的嵌套引号"地狱(`C:\Program Files\GitHub CLI\gh.exe`)。
1105
- */
1106
- export async function launchCommandInTerminal(command, { cwd = process.cwd(), pathDirs = [] } = {}) {
1107
- const dirs = pathDirs.filter(Boolean);
1108
- const augmented = await augmentEnvPath(process.env);
1109
- let env;
1110
- if (dirs.length) {
1111
- env = withPathValue(augmented, [...dirs, pathValueOf(augmented)].filter(Boolean).join(path.delimiter));
1112
- } else {
1113
- // 没有要补的就传 undefined,让子进程原样继承父环境(与改动前行为一致)
1114
- env = augmented === process.env ? undefined : augmented;
1115
- }
1116
-
1117
- if (process.platform === 'win32') {
1118
- return spawnDetached('cmd.exe', ['/c', 'start', '', 'cmd.exe', '/k', command], { cwd, env });
1119
- }
1120
- // posix 侧不额外处理 PATH:那边没有"安装器改注册表"这套机制,
1121
- // 终端模拟器直接继承图形会话的环境,不会出现上面的错配。
1122
- return launchInTerminal(cwd, 'bash', ['-lc', command]);
1123
- }
1124
-
1125
- export function registerFileOpenRoutes({
1126
- app,
1127
- launchAi = launchGai,
1128
- }) {
1129
- app.post('/api/open-directory-with-g-ai', asyncRoute(async (req, res) => {
1130
- const dirPath = req.body?.path;
1131
- if (typeof dirPath !== 'string' || !dirPath.trim() || /[\0\r\n]/.test(dirPath)) {
1132
- throw new HttpError(400, '目录路径不能为空或包含控制字符');
1133
- }
1134
- const target = path.resolve(dirPath);
1135
- let stat;
1136
- try { stat = await fs.stat(target); }
1137
- catch { throw new HttpError(400, '目录不存在或不可访问'); }
1138
- if (!stat.isDirectory()) throw new HttpError(400, '指定路径不是目录');
1139
- try { await launchAi(target); }
1140
- catch (error) { throw new HttpError(400, `无法启动 g ai: ${error.message}`); }
1141
- res.json({ success: true, message: '已在新终端中启动 g ai' });
1142
- }));
1143
-
1144
- // 打开文件
1145
- app.post('/api/open-file', asyncRoute(async (req, res) => {
1146
- try {
1147
- const { filePath, context } = req.body;
1148
-
1149
- if (!filePath) {
1150
- return res.status(400).json({
1151
- success: false,
1152
- error: '文件路径不能为空'
1153
- });
1154
- }
1155
-
1156
- let targetFilePath = filePath;
1157
-
1158
- // 根据上下文处理不同的文件打开方式
1159
- switch (context) {
1160
- case 'git-status':
1161
- // Git状态:直接打开当前工作目录中的文件
1162
- targetFilePath = path.resolve(process.cwd(), filePath);
1163
- break;
1164
-
1165
- case 'commit-detail':
1166
- // 提交详情:这里可以考虑创建临时文件显示该提交时的文件内容
1167
- // 暂时先打开当前版本的文件
1168
- targetFilePath = path.resolve(process.cwd(), filePath);
1169
- break;
1170
-
1171
- case 'stash-detail':
1172
- // Stash详情:同样暂时打开当前版本的文件
1173
- targetFilePath = path.resolve(process.cwd(), filePath);
1174
- break;
1175
-
1176
- default:
1177
- targetFilePath = path.resolve(process.cwd(), filePath);
1178
- }
1179
-
1180
- try {
1181
- // 检查文件是否存在
1182
- await fs.access(targetFilePath);
1183
-
1184
- // 使用系统默认程序打开文件
1185
- await open(targetFilePath, { wait: false });
1186
-
1187
- res.json({
1188
- success: true,
1189
- message: `已打开文件: ${path.basename(targetFilePath)}`
1190
- });
1191
- } catch (error) {
1192
- // 如果文件不存在,尝试在编辑器中创建新文件
1193
- if (error.code === 'ENOENT') {
1194
- try {
1195
- await open(targetFilePath, { wait: false });
1196
- res.json({
1197
- success: true,
1198
- message: `已在编辑器中打开文件: ${path.basename(targetFilePath)}`
1199
- });
1200
- } catch (openError) {
1201
- res.status(400).json({
1202
- success: false,
1203
- error: `无法打开文件 "${path.basename(targetFilePath)}": ${openError.message}`
1204
- });
1205
- }
1206
- } else {
1207
- res.status(400).json({
1208
- success: false,
1209
- error: `无法访问文件 "${path.basename(targetFilePath)}": ${error.message}`
1210
- });
1211
- }
1212
- }
1213
- } catch (error) {
1214
- res.status(500).json({
1215
- success: false,
1216
- error: error.message
1217
- });
1218
- }
1219
- }));
1220
-
1221
- // 用VSCode打开文件
1222
- app.post('/api/open-with-vscode', asyncRoute(async (req, res) => {
1223
- try {
1224
- const { filePath, context } = req.body;
1225
-
1226
- if (!filePath) {
1227
- return res.status(400).json({
1228
- success: false,
1229
- error: '文件路径不能为空'
1230
- });
1231
- }
1232
-
1233
- let targetFilePath = filePath;
1234
-
1235
- // 根据上下文处理不同的文件打开方式
1236
- switch (context) {
1237
- case 'git-status':
1238
- case 'commit-detail':
1239
- case 'stash-detail':
1240
- targetFilePath = path.resolve(process.cwd(), filePath);
1241
- break;
1242
- default:
1243
- targetFilePath = path.resolve(process.cwd(), filePath);
1244
- }
1245
-
1246
- try {
1247
- // 使用VSCode打开文件
1248
- // 尝试使用 'code' 命令打开文件
1249
- // 使用已导入的 spawn
1250
-
1251
- // 创建一个Promise来处理spawn的异步结果
1252
- const spawnPromise = new Promise((resolve, reject) => {
1253
- const vscodeProcess = spawn('code', [targetFilePath], {
1254
- detached: true,
1255
- stdio: 'ignore'
1256
- });
1257
-
1258
- // 监听错误事件
1259
- vscodeProcess.on('error', (err) => {
1260
- reject(err);
1261
- });
1262
-
1263
- // 监听spawn事件,表示进程成功启动
1264
- vscodeProcess.on('spawn', () => {
1265
- resolve('success');
1266
- });
1267
-
1268
- vscodeProcess.unref();
1269
- });
1270
-
1271
- await spawnPromise;
1272
-
1273
- res.json({
1274
- success: true,
1275
- message: `已用VSCode打开文件: ${path.basename(targetFilePath)}`
1276
- });
1277
- } catch (error) {
1278
- // 如果VSCode命令不可用,尝试直接用open打开
1279
- try {
1280
- await open(targetFilePath, { app: { name: 'code' } });
1281
- res.json({
1282
- success: true,
1283
- message: `已用VSCode打开文件: ${path.basename(targetFilePath)}`
1284
- });
1285
- } catch (openError) {
1286
- // 最后的备用方案:尝试用系统默认编辑器打开
1287
- try {
1288
- await open(targetFilePath);
1289
- res.json({
1290
- success: true,
1291
- message: `VSCode不可用,已用系统默认程序打开文件: ${path.basename(targetFilePath)}`
1292
- });
1293
- } catch (finalError) {
1294
- res.status(400).json({
1295
- success: false,
1296
- error: `无法打开文件 "${path.basename(targetFilePath)}": VSCode可能未安装或未添加到PATH,且系统默认程序也无法打开该文件`
1297
- });
1298
- }
1299
- }
1300
- }
1301
- } catch (error) {
1302
- res.status(500).json({
1303
- success: false,
1304
- error: error.message
1305
- });
1306
- }
1307
- }));
1308
-
1309
- // 用 VSCode 打开目录
1310
- app.post('/api/open-directory-with-vscode', asyncRoute(async (req, res) => {
1311
- try {
1312
- const { path: dirPath } = req.body;
1313
- if (!dirPath) {
1314
- throw new HttpError(400, '目录路径不能为空');
1315
- }
1316
-
1317
- try {
1318
- await spawnDetached('code', [dirPath]);
1319
- res.json({ success: true, message: '已用 VSCode 打开目录' });
1320
- } catch {
1321
- // fallback:通过 open 模块指定 code 应用
1322
- try {
1323
- await open(dirPath, { app: { name: 'code' } });
1324
- res.json({ success: true, message: '已用 VSCode 打开目录' });
1325
- } catch (openError) {
1326
- res.status(400).json({ success: false, error: 'VSCode 可能未安装或未添加到 PATH' });
1327
- }
1328
- }
1329
- } catch (error) {
1330
- res.status(500).json({ success: false, error: error.message });
1331
- }
1332
- }));
1333
-
1334
- // 用 Claude Code 打开目录
1335
- app.post('/api/open-directory-with-claude-code', asyncRoute(async (req, res) => {
1336
- try {
1337
- const { path: dirPath, permissionMode } = req.body || {};
1338
- if (!dirPath) {
1339
- throw new HttpError(400, '目录路径不能为空');
1340
- }
1341
-
1342
- try {
1343
- await fs.access(dirPath);
1344
- } catch (error) {
1345
- throw new HttpError(400, `目录不存在或不可访问: ${dirPath}`);
1346
- }
1347
-
1348
- try {
1349
- await launchClaudeCode(dirPath, { permissionMode });
1350
- const message = permissionMode
1351
- ? `已用 Claude Code 打开目录(permission-mode=${permissionMode})`
1352
- : '已用 Claude Code 打开目录';
1353
- res.json({ success: true, message });
1354
- } catch (error) {
1355
- res.status(400).json({
1356
- success: false,
1357
- error: error.message || '未检测到 Claude Code,请先安装并确保可以在终端中直接运行 claude'
1358
- });
1359
- }
1360
- } catch (error) {
1361
- res.status(500).json({ success: false, error: error.message });
1362
- }
1363
- }));
1364
-
1365
- // 用 Codex 打开目录
1366
- app.post('/api/open-directory-with-codex', asyncRoute(async (req, res) => {
1367
- try {
1368
- const { path: dirPath, permissionMode } = req.body || {};
1369
- if (!dirPath) {
1370
- throw new HttpError(400, '目录路径不能为空');
1371
- }
1372
-
1373
- try {
1374
- await fs.access(dirPath);
1375
- } catch (error) {
1376
- throw new HttpError(400, `目录不存在或不可访问: ${dirPath}`);
1377
- }
1378
-
1379
- try {
1380
- await launchCodex(dirPath, { permissionMode });
1381
- const codexArgs = buildCodexArgs(permissionMode);
1382
- const message = codexArgs.length
1383
- ? `已用 Codex 打开目录(${codexArgs.join(' ')})`
1384
- : '已用 Codex 打开目录';
1385
- res.json({ success: true, message });
1386
- } catch (error) {
1387
- res.status(400).json({
1388
- success: false,
1389
- error: error.message || '未检测到 Codex,请先安装并确保可以在终端中直接运行 codex'
1390
- });
1391
- }
1392
- } catch (error) {
1393
- res.status(500).json({ success: false, error: error.message });
1394
- }
1395
- }));
1396
-
1397
- // 用 OpenCode 打开目录
1398
- app.post('/api/open-directory-with-opencode', asyncRoute(async (req, res) => {
1399
- try {
1400
- const { path: dirPath, permissionMode } = req.body || {};
1401
- if (!dirPath) {
1402
- throw new HttpError(400, '目录路径不能为空');
1403
- }
1404
-
1405
- try {
1406
- await fs.access(dirPath);
1407
- } catch (error) {
1408
- throw new HttpError(400, `目录不存在或不可访问: ${dirPath}`);
1409
- }
1410
-
1411
- try {
1412
- await launchOpenCode(dirPath, { permissionMode });
1413
- const message = OPENCODE_AUTO_MODES.has(permissionMode)
1414
- ? '已用 OpenCode 打开目录(--auto:自动批准未被显式拒绝的权限)'
1415
- : '已用 OpenCode 打开目录';
1416
- res.json({ success: true, message });
1417
- } catch (error) {
1418
- res.status(400).json({
1419
- success: false,
1420
- error: error.message || '未检测到 OpenCode,请先安装并确保可以在终端中直接运行 opencode'
1421
- });
1422
- }
1423
- } catch (error) {
1424
- res.status(500).json({ success: false, error: error.message });
1425
- }
1426
- }));
1427
-
1428
- // 用 CodeBuddy Code 打开目录
1429
- app.post('/api/open-directory-with-codebuddy', asyncRoute(async (req, res) => {
1430
- try {
1431
- const { path: dirPath, permissionMode } = req.body || {};
1432
- if (!dirPath) {
1433
- throw new HttpError(400, '目录路径不能为空');
1434
- }
1435
-
1436
- try {
1437
- await fs.access(dirPath);
1438
- } catch (error) {
1439
- throw new HttpError(400, `目录不存在或不可访问: ${dirPath}`);
1440
- }
1441
-
1442
- try {
1443
- await launchCodebuddy(dirPath, { permissionMode });
1444
- const message = CODEBUDDY_MODE_FLAGS.has(permissionMode)
1445
- ? `已用 CodeBuddy Code 打开目录(permission-mode=${permissionMode})`
1446
- : '已用 CodeBuddy Code 打开目录';
1447
- res.json({ success: true, message });
1448
- } catch (error) {
1449
- res.status(400).json({
1450
- success: false,
1451
- error: error.message || '未检测到 CodeBuddy Code,请先安装并确保可以在终端中直接运行 codebuddy'
1452
- });
1453
- }
1454
- } catch (error) {
1455
- res.status(500).json({ success: false, error: error.message });
1456
- }
1457
- }));
1458
-
1459
- // 用 Pi 打开目录
1460
- app.post('/api/open-directory-with-pi', asyncRoute(async (req, res) => {
1461
- try {
1462
- const { path: dirPath } = req.body || {};
1463
- if (!dirPath) {
1464
- throw new HttpError(400, '目录路径不能为空');
1465
- }
1466
-
1467
- try {
1468
- await fs.access(dirPath);
1469
- } catch (error) {
1470
- throw new HttpError(400, `目录不存在或不可访问: ${dirPath}`);
1471
- }
1472
-
1473
- try {
1474
- await launchPi(dirPath);
1475
- res.json({ success: true, message: '已用 Pi 打开目录' });
1476
- } catch (error) {
1477
- res.status(400).json({
1478
- success: false,
1479
- error: error.message || '未检测到 Pi,请先安装并确保可以在终端中直接运行 pi'
1480
- });
1481
- }
1482
- } catch (error) {
1483
- res.status(500).json({ success: false, error: error.message });
1484
- }
1485
- }));
1486
-
1487
- // 用 Kimi Code 打开目录
1488
- app.post('/api/open-directory-with-kimi', asyncRoute(async (req, res) => {
1489
- const { path: dirPath, permissionMode } = req.body || {};
1490
- if (!dirPath) throw new HttpError(400, '目录路径不能为空');
1491
- try {
1492
- await fs.access(dirPath);
1493
- await launchKimiCode(dirPath, { permissionMode });
1494
- const message = KIMI_MODE_FLAGS.has(permissionMode)
1495
- ? '已用 Kimi Code 打开目录(--auto:从不打断)'
1496
- : '已用 Kimi Code 打开目录';
1497
- res.json({ success: true, message });
1498
- } catch (error) {
1499
- res.status(400).json({ success: false, error: error.message || '未检测到 Kimi Code,请先安装 kimi CLI' });
1500
- }
1501
- }));
1502
-
1503
- // 用 DeepSeek Harness 打开目录
1504
- app.post('/api/open-directory-with-dsh', asyncRoute(async (req, res) => {
1505
- const { path: dirPath } = req.body || {};
1506
- if (!dirPath) throw new HttpError(400, '目录路径不能为空');
1507
- try {
1508
- await fs.access(dirPath);
1509
- await launchDsh(dirPath);
1510
- res.json({ success: true, message: '已启动 DeepSeek Harness' });
1511
- } catch (error) {
1512
- res.status(400).json({ success: false, error: error.message || '无法启动 DeepSeek Harness,请确认已安装 Node.js' });
1513
- }
1514
- }));
1515
-
1516
- // 用 ZCode 打开目录
1517
- app.post('/api/open-directory-with-zcode', asyncRoute(async (req, res) => {
1518
- const { path: dirPath } = req.body || {};
1519
- if (!dirPath) throw new HttpError(400, '目录路径不能为空');
1520
- try {
1521
- await fs.access(dirPath);
1522
- await launchZCode(dirPath);
1523
- res.json({ success: true, message: '已用 ZCode 打开目录' });
1524
- } catch (error) {
1525
- res.status(400).json({ success: false, error: error.message || '未检测到 ZCode,请从官网下载桌面安装包' });
1526
- }
1527
- }));
1528
-
1529
- // 安装本地工具:前端只能传固定 tool id,实际命令由服务端按平台从白名单选择。
1530
- app.post('/api/install-tool', asyncRoute(async (req, res) => {
1531
- const { tool } = req.body || {};
1532
- const installers = getToolInstallers();
1533
- const installer = installers[tool];
1534
-
1535
- if (!installer) {
1536
- throw new HttpError(400, '不支持的工具');
1537
- }
1538
- if (!installer.supported) {
1539
- throw new HttpError(400, installer.note || '当前系统不支持一键安装,请按照官方文档手动安装');
1540
- }
1541
-
1542
- await launchToolInstaller(installer);
1543
- res.json({
1544
- success: true,
1545
- message: '安装命令已在新终端中启动',
1546
- });
1547
- }));
1548
-
1549
- // 更新本地工具:与 install-tool 同一套白名单,只是换用 updateXxx 命令变体
1550
- // (npm @latest / winget upgrade / brew upgrade / snap refresh / kimi 脚本重跑)。
1551
- // 没有 updateCommand 的工具(如 zcode 桌面应用)返回 400。
1552
- app.post('/api/update-tool', asyncRoute(async (req, res) => {
1553
- const { tool } = req.body || {};
1554
- const installers = getToolInstallers();
1555
- const installer = installers[tool];
1556
-
1557
- if (!installer) {
1558
- throw new HttpError(400, '不支持的工具');
1559
- }
1560
- if (!installer.updateCommand) {
1561
- throw new HttpError(400, installer.updateNote || '当前工具不支持一键更新,请按官方文档手动升级');
1562
- }
1563
-
1564
- await launchToolInstaller(installer, process.cwd(), { update: true });
1565
- res.json({
1566
- success: true,
1567
- message: '更新命令已在新终端中启动',
1568
- });
1569
- }));
1570
-
1571
- // 查询 npm registry 上的最新版本,供前端更新菜单显示「当前 v1 → 最新 v2」。
1572
- // 只支持 TOOL_INSTALL_PACKAGES 里的 npm 工具;网络失败的工具返回 null(前端降级为原文案)。
1573
- // body 可选 { tools: ['claude', ...] } —— 缺省查全部 npm 工具。
1574
- app.post('/api/latest-tool-versions', asyncRoute(async (req, res) => {
1575
- const { tools } = req.body || {};
1576
- let requested;
1577
- if (tools === undefined) {
1578
- requested = Object.keys(TOOL_INSTALL_PACKAGES);
1579
- } else {
1580
- if (!Array.isArray(tools) || tools.some((t) => typeof t !== 'string')) {
1581
- throw new HttpError(400, 'tools 必须是字符串数组');
1582
- }
1583
- const unknown = tools.filter((t) => !TOOL_INSTALL_PACKAGES[t]);
1584
- if (unknown.length) {
1585
- throw new HttpError(400, `不支持的工具: ${unknown.join(', ')}`);
1586
- }
1587
- requested = tools;
1588
- }
1589
-
1590
- const entries = await Promise.all(
1591
- requested.map(async (tool) => [tool, await fetchLatestVersion(TOOL_INSTALL_PACKAGES[tool])]),
1592
- );
1593
- res.json({
1594
- success: true,
1595
- latest: Object.fromEntries(entries),
1596
- });
1597
- }));
1598
-
1599
- // 打开某个工具的官方桌面应用(顶栏 / 项目菜单里的「打开 X 桌面端」走这里)。
1600
- // 没装桌面端时返回 400 并带上官网地址 —— 前端在检测阶段就分叉了(没装直接
1601
- // window.open 官网),会走到这里说明是检测之后才卸载的边角情况。
1602
- app.post('/api/open-desktop-app', asyncRoute(async (req, res) => {
1603
- const { tool } = req.body || {};
1604
- const app = TOOL_DESKTOP_APPS[tool];
1605
- if (!app) {
1606
- throw new HttpError(400, '该工具没有桌面端');
1607
- }
1608
- const found = await findDesktopApp(tool);
1609
- if (!found.installed || !found.target) {
1610
- throw new HttpError(400, `未检测到 ${app.name},请先到官网下载安装:${app.downloadUrl}`);
1611
- }
1612
- try {
1613
- await launchDesktopAppTarget(found.target);
1614
- res.json({ success: true, message: `已打开 ${app.name}` });
1615
- } catch (error) {
1616
- res.status(500).json({ success: false, error: error.message || `无法启动 ${app.name}` });
1617
- }
1618
- }));
1619
-
1620
- // 检测本地工具是否已安装 + 采集版本号(供前端决定按钮行为、tooltip 显示版本)
1621
- // 检测方式: spawn 'tool --version',exit 0 即视为已安装;stdout/stderr 里提取 semver。
1622
- // 超时 15s:Claude CLI / opencode 等 Node 包装的工具首次冷启动
1623
- // 在 Windows 上经常 5-7s(磁盘 cache + Defender 扫描),3s 永远命中超时。
1624
- app.get('/api/check-tools', asyncRoute(async (req, res) => {
1625
- const checkCmd = (cmd) => new Promise((resolve) => {
1626
- const child = spawn(cmd, ['--version'], {
1627
- // 要捕获输出来解析版本号,不能再用 'ignore'
1628
- stdio: ['ignore', 'pipe', 'pipe'],
1629
- shell: process.platform === 'win32',
1630
- windowsHide: true,
1631
- });
1632
- let output = '';
1633
- let done = false;
1634
- const finish = (ok) => {
1635
- if (done) return;
1636
- done = true;
1637
- try { child.kill('SIGKILL'); } catch {}
1638
- resolve({ installed: ok, version: parseVersionOutput(output) });
1639
- };
1640
- // 有些 CLI 把版本号打到 stderr(如部分 Node 包装工具),两边都收
1641
- child.stdout.on('data', (chunk) => { output += chunk; });
1642
- child.stderr.on('data', (chunk) => { output += chunk; });
1643
- child.on('error', () => finish(false));
1644
- child.on('exit', (code) => finish(code === 0));
1645
- setTimeout(() => finish(false), 15000);
1646
- });
1647
-
1648
- // dsh / kimi 走 find*Executable 拿到完整路径,再补一次 --version 采版本。
1649
- // zcode 是纯 GUI 桌面应用,ZCode.exe --version 行为未知(可能弹窗),不采集。
1650
- const versionOfExecutable = (executable) => {
1651
- if (!executable) return Promise.resolve(null);
1652
- return new Promise((resolve) => {
1653
- const child = spawn(executable, ['--version'], {
1654
- stdio: ['ignore', 'pipe', 'pipe'],
1655
- shell: process.platform === 'win32',
1656
- windowsHide: true,
1657
- });
1658
- let output = '';
1659
- let done = false;
1660
- const finish = () => {
1661
- if (done) return;
1662
- done = true;
1663
- try { child.kill('SIGKILL'); } catch {}
1664
- resolve(parseVersionOutput(output));
1665
- };
1666
- child.stdout.on('data', (chunk) => { output += chunk; });
1667
- child.stderr.on('data', (chunk) => { output += chunk; });
1668
- child.on('error', () => finish());
1669
- child.on('exit', () => finish());
1670
- setTimeout(() => finish(), 15000);
1671
- });
1672
- };
1673
-
1674
- // dsh / kimi 的版本采集依赖 find 结果,用 then 链接保持全并行
1675
- // (find 会跑两次——一次拿路径一次链版本——都是纯 fs/注册表查询,开销可忽略)
1676
- // ⚠️ codebuddy 的 --version 是安全的:实测它只打印版本号就退出,不起引擎服务器
1677
- // (裸跑 codebuddy 才会占 62443,与 checkCmd 的 --version 用法无关)。
1678
- const [vscodeRes, claudeRes, codexRes, opencodeRes, codebuddyRes, piRes, kimiExecutable, zcodeExecutable, dshExecutable, kimiVersion, dshVersion] = await Promise.all([
1679
- checkCmd('code'),
1680
- checkCmd('claude'),
1681
- checkCmd('codex'),
1682
- checkCmd('opencode'),
1683
- checkCmd('codebuddy'),
1684
- checkCmd('pi'),
1685
- findKimiExecutable(),
1686
- findZCodeExecutable(),
1687
- findDshExecutable(),
1688
- findKimiExecutable().then(versionOfExecutable),
1689
- findDshExecutable().then(versionOfExecutable),
1690
- ]);
1691
-
1692
- const installers = getToolInstallers();
1693
- // 官方桌面应用探测:与 CLI 检测并行跑(各自只做 fs / 注册表查询,不起进程)。
1694
- // 前端据此把菜单项翻成「打开 X 桌面端」(装了)/「下载 X 桌面端」(没装)。
1695
- // downloadUrl 可能是函数(dsh 按平台/架构给固定下载地址),在这里统一解析成
1696
- // 字符串再下发 —— 前端只认一个地址,平台判断留在服务端(与 UI 同机)。
1697
- const desktopEntries = await Promise.all(
1698
- Object.entries(TOOL_DESKTOP_APPS).map(async ([tool, app]) => {
1699
- const found = await findDesktopApp(tool);
1700
- return [tool, {
1701
- installed: found.installed,
1702
- name: app.name,
1703
- downloadUrl: typeof app.downloadUrl === 'function'
1704
- ? app.downloadUrl(process.platform, process.arch)
1705
- : app.downloadUrl,
1706
- }];
1707
- }),
1708
- );
1709
- res.json({
1710
- success: true,
1711
- platform: process.platform,
1712
- installers: publicInstallerInfo(installers),
1713
- desktopApps: Object.fromEntries(desktopEntries),
1714
- vscode: vscodeRes.installed,
1715
- claude: claudeRes.installed,
1716
- codex: codexRes.installed,
1717
- opencode: opencodeRes.installed,
1718
- codebuddy: codebuddyRes.installed,
1719
- pi: piRes.installed,
1720
- kimi: !!kimiExecutable,
1721
- zcode: !!zcodeExecutable,
1722
- dsh: !!dshExecutable,
1723
- versions: {
1724
- vscode: vscodeRes.version,
1725
- claude: claudeRes.version,
1726
- codex: codexRes.version,
1727
- opencode: opencodeRes.version,
1728
- codebuddy: codebuddyRes.version,
1729
- pi: piRes.version,
1730
- kimi: kimiExecutable ? kimiVersion : null,
1731
- zcode: null, // 桌面应用,无 CLI 版本通道
1732
- dsh: dshExecutable ? dshVersion : null,
1733
- },
1734
- });
1735
- }));
1736
- }
1
+ // Copyright 2026 xz333221
2
+ //
3
+ // Licensed under the Apache License, Version 2.0 (the "License");
4
+ // you may not use this file except in compliance with the License.
5
+ // You may obtain a copy of the License at
6
+ //
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ //
9
+ // Unless required by applicable law or agreed to in writing, software
10
+ // distributed under the License is distributed on an "AS IS" BASIS,
11
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ // See the License for the specific language governing permissions and
13
+ // limitations under the License.
14
+ //
15
+ import fs from 'fs/promises';
16
+ import { asyncRoute, HttpError } from '../utils/asyncRoute.js';
17
+ import path from 'path';
18
+ import os from 'os';
19
+ import open from 'open';
20
+ import { spawn, spawnSync, execFile } from 'child_process';
21
+ import { fileURLToPath } from 'node:url';
22
+ import { promisify } from 'node:util';
23
+ import { augmentEnvPath, pathValueOf, withPathValue } from '../../../utils/shellPath.js';
24
+
25
+ const execFileAsync = promisify(execFile);
26
+
27
+ const TOOL_INSTALL_PACKAGES = Object.freeze({
28
+ claude: '@anthropic-ai/claude-code',
29
+ codex: '@openai/codex',
30
+ opencode: 'opencode-ai',
31
+ codebuddy: '@tencent-ai/codebuddy-code',
32
+ // Pi(earendil-works 开源终端编程 Agent,2026-10-13 随执行器接入一起补)。
33
+ // npm 安装不带 postinstall(官方推荐 --ignore-scripts),但安装器走的是
34
+ // `npm install -g` 原样命令,不为它单开一个变体 —— 多一个变体多一条要维护的路。
35
+ pi: '@earendil-works/pi-coding-agent',
36
+ dsh: '@deepseek-ai/dsh',
37
+ });
38
+
39
+ const TOOL_DOCS_URLS = Object.freeze({
40
+ vscode: 'https://code.visualstudio.com/download',
41
+ claude: 'https://docs.anthropic.com/en/docs/claude-code/getting-started',
42
+ codex: 'https://help.openai.com/en/articles/11096431-openai-codex-cli-getting-started',
43
+ opencode: 'https://opencode.ai/docs',
44
+ codebuddy: 'https://www.codebuddy.cn/docs/cli/installation',
45
+ pi: 'https://pi.dev',
46
+ // 码道 CLI 没有 npm 包(官方是 PowerShell/bash 安装脚本),所以它只在
47
+ // TOOL_DOCS_URLS 里出现、不进 TOOL_INSTALL_PACKAGES —— 安装引导走官网。
48
+ codearts: 'https://codearts.huaweicloud.com/download.html',
49
+ kimi: 'https://moonshotai.github.io/kimi-code/en/guides/getting-started',
50
+ zcode: 'https://zcode.z.ai/',
51
+ dsh: 'https://github.com/deepseek-ai/deepseek-harness',
52
+ // 远程仓库列表页(RemoteReposList)用到的两个托管平台 CLI。它们不是"AI 工具",
53
+ // 不参与 /api/check-tools 的 open-with 菜单,只借这里的白名单走 /api/install-tool。
54
+ gh: 'https://cli.github.com/',
55
+ gitee: 'https://gitee.com/oschina/gitee-cli',
56
+ });
57
+
58
+ // ── 官方桌面应用(顶栏「打开桌面端 / 下载桌面端」用)────────────────────
59
+ // 顶栏这排工具大多只有 CLI;这里只收**官方有桌面版**的六个:
60
+ // claude → Claude 桌面端(Code 标签页就是桌面版 Claude Code)
61
+ // codex → Codex 桌面版(2026-07 起并入 ChatGPT 桌面应用,是其中一个模式)
62
+ // opencode → OpenCode 桌面版
63
+ // codebuddy → CodeBuddy IDE
64
+ // kimi → Kimi 桌面端
65
+ // dsh → DeepSeek Harness 桌面版(官方仓库 apps/desktop,Electron 壳)
66
+ // 不收的:vscode(主按钮本身就是打开桌面应用,`code <目录>` 拉起的正是 GUI)、
67
+ // zcode(整个就是桌面应用,主按钮直接启动)、pi(没有官方桌面版,
68
+ // 社区壳不代表官方立场,不代收)。
69
+ // downloadUrl 给没装桌面端的用户点(前端直接 window.open,不经服务端);
70
+ // 可以是字符串,也可以是 (platform, arch) => string —— dsh 的官方固定下载
71
+ // 地址按平台/架构分(win-x64 exe、mac arm64/x64 dmg,没有落地页),由服务端
72
+ // 判(UI 与后端同机,process.platform/arch 就是用户机器的)。
73
+ // name 是菜单展示名,刻意和 CLI 版的名字区分("Claude 桌面端" vs "Claude Code"),
74
+ // 否则用户分不清点下去开的是哪个。
75
+ const TOOL_DESKTOP_APPS = Object.freeze({
76
+ claude: {
77
+ name: 'Claude 桌面端',
78
+ downloadUrl: 'https://claude.com/download',
79
+ winExeName: 'Claude.exe',
80
+ winExeRe: /Claude\.exe$/i,
81
+ winPaths: ['AnthropicClaude/Claude.exe', 'Programs/Claude/Claude.exe', 'Claude/Claude.exe'],
82
+ winLnkRe: /^Claude\.lnk$/i,
83
+ macApp: 'Claude.app',
84
+ linuxPaths: ['/opt/Claude/claude', '/usr/bin/claude-desktop'],
85
+ },
86
+ codex: {
87
+ name: 'Codex 桌面版',
88
+ downloadUrl: 'https://developers.openai.com/codex/app/',
89
+ winExeName: 'ChatGPT.exe',
90
+ winExeRe: /ChatGPT\.exe$/i,
91
+ winPaths: ['Microsoft/WindowsApps/ChatGPT.exe', 'Programs/ChatGPT/ChatGPT.exe', 'OpenAI/ChatGPT/ChatGPT.exe'],
92
+ winLnkRe: /^(ChatGPT|Codex)\.lnk$/i,
93
+ macApp: 'ChatGPT.app',
94
+ linuxPaths: [], // Codex 桌面版只有 macOS / Windows
95
+ },
96
+ opencode: {
97
+ name: 'OpenCode 桌面版',
98
+ downloadUrl: 'https://opencode.ai/download',
99
+ winExeName: 'opencode.exe',
100
+ winExeRe: /opencode.*\.exe$/i,
101
+ winPaths: ['Programs/opencode/opencode.exe', 'Programs/OpenCode/OpenCode.exe', 'Programs/opencode-desktop/opencode-desktop.exe'],
102
+ winLnkRe: /^OpenCode\.lnk$/i,
103
+ macApp: 'OpenCode.app',
104
+ linuxPaths: ['/usr/bin/opencode-desktop', '/usr/local/bin/opencode-desktop', '/opt/opencode-desktop/opencode-desktop'],
105
+ },
106
+ codebuddy: {
107
+ name: 'CodeBuddy IDE',
108
+ downloadUrl: 'https://www.codebuddy.cn/ide/',
109
+ winExeName: 'CodeBuddy.exe',
110
+ winExeRe: /CodeBuddy\.exe$/i,
111
+ winPaths: ['Programs/CodeBuddy/CodeBuddy.exe', 'CodeBuddy/CodeBuddy.exe'],
112
+ winLnkRe: /^CodeBuddy( IDE)?\.lnk$/i,
113
+ macApp: 'CodeBuddy.app',
114
+ linuxPaths: ['/usr/bin/codebuddy', '/usr/local/bin/codebuddy', '/opt/codebuddy/codebuddy'],
115
+ },
116
+ kimi: {
117
+ name: 'Kimi 桌面端',
118
+ downloadUrl: 'https://www.kimi.com/products/kimi-work',
119
+ winExeName: 'Kimi.exe',
120
+ winExeRe: /Kimi\.exe$/i,
121
+ winPaths: ['Programs/Kimi/Kimi.exe', 'Kimi/Kimi.exe'],
122
+ winLnkRe: /^Kimi\.lnk$/i,
123
+ macApp: 'Kimi.app',
124
+ linuxPaths: [], // Kimi 桌面端只有 Windows / macOS(Apple 芯片)
125
+ },
126
+ dsh: {
127
+ name: 'DeepSeek Harness 桌面版',
128
+ // 官方固定下载地址(apps/desktop/publishing/README:upload:latest:* 传到
129
+ // download.deepseek.com/desktop/dsh-latest-*,就是安装包本身、没有落地页)。
130
+ // 官方 targets 只有 win-x64 / mac-arm64 / mac-x64 —— Linux 没有官方桌面版,
131
+ // 回落仓库里的 Desktop 文档。安装器是 per-user NSIS,卸载项在 HKCU 根
132
+ // (scanWindowsUninstallPaths 本来就查 HKCU)。
133
+ downloadUrl: (platform, arch) => {
134
+ if (platform === 'win32') return 'https://download.deepseek.com/desktop/dsh-latest-windows-x64.exe';
135
+ if (platform === 'darwin') {
136
+ return `https://download.deepseek.com/desktop/dsh-latest-macos-${arch === 'arm64' ? 'arm64' : 'x64'}.dmg`;
137
+ }
138
+ return 'https://github.com/deepseek-ai/deepseek-harness/tree/master/apps/desktop';
139
+ },
140
+ // electron-builder productName 是 'DeepSeek Harness' → 装出来的 exe / .app /
141
+ // 开始菜单快捷方式都叫这个名字(artifactName 的 deepseek-harness-* 只是安装包文件名)
142
+ winExeName: 'DeepSeek Harness.exe',
143
+ winExeRe: /DeepSeek Harness\.exe$/i,
144
+ winPaths: ['Programs/DeepSeek Harness/DeepSeek Harness.exe'],
145
+ winLnkRe: /^DeepSeek Harness\.lnk$/i,
146
+ macApp: 'DeepSeek Harness.app',
147
+ linuxPaths: [], // 官方没有 Linux 桌面版
148
+ },
149
+ });
150
+
151
+ function commandExists(command, platform = process.platform) {
152
+ const checker = platform === 'win32' ? 'where.exe' : 'which';
153
+ const result = spawnSync(checker, [command], {
154
+ stdio: 'ignore',
155
+ windowsHide: true,
156
+ });
157
+ return result.status === 0;
158
+ }
159
+
160
+ /**
161
+ * 从 `--version` 的输出里提取 semver 版本号。
162
+ * 各工具输出格式五花八门:
163
+ * dsh → "0.1.1-rc.2"
164
+ * claude → "1.0.60 (Claude Code)"
165
+ * code → "1.92.0 abc123def (commit ...)"
166
+ * opencode → "opencode 0.1.30"
167
+ * 策略:取第一个 semver 模式(含 prerelease 0.1.1-rc.2 / build 1.0.0+build.1)。
168
+ * 提不出来返回 null(调用方按"未知版本"处理,不影响安装判定)。
169
+ */
170
+ export function parseVersionOutput(text) {
171
+ if (!text) return null;
172
+ const match = String(text).match(/\d+\.\d+\.\d+(?:[-+][\w.-]+)?/);
173
+ return match ? match[0] : null;
174
+ }
175
+
176
+ // ── npm registry 最新版本查询(右键更新菜单的「当前 → 最新」提示用)──────────
177
+ // 为什么走 HTTP 而不是 `npm view <pkg> version`:spawnSync('npm.cmd', ...) 在
178
+ // server 子进程里不稳(prefix -g 曾返回 "…npm:3" 废地址,见 findDshExecutable
179
+ // 的修复记录),HTTP 直查又快又可控。
180
+ // registry 优先级:~/.npmrc 的 registry 字段(用户可能配了镜像)→ npmmirror 默认。
181
+
182
+ let cachedRegistryBase = null;
183
+
184
+ export function getRegistryBase(readFile = fs.readFile) {
185
+ if (cachedRegistryBase) return cachedRegistryBase;
186
+ return readFile(path.join(os.homedir(), '.npmrc'), 'utf8').then((npmrc) => {
187
+ const match = String(npmrc).match(/^\s*registry\s*=\s*(\S+)/m);
188
+ cachedRegistryBase = match
189
+ ? match[1].replace(/\/+$/, '')
190
+ : 'https://registry.npmmirror.com';
191
+ return cachedRegistryBase;
192
+ }).catch(() => (cachedRegistryBase = 'https://registry.npmmirror.com'));
193
+ }
194
+
195
+ export async function fetchLatestVersion(packageName, { timeoutMs = 8000, fetchImpl = fetch } = {}) {
196
+ if (!packageName || !/^[\w@/.-]+$/.test(packageName)) return null;
197
+ const base = await getRegistryBase();
198
+ const controller = new AbortController();
199
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
200
+ try {
201
+ const resp = await fetchImpl(`${base}/${packageName}/latest`, {
202
+ signal: controller.signal,
203
+ headers: { accept: 'application/json' },
204
+ });
205
+ if (!resp.ok) return null;
206
+ const data = await resp.json();
207
+ return typeof data?.version === 'string' ? data.version : null;
208
+ } catch {
209
+ return null; // 网络失败/超时:菜单显示原文案,不影响任何功能
210
+ } finally {
211
+ clearTimeout(timer);
212
+ }
213
+ }
214
+
215
+ /**
216
+ * 返回当前平台可展示、可执行的安装方案。
217
+ * executable/args 只在服务端使用;前端只能提交固定 tool id,不能提交命令。
218
+ */
219
+ export function getToolInstallers(platform = process.platform, hasCommand = commandExists) {
220
+ const npmExecutable = platform === 'win32' ? 'npm.cmd' : 'npm';
221
+ const npmAvailable = hasCommand('npm', platform);
222
+ const installers = {};
223
+
224
+ for (const [tool, packageName] of Object.entries(TOOL_INSTALL_PACKAGES)) {
225
+ installers[tool] = {
226
+ supported: npmAvailable,
227
+ command: `npm install -g ${packageName}`,
228
+ packageManager: 'npm',
229
+ docsUrl: TOOL_DOCS_URLS[tool],
230
+ note: npmAvailable
231
+ ? '将在新终端中执行全局安装。安装过程中可能需要登录或确认权限。'
232
+ : '未检测到 npm,请先安装 Node.js/npm,或按照官方文档手动安装。',
233
+ executable: npmExecutable,
234
+ args: ['install', '-g', packageName],
235
+ // 升级变体:@latest 强制同步到最新版。已是最新时 npm 自然跳过。
236
+ updateCommand: `npm install -g ${packageName}@latest`,
237
+ updatePackageManager: 'npm',
238
+ updateNote: '将在新终端中执行 `npm install -g <pkg>@latest`。已是最新时 npm 会跳过。',
239
+ updateExecutable: npmExecutable,
240
+ updateArgs: ['install', '-g', `${packageName}@latest`],
241
+ };
242
+ }
243
+
244
+ // 码道 CLI 没有 npm 包(官方是 PowerShell / bash 安装脚本),所以只给一张「手动安装」
245
+ // 卡片:supported 恒 false —— 前端据此不显示"一键安装"按钮,只保留复制命令 + 文档。
246
+ // 刻意不代跑那条远程脚本:`irm <url> | iex` 等于把一段网上拉下来的代码塞进用户终端,
247
+ // 属于要用户自己看清楚的动作用(与别家 npm 安装不同,那个至少有包名可审计)。
248
+ installers.codearts = {
249
+ supported: false,
250
+ command: platform === 'win32'
251
+ ? 'irm https://cnnorth4-cloudide-marketplace.obs.cn-north-4.myhuaweicloud.com/codearts/cli_tui/install_script/install.ps1 | iex'
252
+ : 'sh -c "$(curl -L https://cnnorth4-cloudide-marketplace.obs.cn-north-4.myhuaweicloud.com/codearts/cli_tui/install_script/install.sh)"',
253
+ packageManager: 'official-script',
254
+ docsUrl: TOOL_DOCS_URLS.codearts,
255
+ note: '复制命令到终端执行(装完会自动加入 PATH)。装好后点「重新检测」;若仍显示未安装,重启 ZenGitSync —— 已运行的进程读不到新的 PATH。',
256
+ };
257
+
258
+ if (platform === 'win32') {
259
+ const wingetAvailable = hasCommand('winget', platform);
260
+ installers.vscode = {
261
+ supported: wingetAvailable,
262
+ command: 'winget install --id Microsoft.VisualStudioCode -e --accept-package-agreements --accept-source-agreements',
263
+ packageManager: 'winget',
264
+ docsUrl: TOOL_DOCS_URLS.vscode,
265
+ note: wingetAvailable
266
+ ? '将在新终端中通过 winget 安装 Visual Studio Code。'
267
+ : '未检测到 winget,请使用 VS Code 官方安装程序。',
268
+ executable: 'winget',
269
+ args: ['install', '--id', 'Microsoft.VisualStudioCode', '-e', '--accept-package-agreements', '--accept-source-agreements'],
270
+ // winget 的升级动词是 upgrade(不是 install)
271
+ updateCommand: wingetAvailable
272
+ ? 'winget upgrade --id Microsoft.VisualStudioCode -e --accept-package-agreements --accept-source-agreements'
273
+ : undefined,
274
+ updatePackageManager: 'winget',
275
+ updateNote: wingetAvailable
276
+ ? '将在新终端中通过 winget upgrade 升级 VS Code。'
277
+ : '未检测到 winget,请使用 VS Code 官方安装包。',
278
+ updateExecutable: wingetAvailable ? 'winget' : undefined,
279
+ updateArgs: wingetAvailable
280
+ ? ['upgrade', '--id', 'Microsoft.VisualStudioCode', '-e', '--accept-package-agreements', '--accept-source-agreements']
281
+ : undefined,
282
+ };
283
+ } else if (platform === 'darwin') {
284
+ const brewAvailable = hasCommand('brew', platform);
285
+ installers.vscode = {
286
+ supported: brewAvailable,
287
+ command: 'brew install --cask visual-studio-code',
288
+ packageManager: 'Homebrew',
289
+ docsUrl: TOOL_DOCS_URLS.vscode,
290
+ note: brewAvailable
291
+ ? '将在新终端中通过 Homebrew 安装 Visual Studio Code。'
292
+ : '未检测到 Homebrew,请使用 VS Code 官方安装程序。',
293
+ executable: 'brew',
294
+ args: ['install', '--cask', 'visual-studio-code'],
295
+ updateCommand: brewAvailable ? 'brew upgrade --cask visual-studio-code' : undefined,
296
+ updatePackageManager: 'Homebrew',
297
+ updateNote: brewAvailable ? '将在新终端中通过 brew upgrade 升级 VS Code。' : '未检测到 brew。',
298
+ updateExecutable: brewAvailable ? 'brew' : undefined,
299
+ updateArgs: brewAvailable ? ['upgrade', '--cask', 'visual-studio-code'] : undefined,
300
+ };
301
+ } else {
302
+ const snapAvailable = hasCommand('snap', platform);
303
+ installers.vscode = {
304
+ supported: snapAvailable,
305
+ command: 'sudo snap install code --classic',
306
+ packageManager: 'snap',
307
+ docsUrl: TOOL_DOCS_URLS.vscode,
308
+ note: snapAvailable
309
+ ? '将在新终端中通过 snap 安装 Visual Studio Code,可能需要输入系统密码。'
310
+ : '当前 Linux 环境未检测到 snap,请按照发行版对应的官方说明安装。',
311
+ executable: 'sudo',
312
+ args: ['snap', 'install', 'code', '--classic'],
313
+ // snap 的升级动词是 refresh
314
+ updateCommand: snapAvailable ? 'sudo snap refresh code' : undefined,
315
+ updatePackageManager: 'snap',
316
+ updateNote: snapAvailable ? '将在新终端中通过 snap refresh 升级 VS Code。' : '未检测到 snap。',
317
+ updateExecutable: snapAvailable ? 'sudo' : undefined,
318
+ updateArgs: snapAvailable ? ['snap', 'refresh', 'code'] : undefined,
319
+ };
320
+ }
321
+
322
+ installers.kimi = {
323
+ supported: platform === 'win32' || platform === 'darwin' || platform === 'linux',
324
+ command: platform === 'win32'
325
+ ? 'irm https://code.kimi.com/kimi-code/install.ps1 | iex'
326
+ : 'curl -fsSL https://code.kimi.com/kimi-code/install.sh | bash',
327
+ packageManager: 'Kimi Code 官方安装脚本',
328
+ docsUrl: TOOL_DOCS_URLS.kimi,
329
+ note: '将打开终端执行官方安装脚本;Windows 首次使用还需要 Git for Windows。',
330
+ kind: 'script',
331
+ executionCommand: platform === 'win32'
332
+ ? [
333
+ "if (-not (Get-Command Get-FileHash -ErrorAction SilentlyContinue)) {",
334
+ "function global:Get-FileHash { param([string]$Path, [string]$Algorithm = 'SHA256')",
335
+ "$stream = [System.IO.File]::OpenRead($Path)",
336
+ "try { $sha = [System.Security.Cryptography.SHA256]::Create(); try { $bytes = $sha.ComputeHash($stream) } finally { $sha.Dispose() } } finally { $stream.Dispose() }",
337
+ "$hash = ([System.BitConverter]::ToString($bytes)).Replace('-', '')",
338
+ "New-Object PSObject -Property @{ Algorithm = 'SHA256'; Hash = $hash; Path = $Path }",
339
+ "}",
340
+ "}",
341
+ 'Invoke-RestMethod https://code.kimi.com/kimi-code/install.ps1 | Invoke-Expression',
342
+ ].join('; ')
343
+ : undefined,
344
+ // 升级:kimi 官方脚本幂等,已安装时执行会自动升级到最新版,直接复用同一脚本
345
+ updateCommand: platform === 'win32'
346
+ ? 'irm https://code.kimi.com/kimi-code/install.ps1 | iex'
347
+ : 'curl -fsSL https://code.kimi.com/kimi-code/install.sh | bash',
348
+ updatePackageManager: 'Kimi Code 官方安装脚本',
349
+ updateNote: '官方安装脚本幂等,已安装时执行会自动升级到最新版本。',
350
+ updateKind: 'script',
351
+ updateExecutionCommand: platform === 'win32'
352
+ ? [
353
+ "if (-not (Get-Command Get-FileHash -ErrorAction SilentlyContinue)) {",
354
+ "function global:Get-FileHash { param([string]$Path, [string]$Algorithm = 'SHA256')",
355
+ "$stream = [System.IO.File]::OpenRead($Path)",
356
+ "try { $sha = [System.Security.Cryptography.SHA256]::Create(); try { $bytes = $sha.ComputeHash($stream) } finally { $sha.Dispose() } } finally { $stream.Dispose() }",
357
+ "$hash = ([System.BitConverter]::ToString($bytes)).Replace('-', '')",
358
+ "New-Object PSObject -Property @{ Algorithm = 'SHA256'; Hash = $hash; Path = $Path }",
359
+ "}",
360
+ "}",
361
+ 'Invoke-RestMethod https://code.kimi.com/kimi-code/install.ps1 | Invoke-Expression',
362
+ ].join('; ')
363
+ : undefined,
364
+ };
365
+ installers.zcode = {
366
+ supported: false,
367
+ command: '打开 ZCode 官方下载页,选择当前系统安装包',
368
+ packageManager: 'ZCode 官方安装包',
369
+ docsUrl: TOOL_DOCS_URLS.zcode,
370
+ note: 'ZCode 是桌面应用,官方提供 Windows、macOS 和 Linux 安装包,没有官方 npm CLI。',
371
+ // ZCode 是桌面应用、没有 CLI 更新通道;/api/update-tool 会对它返回 400
372
+ updateCommand: undefined,
373
+ updatePackageManager: 'ZCode 官方安装包',
374
+ updateNote: 'ZCode 是桌面应用,升级请到官网下载新版安装包覆盖安装。',
375
+ };
376
+
377
+ // ── 远程托管平台 CLI(远程仓库列表页用)──────────────────────────────────
378
+ // 与上面的 AI 工具共用同一份白名单,好处是"前端只能提交 tool id、命令由服务端
379
+ // 决定"这条安全边界不用再实现一遍。它们不出现在 open-with 菜单里 —— 那个菜单
380
+ // 是 useDirectoryOpenActions.ts 里的硬编码数组,不看这张表。
381
+ if (platform === 'win32') {
382
+ const wingetAvailable = hasCommand('winget', platform);
383
+ installers.gh = {
384
+ supported: wingetAvailable,
385
+ command: 'winget install --id GitHub.cli -e --accept-package-agreements --accept-source-agreements',
386
+ packageManager: 'winget',
387
+ docsUrl: TOOL_DOCS_URLS.gh,
388
+ note: wingetAvailable
389
+ ? '将在新终端中通过 winget 安装 GitHub CLI;装完请在新终端执行 gh auth login 登录。'
390
+ : '未检测到 winget,请到 cli.github.com 下载安装包。',
391
+ executable: 'winget',
392
+ args: ['install', '--id', 'GitHub.cli', '-e', '--accept-package-agreements', '--accept-source-agreements'],
393
+ updateCommand: wingetAvailable
394
+ ? 'winget upgrade --id GitHub.cli -e --accept-package-agreements --accept-source-agreements'
395
+ : undefined,
396
+ updatePackageManager: 'winget',
397
+ updateNote: wingetAvailable ? '将在新终端中通过 winget upgrade 升级 GitHub CLI。' : '未检测到 winget。',
398
+ updateExecutable: wingetAvailable ? 'winget' : undefined,
399
+ updateArgs: wingetAvailable
400
+ ? ['upgrade', '--id', 'GitHub.cli', '-e', '--accept-package-agreements', '--accept-source-agreements']
401
+ : undefined,
402
+ };
403
+ } else if (platform === 'darwin') {
404
+ const brewAvailable = hasCommand('brew', platform);
405
+ installers.gh = {
406
+ supported: brewAvailable,
407
+ command: 'brew install gh',
408
+ packageManager: 'Homebrew',
409
+ docsUrl: TOOL_DOCS_URLS.gh,
410
+ note: brewAvailable
411
+ ? '将在新终端中通过 Homebrew 安装 GitHub CLI;装完请执行 gh auth login 登录。'
412
+ : '未检测到 Homebrew,请到 cli.github.com 下载安装包。',
413
+ executable: 'brew',
414
+ args: ['install', 'gh'],
415
+ updateCommand: brewAvailable ? 'brew upgrade gh' : undefined,
416
+ updatePackageManager: 'Homebrew',
417
+ updateNote: brewAvailable ? '将在新终端中通过 brew upgrade 升级 GitHub CLI。' : '未检测到 brew。',
418
+ updateExecutable: brewAvailable ? 'brew' : undefined,
419
+ updateArgs: brewAvailable ? ['upgrade', 'gh'] : undefined,
420
+ };
421
+ } else {
422
+ // Debian/Ubuntu 官方源里已有 gh;其它发行版请看 docsUrl(Arch: pacman -S github-cli)
423
+ const aptAvailable = hasCommand('apt-get', platform);
424
+ installers.gh = {
425
+ supported: aptAvailable,
426
+ command: 'sudo apt-get install -y gh',
427
+ packageManager: 'apt',
428
+ docsUrl: TOOL_DOCS_URLS.gh,
429
+ note: aptAvailable
430
+ ? '将在新终端中通过 apt 安装 GitHub CLI,可能需要输入系统密码;装完请执行 gh auth login 登录。'
431
+ : '当前 Linux 环境未检测到 apt-get,请按 docsUrl 里对应发行版的说明安装。',
432
+ executable: 'sudo',
433
+ args: ['apt-get', 'install', '-y', 'gh'],
434
+ // apt 没有"就地升级单个包"的稳定动词,不给一键更新,引导去官方文档
435
+ updateCommand: undefined,
436
+ updatePackageManager: 'apt',
437
+ updateNote: '请按官方文档升级 GitHub CLI。',
438
+ };
439
+ }
440
+ installers.gitee = {
441
+ supported: npmAvailable,
442
+ command: 'npm install -g @gitee/gitee-cli',
443
+ packageManager: 'npm',
444
+ docsUrl: TOOL_DOCS_URLS.gitee,
445
+ note: npmAvailable
446
+ ? '将在新终端中通过 npm 全局安装 Gitee CLI;装完请执行 gitee auth login 粘贴私人令牌。'
447
+ : '未检测到 npm,请先安装 Node.js/npm,或按官方文档手动安装。',
448
+ executable: npmExecutable,
449
+ args: ['install', '-g', '@gitee/gitee-cli'],
450
+ updateCommand: 'npm install -g @gitee/gitee-cli@latest',
451
+ updatePackageManager: 'npm',
452
+ updateNote: '将在新终端中执行 `npm install -g @gitee/gitee-cli@latest`。已是最新时 npm 会跳过。',
453
+ updateExecutable: npmExecutable,
454
+ updateArgs: ['install', '-g', '@gitee/gitee-cli@latest'],
455
+ };
456
+
457
+ return installers;
458
+ }
459
+
460
+ export function publicInstallerInfo(installers) {
461
+ return Object.fromEntries(Object.entries(installers).map(([tool, installer]) => [tool, {
462
+ supported: installer.supported,
463
+ command: installer.command,
464
+ packageManager: installer.packageManager,
465
+ docsUrl: installer.docsUrl,
466
+ note: installer.note,
467
+ }]));
468
+ }
469
+
470
+ function spawnDetached(command, args, options = {}) {
471
+ return new Promise((resolve, reject) => {
472
+ const child = spawn(command, args, {
473
+ detached: true,
474
+ stdio: 'ignore',
475
+ ...options
476
+ });
477
+
478
+ child.on('error', reject);
479
+ child.on('spawn', () => {
480
+ child.unref();
481
+ resolve('success');
482
+ });
483
+ });
484
+ }
485
+
486
+ function shQuote(s) {
487
+ return `'${String(s).replace(/'/g, `'\\''`)}'`;
488
+ }
489
+
490
+ // Linux 终端模拟器探测顺序:x-terminal-emulator 是 Debian/Ubuntu alternatives
491
+ // 指向用户默认终端的通用入口,优先使用;其余为常见桌面终端的兜底
492
+ const LINUX_TERMINALS = [
493
+ { cmd: 'x-terminal-emulator', args: (shell) => ['-e', 'bash', '-c', shell] },
494
+ { cmd: 'gnome-terminal', args: (shell) => ['--', 'bash', '-c', shell] },
495
+ { cmd: 'konsole', args: (shell) => ['-e', 'bash', '-c', shell] },
496
+ { cmd: 'xfce4-terminal', args: (shell) => ['-x', 'bash', '-c', shell] },
497
+ { cmd: 'mate-terminal', args: (shell) => ['-e', `bash -c ${shQuote(shell)}`] },
498
+ { cmd: 'alacritty', args: (shell) => ['-e', 'bash', '-c', shell] },
499
+ { cmd: 'kitty', args: (shell) => ['bash', '-c', shell] },
500
+ { cmd: 'xterm', args: (shell) => ['-e', 'bash', '-c', shell] },
501
+ ];
502
+
503
+ function findLinuxTerminal() {
504
+ for (const term of LINUX_TERMINALS) {
505
+ const r = spawnSync('which', [term.cmd], { stdio: 'pipe' });
506
+ if (r.status === 0) return term;
507
+ }
508
+ return null;
509
+ }
510
+
511
+ // 拼一段在终端里执行的 shell:先 cd 到目标目录再启动 CLI;
512
+ // 进程非零退出时停住窗口,否则 CLI 启动失败会秒关窗口,用户看不到任何报错
513
+ function buildTerminalShell(dirPath, command, cliArgs) {
514
+ const cmdStr = [command, ...cliArgs].map(shQuote).join(' ');
515
+ return `cd ${shQuote(dirPath)} && ${cmdStr}; rc=$?; if [ $rc -ne 0 ]; then echo; echo "${command} 退出码 $rc,按回车关闭窗口"; read -r _; fi`;
516
+ }
517
+
518
+ // Linux/macOS 没有统一的"新开终端窗口"API:直接 spawn TUI 程序只会在后台
519
+ // 跑一个无 TTY 的进程(stdio 被 ignore),用户屏幕上看不到任何东西,
520
+ // 必须显式起终端模拟器把命令跑在窗口里
521
+ async function launchInTerminal(dirPath, command, cliArgs = []) {
522
+ const shell = buildTerminalShell(dirPath, command, cliArgs);
523
+
524
+ if (process.platform === 'darwin') {
525
+ const script = `tell application "Terminal"\nactivate\ndo script ${JSON.stringify(shell)}\nend tell`;
526
+ return spawnDetached('osascript', ['-e', script]);
527
+ }
528
+
529
+ if (!process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) {
530
+ throw new Error('当前会话没有图形环境(DISPLAY / WAYLAND_DISPLAY 均为空),无法打开终端窗口');
531
+ }
532
+ const term = findLinuxTerminal();
533
+ if (!term) {
534
+ throw new Error('未检测到可用的终端模拟器(gnome-terminal / konsole / xterm 等)');
535
+ }
536
+ return spawnDetached(term.cmd, term.args(shell));
537
+ }
538
+
539
+ async function launchClaudeCode(dirPath, { permissionMode } = {}) {
540
+ // 透传可选的权限模式参数到 claude CLI(如 acceptEdits)
541
+ // 注意:permissionMode 必须是一个 token 字符串,避免 shell 注入
542
+ const SAFE_MODE = /^[a-zA-Z][a-zA-Z0-9_-]*$/;
543
+ const cliArgs = [];
544
+ if (permissionMode && typeof permissionMode === 'string' && SAFE_MODE.test(permissionMode)) {
545
+ cliArgs.push('--permission-mode', permissionMode);
546
+ }
547
+
548
+ if (process.platform === 'win32') {
549
+ return spawnDetached('cmd.exe', ['/c', 'start', '""', 'claude', ...cliArgs], {
550
+ cwd: dirPath
551
+ });
552
+ }
553
+
554
+ return launchInTerminal(dirPath, 'claude', cliArgs);
555
+ }
556
+
557
+ /**
558
+ * codex 的启动参数(按权限档位)。与 claude / opencode 的档位对应关系:
559
+ *
560
+ * 默认(不带 token) → 一个 flag 都不加,跟随 `~/.codex/config.toml` 的
561
+ * approval / sandbox 设置
562
+ * 'sandboxed' → `-a never -s workspace-write`:**从不问人,但命令仍在沙箱里跑**
563
+ * (写限工作区、默认无网络)。这是"不想被批准提示打断、又不想
564
+ * 完全交出去"的那一档,语义上对应 claude 的「批准文件编辑」。
565
+ * 'bypass' → `--dangerously-bypass-approvals-and-sandbox`:免批准 + 免沙箱。
566
+ * 官方帮助原文 "Skip all confirmation prompts and execute commands
567
+ * without sandboxing. EXTREMELY DANGEROUS."。**完全批准**就是它。
568
+ * 'bypassPermissions'→ 同上。只为让调用方(顶栏菜单与编排台项目菜单)跟 claude 那档
569
+ * 共用同一个字符串,不是第三种行为。
570
+ *
571
+ * ⚠️ codex 没有 claude 的 `--permission-mode`,两档都得自己拼 flag。
572
+ * ⚠️ 别只看 sandbox:本机 config.toml 写的是 `sandbox_mode = "danger-full-access"`,
573
+ * 也就是说这台机器的**默认档本来就没有沙箱**,完全批准档真正多出来的只是"不再弹批准"。
574
+ *
575
+ * 回归 `test/open-with-permission.test.mjs`(与 opencode 那份参数映射放一起验)。
576
+ */
577
+ const CODEX_MODE_FLAGS = new Map([
578
+ ['sandboxed', ['-a', 'never', '-s', 'workspace-write']],
579
+ ['bypass', ['--dangerously-bypass-approvals-and-sandbox']],
580
+ ['bypassPermissions', ['--dangerously-bypass-approvals-and-sandbox']],
581
+ ]);
582
+
583
+ export function buildCodexArgs(permissionMode) {
584
+ // Map.get 对非字符串返回 undefined → 未知 token 一律空数组,白名单式,不存在拼参数注入
585
+ return [...(CODEX_MODE_FLAGS.get(permissionMode) || [])];
586
+ }
587
+
588
+ async function launchCodex(dirPath, { permissionMode } = {}) {
589
+ // OpenAI Codex CLI - https://github.com/openai/codex
590
+ const cliArgs = buildCodexArgs(permissionMode);
591
+ if (process.platform === 'win32') {
592
+ return spawnDetached('cmd.exe', ['/c', 'start', '""', 'codex', ...cliArgs], {
593
+ cwd: dirPath
594
+ });
595
+ }
596
+ return launchInTerminal(dirPath, 'codex', cliArgs);
597
+ }
598
+
599
+ /**
600
+ * opencode 的「完全批准」= `--auto`(官方文档里的 auto mode):把**未被显式拒绝**的
601
+ * 权限请求自动批准。
602
+ *
603
+ * 它不等于"什么都放行":配置里显式 `deny` 的仍然会被拦(opencode 默认就 deny 了
604
+ * `read` 的 `*.env` / `*.env.*`,TUI 里 `--auto` 也一样读不到),这与 claude 的
605
+ * bypassPermissions 受 `~/.claude/settings.json` deny 约束是同一个口径 —— 两边都是
606
+ * "除非你明确禁止,否则全放"。
607
+ *
608
+ * 档位 token:`auto` 是本工具的正式叫法;`bypassPermissions` 只是为了让调用方
609
+ * (顶栏菜单与编排台项目菜单)与 claude 那档共用同一个字符串。
610
+ */
611
+ const OPENCODE_AUTO_MODES = new Set(['auto', 'bypassPermissions']);
612
+
613
+ /**
614
+ * opencode 的启动参数(按权限档位)。
615
+ *
616
+ * 单独抽成纯函数是为了能**真的跑断言**而不用 spawn 一个终端窗口 —— 带 `--auto` 的
617
+ * TUI 会弹出来并停在那里等人输入,验证不能有这种副作用。回归 `test/opencode-permission.test.mjs`。
618
+ * 白名单式:非字符串 / 未知 token 一律返回空数组,不存在拼参数注入的面。
619
+ */
620
+ export function buildOpencodeArgs(permissionMode) {
621
+ return OPENCODE_AUTO_MODES.has(permissionMode) ? ['--auto'] : [];
622
+ }
623
+
624
+ async function launchOpenCode(dirPath, { permissionMode } = {}) {
625
+ // opencode (sst/opencode) CLI - https://opencode.ai
626
+ const cliArgs = buildOpencodeArgs(permissionMode);
627
+ if (process.platform === 'win32') {
628
+ return spawnDetached('cmd.exe', ['/c', 'start', '""', 'opencode', ...cliArgs], {
629
+ cwd: dirPath
630
+ });
631
+ }
632
+ return launchInTerminal(dirPath, 'opencode', cliArgs);
633
+ }
634
+
635
+ /**
636
+ * codebuddy 的权限档位(`--permission-mode`,用 CLI 自己的档位名当 token)。
637
+ *
638
+ * 档位名与 claude **逐字相同**(acceptEdits / bypassPermissions),所以直接沿用
639
+ * claude 那套 token,不必再造一份 —— 前端两处菜单传的是同一个字符串。
640
+ *
641
+ * 'bypassPermissions' → `--permission-mode bypassPermissions`
642
+ * 官方档位说明原文 "Skips all permission prompts",这就是它的**完全批准**档。
643
+ * 实测(2026-10-14,本机 CLI)`codebuddy -p --output-format stream-json
644
+ * --permission-mode bypassPermissions` 的 init 事件回传
645
+ * `permissionMode: "bypassPermissions"` —— 档位真的生效,不是被静默忽略。
646
+ * `-y / --dangerously-skip-permissions` 是同一档的别名(headless 执行器走的就是
647
+ * -y,init 同样回传 bypassPermissions),这里用 `--permission-mode` 是因为它
648
+ * 是 TUI 也认的正式写法(help: "Default permission mode for this process
649
+ * (TUI, --serve Web, ACP)"),且与 claude 的启动参数形状一致。
650
+ * 'acceptEdits' → `--permission-mode acceptEdits`(自动批准文件编辑,中间档)
651
+ * 其余一切(undefined / 未知串 / 非字符串)→ 一个 flag 都不带,跟随 CLI 自己的配置
652
+ *
653
+ * 白名单式:只有表里这两个 token 能变成参数,不存在拼参数注入的面。
654
+ * 回归 `test/open-with-permission.test.mjs`。
655
+ */
656
+ const CODEBUDDY_MODE_FLAGS = new Map([
657
+ ['acceptEdits', ['--permission-mode', 'acceptEdits']],
658
+ ['bypassPermissions', ['--permission-mode', 'bypassPermissions']],
659
+ ]);
660
+
661
+ /**
662
+ * codebuddy 的启动参数(按权限档位)。
663
+ *
664
+ * `--port 0` 是**必须**的(与档位无关,恒定带上):CLI 每次运行都自带一个引擎服务器,
665
+ * 默认端口写死 62443,而 WorkBuddy 桌面版常驻占着它 —— 实测裸跑 `codebuddy`
666
+ * (交互也一样)直接 EADDRINUSE 退出。0 = 让 OS 选空闲端口,与 headless 执行器
667
+ * (taskRunner.launchCodebuddyPrint)同一个口径。
668
+ */
669
+ export function buildCodebuddyArgs(permissionMode) {
670
+ return ['--port', '0', ...(CODEBUDDY_MODE_FLAGS.get(permissionMode) || [])];
671
+ }
672
+
673
+ /**
674
+ * 用 CodeBuddy Code 打开目录(交互 TUI,2026-10-12 随执行器接入一起补)。
675
+ * 权限档位见 buildCodebuddyArgs。
676
+ *
677
+ * 登录态来自 ~/.codebuddy:没登录过的话 TUI 里会提示 /login 扫码。
678
+ */
679
+ async function launchCodebuddy(dirPath, { permissionMode } = {}) {
680
+ // CodeBuddy Code CLI - https://www.codebuddy.cn/docs/cli
681
+ const cliArgs = buildCodebuddyArgs(permissionMode);
682
+ if (process.platform === 'win32') {
683
+ return spawnDetached('cmd.exe', ['/c', 'start', '""', 'codebuddy', ...cliArgs], {
684
+ cwd: dirPath
685
+ });
686
+ }
687
+ return launchInTerminal(dirPath, 'codebuddy', cliArgs);
688
+ }
689
+
690
+ /**
691
+ * 用 Pi 打开目录(交互 TUI,2026-10-13 随执行器接入一起补)。
692
+ *
693
+ * 与 codebuddy 的差别:pi **没有**引擎服务器、不占固定端口,所以裸起即可
694
+ * (没有 --port 0 那一出)。登录态在 TUI 里 /login(订阅或 provider API key),
695
+ * 凭据落 ~/.pi/agent。
696
+ */
697
+ async function launchPi(dirPath) {
698
+ // Pi CLI - https://pi.dev
699
+ if (process.platform === 'win32') {
700
+ return spawnDetached('cmd.exe', ['/c', 'start', '""', 'pi'], {
701
+ cwd: dirPath
702
+ });
703
+ }
704
+ return launchInTerminal(dirPath, 'pi', []);
705
+ }
706
+
707
+ /**
708
+ * 用码道 CLI 打开目录(交互 TUI,2026-10-25 随执行器接入一起补)。
709
+ *
710
+ * `codearts <project>` 就是"在指定目录进入 TUI"。三个必须注意的点:
711
+ * - 必须走 installers\codearts.cmd(安装脚本加的 shim)—— 它负责补齐
712
+ * OPENCODE_CONFIG 等一串环境变量;裸起 bin\codearts.exe 会读用户自己那套
713
+ * opencode 的配置。
714
+ * - **必须用解析出的绝对路径,不能让 cmd 自己去 PATH 里找**:安装脚本只改用户
715
+ * PATH,已经在跑的进程看不到 —— 实测 `start "" codearts <dir>` 直接弹
716
+ * Windows「找不到文件 'codearts'」(2026-10-25 用户截图)。
717
+ * - 首次开会要求 AK/SK(CODEARTS_CLI_AK/SK)或浏览器登录,属于交互环节。
718
+ */
719
+ async function launchCodeArts(dirPath) {
720
+ if (process.platform === 'win32') {
721
+ const exe = await findCodeArtsExecutable();
722
+ if (!exe) {
723
+ throw new Error('未检测到 codearts,请先安装(或重启 ZenGitSync 让其刷新 PATH)');
724
+ }
725
+ return spawnDetached('cmd.exe', ['/c', 'start', '""', exe, dirPath], {
726
+ cwd: dirPath
727
+ });
728
+ }
729
+ return launchInTerminal(dirPath, 'codearts', [dirPath]);
730
+ }
731
+
732
+ /**
733
+ * 找 codearts 的可执行文件(2026-10-25 加)。
734
+ *
735
+ * 为什么不能像别家那样直接 `checkCmd('codearts')` 靠 PATH:
736
+ * 官方安装脚本把 `%USERPROFILE%\.codeartsdoer\installers` 追加到**用户 PATH**,
737
+ * 而 Windows 的 PATH 变更只对新进程生效 —— 已经在跑的 g ui 进程永远看不到。
738
+ * 于是"明明装了却显示未安装",而且用户重启应用前怎么点都修复不了。
739
+ * 这里按已知安装位置硬找,与 taskRunner.resolveCodeArtsExe 同一份口径。
740
+ */
741
+ export async function findCodeArtsExecutable() {
742
+ const home = process.env.USERPROFILE || '';
743
+ if (process.platform !== 'win32') {
744
+ return commandExists('codearts') ? 'codearts' : null;
745
+ }
746
+
747
+ const candidates = [
748
+ path.join(home, '.codeartsdoer', 'installers', 'codearts.cmd'),
749
+ path.join(home, '.codeartsdoer', 'installers', 'bin', 'codearts.exe'),
750
+ ];
751
+ const registryPath = spawnSync('reg.exe', ['query', 'HKCU\\Environment', '/v', 'Path'], {
752
+ encoding: 'utf8',
753
+ windowsHide: true,
754
+ }).stdout || '';
755
+ const pathLine = registryPath.split(/\r?\n/).find(line => /\sPath\s+REG_(?:EXPAND_)?SZ\s+/i.test(line));
756
+ if (pathLine) {
757
+ const userPath = pathLine.replace(/^.*?REG_(?:EXPAND_)?SZ\s+/i, '').trim();
758
+ for (const directory of userPath.split(';').map(value => value.trim()).filter(Boolean)) {
759
+ candidates.push(path.join(directory, 'codearts.cmd'));
760
+ candidates.push(path.join(directory, 'codearts.exe'));
761
+ }
762
+ }
763
+
764
+ for (const candidate of candidates) {
765
+ try {
766
+ const stat = await fs.stat(candidate);
767
+ if (stat.isFile()) return candidate;
768
+ } catch {}
769
+ }
770
+ return commandExists('codearts') ? 'codearts' : null;
771
+ }
772
+
773
+ // Run the CLI shipped with this server, even when `g` is not in PATH or points
774
+ // to another install. Directory paths remain spawn options, never CMD source.
775
+ export async function launchGai(dirPath, {
776
+ platform = process.platform,
777
+ spawnFn = execFileAsync,
778
+ terminalFn = launchInTerminal,
779
+ } = {}) {
780
+ const entry = fileURLToPath(new URL('../../../gitCommit.js', import.meta.url));
781
+ if (platform !== 'win32') return terminalFn(dirPath, process.execPath, [entry, 'ai']);
782
+
783
+ const psLiteral = value => `'${String(value).replace(/'/g, "''")}'`;
784
+ const encode = value => Buffer.from(value, 'utf16le').toString('base64');
785
+ const command = `& ${psLiteral(process.execPath)} ${psLiteral(entry)} 'ai'`;
786
+ // The hidden launcher opens a visible interactive console. -NoExit leaves
787
+ // startup errors readable; no model request is sent until the user types.
788
+ const launcher = [
789
+ "$ErrorActionPreference = 'Stop';",
790
+ "Start-Process -FilePath (Join-Path $PSHOME 'powershell.exe')",
791
+ `-ArgumentList '-NoLogo','-NoProfile','-NoExit','-EncodedCommand','${encode(command)}'`,
792
+ '-WorkingDirectory (Get-Location).ProviderPath -WindowStyle Normal',
793
+ ].join(' ');
794
+ return spawnFn('powershell.exe', ['-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand', encode(launcher)], {
795
+ cwd: dirPath,
796
+ windowsHide: true,
797
+ timeout: 10000,
798
+ // 这个终端窗口以及里面跑起来的 g ai,会继承下面这份 env;把注册表里新装的 CLI
799
+ // 目录补进去,否则窗口里 `gh repo list` 照样报"不是内部或外部命令"
800
+ // (本进程的 PATH 是启动快照)。见 utils/shellPath.js 顶部。
801
+ env: await augmentEnvPath(process.env),
802
+ });
803
+ }
804
+
805
+ /**
806
+ * kimi 的权限档位。
807
+ *
808
+ * 'auto' | 'bypassPermissions' → `--auto`
809
+ * 官方 help 原文 "Start in Never Ask mode: never interrupts you; everything runs
810
+ * and is decided automatically" —— 这才是它的**完全批准**档。
811
+ * `bypassPermissions` 只是让调用方与 claude / codex / opencode 那档共用同一个
812
+ * 字符串,不是另一种行为。
813
+ * 其余一切(undefined / 未知串 / 非字符串)→ 一个 flag 都不带(默认档:该问的照问)
814
+ *
815
+ * ⚠️ 这个 `--auto` 与 opencode 的 `--auto` **同名但语义不同**:opencode 是"自动批准
816
+ * 未被显式拒绝的权限"(配置里 deny 的仍拦),kimi 是"从不打断"。档位 token 各按各家
817
+ * CLI 的叫法走,别硬凑一个公共词(同 codex 那段的取舍)。
818
+ * ⚠️ kimi 还有一个中间档 `-y / --yolo`(Ask When Needed:常规编辑与命令自动放行,
819
+ * 危险动作 / 提问 / 计划仍会问),菜单暂未暴露 —— 要加时在 KIMI_MODE_FLAGS 补一行。
820
+ *
821
+ * 白名单式:只有表里这两个 token 能变成参数。
822
+ * 回归 `test/open-with-permission.test.mjs`。
823
+ */
824
+ const KIMI_MODE_FLAGS = new Map([
825
+ ['auto', ['--auto']],
826
+ ['bypassPermissions', ['--auto']],
827
+ ]);
828
+
829
+ export function buildKimiArgs(permissionMode) {
830
+ return [...(KIMI_MODE_FLAGS.get(permissionMode) || [])];
831
+ }
832
+
833
+ async function launchKimiCode(dirPath, { permissionMode } = {}) {
834
+ const cliArgs = buildKimiArgs(permissionMode);
835
+ if (process.platform === 'win32') {
836
+ const executable = await findKimiExecutable();
837
+ if (!executable) throw new Error('未检测到 Kimi Code,请先安装 kimi CLI');
838
+ return spawnDetached('cmd.exe', ['/c', 'start', '""', executable, ...cliArgs], { cwd: dirPath });
839
+ }
840
+ return launchInTerminal(dirPath, 'kimi', cliArgs);
841
+ }
842
+
843
+ async function launchDsh(dirPath) {
844
+ const executable = await findDshExecutable();
845
+ if (!executable) throw new Error('未检测到 DeepSeek Harness,请先全局安装 dsh CLI');
846
+ if (process.platform === 'win32') {
847
+ // 编码 PowerShell 命令,避免 cmd start 对 dsh.cmd 完整路径中的引号进行二次解析。
848
+ const escapedExecutable = executable.replace(/'/g, "''");
849
+ const script = `& '${escapedExecutable}' web`;
850
+ const encoded = Buffer.from(script, 'utf16le').toString('base64');
851
+ return spawnDetached('cmd.exe', [
852
+ '/c', 'start', '""', 'powershell.exe', '-NoLogo', '-NoExit',
853
+ '-ExecutionPolicy', 'Bypass', '-EncodedCommand', encoded,
854
+ ], { cwd: dirPath });
855
+ }
856
+ return launchInTerminal(dirPath, executable, ['web']);
857
+ }
858
+
859
+ // 在 Windows 上找 dsh 可执行文件。
860
+ // 优先用 where.exe dsh(覆盖 nvm4w 的 C:\nvm4w\nodejs\、默认的 %APPDATA%\npm、
861
+ // pnpm/yarn 自定义 prefix、以及用户手摆到 PATH 里的任意位置),
862
+ //再兜底 APPDATA\npm + npm prefix -g 两个写死路径(为 where.exe 漏检的边缘情况,
863
+ //比如 PATH 里只有 junction/symlink 时)。
864
+ export async function findDshExecutable() {
865
+ if (process.platform !== 'win32') return commandExists('dsh') ? 'dsh' : null;
866
+
867
+ const where = spawnSync('where.exe', ['dsh'], { encoding: 'utf8', windowsHide: true });
868
+ if (where.status === 0 && where.stdout) {
869
+ for (const line of where.stdout.split(/\r?\n/).map((s) => s.trim()).filter(Boolean)) {
870
+ // 偶尔会输出 "PATH=..." 这种变量行,跳过
871
+ if (/^[A-Z_]+=/i.test(line)) continue;
872
+ try {
873
+ const stat = await fs.stat(line);
874
+ if (stat.isFile()) return line;
875
+ } catch {}
876
+ }
877
+ }
878
+
879
+ const fallback = [path.join(process.env.APPDATA || '', 'npm', 'dsh.cmd')];
880
+ const npmPrefix = spawnSync('npm.cmd', ['prefix', '-g'], {
881
+ encoding: 'utf8',
882
+ windowsHide: true,
883
+ }).stdout?.trim();
884
+ if (npmPrefix) fallback.push(path.join(npmPrefix, 'dsh.cmd'));
885
+
886
+ for (const candidate of fallback.filter(Boolean)) {
887
+ try {
888
+ const stat = await fs.stat(candidate);
889
+ if (stat.isFile()) return candidate;
890
+ } catch {}
891
+ }
892
+ return null;
893
+ }
894
+
895
+ async function findKimiExecutable() {
896
+ if (process.platform !== 'win32') return commandExists('kimi') ? 'kimi' : null;
897
+
898
+ const candidates = [
899
+ path.join(process.env.USERPROFILE || '', '.kimi-code', 'bin', 'kimi.exe'),
900
+ ];
901
+ const registryPath = spawnSync('reg.exe', ['query', 'HKCU\\Environment', '/v', 'Path'], {
902
+ encoding: 'utf8',
903
+ windowsHide: true,
904
+ }).stdout || '';
905
+ const pathLine = registryPath.split(/\r?\n/).find(line => /\sPath\s+REG_(?:EXPAND_)?SZ\s+/i.test(line));
906
+ if (pathLine) {
907
+ const userPath = pathLine.replace(/^.*?REG_(?:EXPAND_)?SZ\s+/i, '').trim();
908
+ for (const directory of userPath.split(';').map(value => value.trim()).filter(Boolean)) {
909
+ candidates.push(path.join(directory, 'kimi.exe'));
910
+ }
911
+ }
912
+
913
+ for (const candidate of candidates) {
914
+ try {
915
+ const stat = await fs.stat(candidate);
916
+ if (stat.isFile()) return candidate;
917
+ } catch {}
918
+ }
919
+ return null;
920
+ }
921
+
922
+ async function launchZCode(dirPath) {
923
+ const executable = await findZCodeExecutable();
924
+ if (!executable) throw new Error('未检测到 ZCode,请从官网下载桌面安装包');
925
+ return spawnDetached(executable, [dirPath]);
926
+ }
927
+
928
+ async function findZCodeExecutable() {
929
+ const candidates = process.platform === 'win32'
930
+ ? [
931
+ path.join(process.env.LOCALAPPDATA || '', 'Programs', 'ZCode', 'ZCode.exe'),
932
+ path.join(process.env.LOCALAPPDATA || '', 'ZCode', 'ZCode.exe'),
933
+ path.join(process.env.PROGRAMFILES || '', 'ZCode', 'ZCode.exe'),
934
+ ]
935
+ : process.platform === 'darwin'
936
+ ? ['/Applications/ZCode.app/Contents/MacOS/ZCode']
937
+ : ['/usr/bin/zcode', '/usr/local/bin/zcode'];
938
+
939
+ if (process.platform === 'win32') {
940
+ const registryRoots = [
941
+ 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall',
942
+ 'HKLM\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall',
943
+ 'HKLM\\Software\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall',
944
+ ];
945
+ for (const root of registryRoots) {
946
+ const output = spawnSync('reg.exe', ['query', root, '/s'], {
947
+ encoding: 'utf8',
948
+ windowsHide: true,
949
+ }).stdout || '';
950
+ for (const line of output.split(/\r?\n/)) {
951
+ if (!/DisplayIcon|InstallLocation/i.test(line) || !/zcode/i.test(line)) continue;
952
+ const value = line.replace(/^.*?REG_SZ\s+/i, '').trim().replace(/^"|"$/g, '').replace(/,\d+$/, '');
953
+ if (value && !/\.(ico|png|jpg|jpeg)$/i.test(value)) candidates.push(value);
954
+ }
955
+ }
956
+ const running = spawnSync('powershell.exe', [
957
+ '-NoLogo', '-NoProfile', '-Command',
958
+ "Get-Process -ErrorAction SilentlyContinue | Where-Object { $_.ProcessName -match 'ZCode' } | Select-Object -ExpandProperty Path",
959
+ ], { encoding: 'utf8', windowsHide: true }).stdout || '';
960
+ candidates.push(...running.split(/\r?\n/).map(value => value.trim()).filter(Boolean));
961
+ }
962
+
963
+ for (const candidate of candidates.filter(Boolean)) {
964
+ const normalized = String(candidate).trim().replace(/^"|"$/g, '').replace(/,\d+$/, '');
965
+ if (/\.(ico|png|jpg|jpeg)$/i.test(normalized)) continue;
966
+ try {
967
+ const stat = await fs.stat(normalized);
968
+ if (stat.isFile()) return normalized;
969
+ if (stat.isDirectory()) {
970
+ for (const name of ['ZCode.exe', 'zcode.exe']) {
971
+ const executable = path.join(normalized, name);
972
+ try {
973
+ const executableStat = await fs.stat(executable);
974
+ if (executableStat.isFile()) return executable;
975
+ } catch {}
976
+ }
977
+ }
978
+ } catch {}
979
+ }
980
+ return null;
981
+ }
982
+
983
+ // ── 官方桌面应用探测 / 启动 ────────────────────────────────────────────
984
+
985
+ async function isFileAt(p) {
986
+ try { return (await fs.stat(p)).isFile(); } catch { return false; }
987
+ }
988
+
989
+ async function isDirAt(p) {
990
+ try { return (await fs.stat(p)).isDirectory(); } catch { return false; }
991
+ }
992
+
993
+ /**
994
+ * Windows 卸载注册表里所有 DisplayIcon / InstallLocation 的值(三个根各查一次,
995
+ * 五个桌面应用共用这一遍 —— check-tools 每 10 分钟跑一轮,不能每个应用各查三遍)。
996
+ * 为什么查注册表:NSIS / Inno 安装的桌面应用(Claude、CodeBuddy IDE、Kimi)
997
+ * 安装路径随用户选择变,固定路径猜不中,注册表是唯一权威登记处。
998
+ */
999
+ function scanWindowsUninstallPaths() {
1000
+ const roots = [
1001
+ 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall',
1002
+ 'HKLM\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall',
1003
+ 'HKLM\\Software\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall',
1004
+ ];
1005
+ const values = [];
1006
+ for (const root of roots) {
1007
+ const output = spawnSync('reg.exe', ['query', root, '/s'], {
1008
+ encoding: 'utf8',
1009
+ windowsHide: true,
1010
+ }).stdout || '';
1011
+ for (const line of output.split(/\r?\n/)) {
1012
+ if (!/DisplayIcon|InstallLocation/i.test(line)) continue;
1013
+ const value = line.replace(/^.*?REG_(?:EXPAND_)?SZ\s+/i, '').trim().replace(/^"|"$/g, '').replace(/,\d+$/, '');
1014
+ if (value) values.push(value);
1015
+ }
1016
+ }
1017
+ return values;
1018
+ }
1019
+
1020
+ /** 递归找一个名字匹配的项(开始菜单目录都很浅,递归开销可忽略) */
1021
+ async function walkFindFile(dir, nameRe) {
1022
+ let entries;
1023
+ try {
1024
+ entries = await fs.readdir(dir, { withFileTypes: true });
1025
+ } catch {
1026
+ return null;
1027
+ }
1028
+ for (const entry of entries) {
1029
+ const full = path.join(dir, entry.name);
1030
+ if (entry.isDirectory()) {
1031
+ const hit = await walkFindFile(full, nameRe);
1032
+ if (hit) return hit;
1033
+ } else if (nameRe.test(entry.name)) {
1034
+ return full;
1035
+ }
1036
+ }
1037
+ return null;
1038
+ }
1039
+
1040
+ /**
1041
+ * 开始菜单两个 Programs 根里按文件名找快捷方式。
1042
+ * 为什么需要它:微软商店分发的桌面应用(Codex 桌面版所在的 ChatGPT 桌面)
1043
+ * 不进经典 Uninstall 注册表、安装路径也猜不中,但开始菜单一定有 .lnk;
1044
+ * 且 .lnk 本身就是可启动目标(ShellExecute 认快捷方式,见 launchDesktopAppTarget)。
1045
+ */
1046
+ async function findStartMenuShortcut(nameRe) {
1047
+ const roots = [
1048
+ path.join(process.env.APPDATA || '', 'Microsoft', 'Windows', 'Start Menu', 'Programs'),
1049
+ path.join(process.env.PROGRAMDATA || '', 'Microsoft', 'Windows', 'Start Menu', 'Programs'),
1050
+ ].filter(Boolean);
1051
+ for (const root of roots) {
1052
+ const hit = await walkFindFile(root, nameRe);
1053
+ if (hit) return hit;
1054
+ }
1055
+ return null;
1056
+ }
1057
+
1058
+ /**
1059
+ * 探测某个工具的官方桌面应用:装没装、启动目标是什么。
1060
+ * target 是**可直接启动的东西**(exe / .app / .lnk / Linux 可执行文件);
1061
+ * 没装上 target 为 null —— 前端据此把菜单项翻成「下载 X」并打开官网地址。
1062
+ *
1063
+ * 三平台口径:
1064
+ * Windows 固定安装路径 → 卸载注册表(DisplayIcon 指向 exe / InstallLocation
1065
+ * 是目录时拼期望 exe 名)→ 开始菜单 .lnk(商店版应用走这条)
1066
+ * macOS /Applications/<App>.app
1067
+ * Linux 固定候选路径(桌面版分发最不稳的平台,猜不中就降级成"去官网下载")
1068
+ */
1069
+ async function findDesktopApp(tool) {
1070
+ const app = TOOL_DESKTOP_APPS[tool];
1071
+ if (!app) return { installed: false, target: null };
1072
+
1073
+ if (process.platform === 'win32') {
1074
+ const roots = [process.env.LOCALAPPDATA, process.env.PROGRAMFILES].filter(Boolean);
1075
+ for (const rel of app.winPaths || []) {
1076
+ for (const root of roots) {
1077
+ const candidate = path.join(root, rel);
1078
+ if (await isFileAt(candidate)) return { installed: true, target: candidate };
1079
+ }
1080
+ }
1081
+ // 注册表值两种形态:DisplayIcon 多直接指向 exe;InstallLocation 是目录,
1082
+ // 在目录下拼期望的 exe 名。值与 exe 名对不上的一律不算(防误报)。
1083
+ for (const value of scanWindowsUninstallPaths()) {
1084
+ if (/\.(ico|png|jpg|jpeg|svg)$/i.test(value)) continue;
1085
+ if (app.winExeRe.test(value) && (await isFileAt(value))) {
1086
+ return { installed: true, target: value };
1087
+ }
1088
+ if (await isDirAt(value)) {
1089
+ const exe = path.join(value, app.winExeName);
1090
+ if (await isFileAt(exe)) return { installed: true, target: exe };
1091
+ }
1092
+ }
1093
+ const lnk = await findStartMenuShortcut(app.winLnkRe);
1094
+ if (lnk) return { installed: true, target: lnk };
1095
+ return { installed: false, target: null };
1096
+ }
1097
+
1098
+ if (process.platform === 'darwin') {
1099
+ const appDir = `/Applications/${app.macApp}`;
1100
+ return (await isDirAt(appDir)) ? { installed: true, target: appDir } : { installed: false, target: null };
1101
+ }
1102
+
1103
+ for (const candidate of app.linuxPaths || []) {
1104
+ if (await isFileAt(candidate)) return { installed: true, target: candidate };
1105
+ }
1106
+ return { installed: false, target: null };
1107
+ }
1108
+
1109
+ /**
1110
+ * 启动桌面应用。target 的三种形态分开处理:
1111
+ * .lnk(开始菜单快捷方式)不能直接 spawn(CreateProcess 不认快捷方式),
1112
+ * 走 cmd /c start 让 ShellExecute 去解析;
1113
+ * .app(macOS)是目录,交给 open 命令;
1114
+ * 其余(Windows exe / Linux 可执行文件)直接 detached spawn,与 zcode 一致。
1115
+ */
1116
+ async function launchDesktopAppTarget(target) {
1117
+ if (process.platform === 'win32') {
1118
+ if (/\.lnk$/i.test(target)) {
1119
+ return spawnDetached('cmd.exe', ['/c', 'start', '""', target]);
1120
+ }
1121
+ return spawnDetached(target, []);
1122
+ }
1123
+ if (/\.app$/i.test(target)) {
1124
+ return spawnDetached('open', [target]);
1125
+ }
1126
+ return spawnDetached(target, []);
1127
+ }
1128
+
1129
+ async function launchToolInstaller(installer, dirPath = process.cwd(), { update = false } = {}) {
1130
+ // update=true 时改用 updateXxx 字段(升级命令与安装命令动词不同:
1131
+ // npm @latest / winget upgrade / brew upgrade / snap refresh / kimi 脚本幂等重跑)
1132
+ const kindField = update ? 'updateKind' : 'kind';
1133
+ const execCmdField = update ? 'updateExecutionCommand' : 'executionCommand';
1134
+ const cmdField = update ? 'updateCommand' : 'command';
1135
+ const executableField = update ? 'updateExecutable' : 'executable';
1136
+ const argsField = update ? 'updateArgs' : 'args';
1137
+
1138
+ if (installer[kindField] === 'script') {
1139
+ if (process.platform === 'win32') {
1140
+ // 显式创建新控制台;编码脚本可避免 cmd.exe 截获 PowerShell 管道符。
1141
+ const encoded = Buffer.from(installer[execCmdField] || installer[cmdField], 'utf16le').toString('base64');
1142
+ return spawnDetached('cmd.exe', [
1143
+ '/c', 'start', '""', 'powershell.exe', '-NoLogo', '-NoExit',
1144
+ '-ExecutionPolicy', 'Bypass', '-EncodedCommand', encoded,
1145
+ ], { cwd: dirPath });
1146
+ }
1147
+ return launchInTerminal(dirPath, 'bash', ['-lc', installer[cmdField]]);
1148
+ }
1149
+ if (process.platform === 'win32') {
1150
+ // 安装/更新命令完全来自服务端白名单。使用可见 cmd 窗口,让用户看到进度和错误。
1151
+ const commandLine = [installer[executableField], ...installer[argsField]].join(' ');
1152
+ return spawnDetached('cmd.exe', ['/c', 'start', '', 'cmd.exe', '/k', commandLine], {
1153
+ cwd: dirPath,
1154
+ });
1155
+ }
1156
+
1157
+ return launchInTerminal(dirPath, installer[executableField], installer[argsField]);
1158
+ }
1159
+
1160
+ /**
1161
+ * 在新终端窗口里跑一条**服务端白名单内的**命令,跑完窗口不自动关。
1162
+ *
1163
+ * 与 launchToolInstaller 的区别:那个吃 installer 描述符(executable + args),
1164
+ * 这个直接吃整条命令串。给「登录」这类**交互式** CLI 用 —— `gh auth login`
1165
+ * 会走一串问答(选 GitHub.com / 协议 / 浏览器还是粘贴 token),必须在真终端里跑:
1166
+ * 被 spawn 出去又没有 TTY 的话,它直接就失败了,用户屏幕上什么也看不到。
1167
+ *
1168
+ * Windows 用 `cmd /k`:登录完成后窗口留着,用户能看清结果;失败也能看到报错。
1169
+ *
1170
+ * ⚠️ command 必须是服务端自己拼出来的常量。永远不要把它接成请求体里的字符串 ——
1171
+ * 那等于开了一个任意命令执行的口子(和 /api/install-tool 同一条原则)。
1172
+ *
1173
+ * `pathDirs` 是**必须**看的参数(2026-09-22 实测踩到):
1174
+ * 本进程的 `process.env.PATH` 是"启动那一刻"的快照。用户在界面里刚装好 gh,
1175
+ * 安装器改的是**注册表**里的 PATH,我们这个已经跑着的 node 进程看不见 ——
1176
+ * 于是出现很割裂的一幕:**探测**能找到 gh(它走注册表 + 默认落点),界面显示
1177
+ * "gh 2.101.0 已安装",可 `cmd /k gh auth login` 这个子进程按自己的 PATH 找,
1178
+ * 回一句 `'gh' 不是内部或外部命令`;而用户自己新开一个 cmd 却是好的,
1179
+ * 因为新进程拿到的是最新 PATH。
1180
+ * 解法有两层,缺一不可:
1181
+ * 1. **基线兜底**(`augmentEnvPath`):把注册表 Machine + User 的 PATH 里本进程
1182
+ * 快照没有的目录补进子进程 —— 语义上等价于"这个进程是现在才启动的"。
1183
+ * 所以**不传 `pathDirs` 也不会踩旧快照**,`launchGai` 这类入口不用各自记得传。
1184
+ * 2. `pathDirs`:调用方**已经知道 CLI 落点**时显式**前置**,抢在别的同名命令之前。
1185
+ * 注意两层语义不同:兜底是追加(不动原有优先级),pathDirs 是前置(要优先)。
1186
+ * 比起"把可执行文件全路径拼进命令行",前置 PATH 还顺带绕开了 cmd.exe 那套
1187
+ * "路径带空格时的嵌套引号"地狱(`C:\Program Files\GitHub CLI\gh.exe`)。
1188
+ */
1189
+ export async function launchCommandInTerminal(command, { cwd = process.cwd(), pathDirs = [] } = {}) {
1190
+ const dirs = pathDirs.filter(Boolean);
1191
+ const augmented = await augmentEnvPath(process.env);
1192
+ let env;
1193
+ if (dirs.length) {
1194
+ env = withPathValue(augmented, [...dirs, pathValueOf(augmented)].filter(Boolean).join(path.delimiter));
1195
+ } else {
1196
+ // 没有要补的就传 undefined,让子进程原样继承父环境(与改动前行为一致)
1197
+ env = augmented === process.env ? undefined : augmented;
1198
+ }
1199
+
1200
+ if (process.platform === 'win32') {
1201
+ return spawnDetached('cmd.exe', ['/c', 'start', '', 'cmd.exe', '/k', command], { cwd, env });
1202
+ }
1203
+ // posix 侧不额外处理 PATH:那边没有"安装器改注册表"这套机制,
1204
+ // 终端模拟器直接继承图形会话的环境,不会出现上面的错配。
1205
+ return launchInTerminal(cwd, 'bash', ['-lc', command]);
1206
+ }
1207
+
1208
+ export function registerFileOpenRoutes({
1209
+ app,
1210
+ launchAi = launchGai,
1211
+ }) {
1212
+ app.post('/api/open-directory-with-g-ai', asyncRoute(async (req, res) => {
1213
+ const dirPath = req.body?.path;
1214
+ if (typeof dirPath !== 'string' || !dirPath.trim() || /[\0\r\n]/.test(dirPath)) {
1215
+ throw new HttpError(400, '目录路径不能为空或包含控制字符');
1216
+ }
1217
+ const target = path.resolve(dirPath);
1218
+ let stat;
1219
+ try { stat = await fs.stat(target); }
1220
+ catch { throw new HttpError(400, '目录不存在或不可访问'); }
1221
+ if (!stat.isDirectory()) throw new HttpError(400, '指定路径不是目录');
1222
+ try { await launchAi(target); }
1223
+ catch (error) { throw new HttpError(400, `无法启动 g ai: ${error.message}`); }
1224
+ res.json({ success: true, message: '已在新终端中启动 g ai' });
1225
+ }));
1226
+
1227
+ // 打开文件
1228
+ app.post('/api/open-file', asyncRoute(async (req, res) => {
1229
+ try {
1230
+ const { filePath, context } = req.body;
1231
+
1232
+ if (!filePath) {
1233
+ return res.status(400).json({
1234
+ success: false,
1235
+ error: '文件路径不能为空'
1236
+ });
1237
+ }
1238
+
1239
+ let targetFilePath = filePath;
1240
+
1241
+ // 根据上下文处理不同的文件打开方式
1242
+ switch (context) {
1243
+ case 'git-status':
1244
+ // Git状态:直接打开当前工作目录中的文件
1245
+ targetFilePath = path.resolve(process.cwd(), filePath);
1246
+ break;
1247
+
1248
+ case 'commit-detail':
1249
+ // 提交详情:这里可以考虑创建临时文件显示该提交时的文件内容
1250
+ // 暂时先打开当前版本的文件
1251
+ targetFilePath = path.resolve(process.cwd(), filePath);
1252
+ break;
1253
+
1254
+ case 'stash-detail':
1255
+ // Stash详情:同样暂时打开当前版本的文件
1256
+ targetFilePath = path.resolve(process.cwd(), filePath);
1257
+ break;
1258
+
1259
+ default:
1260
+ targetFilePath = path.resolve(process.cwd(), filePath);
1261
+ }
1262
+
1263
+ try {
1264
+ // 检查文件是否存在
1265
+ await fs.access(targetFilePath);
1266
+
1267
+ // 使用系统默认程序打开文件
1268
+ await open(targetFilePath, { wait: false });
1269
+
1270
+ res.json({
1271
+ success: true,
1272
+ message: `已打开文件: ${path.basename(targetFilePath)}`
1273
+ });
1274
+ } catch (error) {
1275
+ // 如果文件不存在,尝试在编辑器中创建新文件
1276
+ if (error.code === 'ENOENT') {
1277
+ try {
1278
+ await open(targetFilePath, { wait: false });
1279
+ res.json({
1280
+ success: true,
1281
+ message: `已在编辑器中打开文件: ${path.basename(targetFilePath)}`
1282
+ });
1283
+ } catch (openError) {
1284
+ res.status(400).json({
1285
+ success: false,
1286
+ error: `无法打开文件 "${path.basename(targetFilePath)}": ${openError.message}`
1287
+ });
1288
+ }
1289
+ } else {
1290
+ res.status(400).json({
1291
+ success: false,
1292
+ error: `无法访问文件 "${path.basename(targetFilePath)}": ${error.message}`
1293
+ });
1294
+ }
1295
+ }
1296
+ } catch (error) {
1297
+ res.status(500).json({
1298
+ success: false,
1299
+ error: error.message
1300
+ });
1301
+ }
1302
+ }));
1303
+
1304
+ // 用VSCode打开文件
1305
+ app.post('/api/open-with-vscode', asyncRoute(async (req, res) => {
1306
+ try {
1307
+ const { filePath, context } = req.body;
1308
+
1309
+ if (!filePath) {
1310
+ return res.status(400).json({
1311
+ success: false,
1312
+ error: '文件路径不能为空'
1313
+ });
1314
+ }
1315
+
1316
+ let targetFilePath = filePath;
1317
+
1318
+ // 根据上下文处理不同的文件打开方式
1319
+ switch (context) {
1320
+ case 'git-status':
1321
+ case 'commit-detail':
1322
+ case 'stash-detail':
1323
+ targetFilePath = path.resolve(process.cwd(), filePath);
1324
+ break;
1325
+ default:
1326
+ targetFilePath = path.resolve(process.cwd(), filePath);
1327
+ }
1328
+
1329
+ try {
1330
+ // 使用VSCode打开文件
1331
+ // 尝试使用 'code' 命令打开文件
1332
+ // 使用已导入的 spawn
1333
+
1334
+ // 创建一个Promise来处理spawn的异步结果
1335
+ const spawnPromise = new Promise((resolve, reject) => {
1336
+ const vscodeProcess = spawn('code', [targetFilePath], {
1337
+ detached: true,
1338
+ stdio: 'ignore'
1339
+ });
1340
+
1341
+ // 监听错误事件
1342
+ vscodeProcess.on('error', (err) => {
1343
+ reject(err);
1344
+ });
1345
+
1346
+ // 监听spawn事件,表示进程成功启动
1347
+ vscodeProcess.on('spawn', () => {
1348
+ resolve('success');
1349
+ });
1350
+
1351
+ vscodeProcess.unref();
1352
+ });
1353
+
1354
+ await spawnPromise;
1355
+
1356
+ res.json({
1357
+ success: true,
1358
+ message: `已用VSCode打开文件: ${path.basename(targetFilePath)}`
1359
+ });
1360
+ } catch (error) {
1361
+ // 如果VSCode命令不可用,尝试直接用open打开
1362
+ try {
1363
+ await open(targetFilePath, { app: { name: 'code' } });
1364
+ res.json({
1365
+ success: true,
1366
+ message: `已用VSCode打开文件: ${path.basename(targetFilePath)}`
1367
+ });
1368
+ } catch (openError) {
1369
+ // 最后的备用方案:尝试用系统默认编辑器打开
1370
+ try {
1371
+ await open(targetFilePath);
1372
+ res.json({
1373
+ success: true,
1374
+ message: `VSCode不可用,已用系统默认程序打开文件: ${path.basename(targetFilePath)}`
1375
+ });
1376
+ } catch (finalError) {
1377
+ res.status(400).json({
1378
+ success: false,
1379
+ error: `无法打开文件 "${path.basename(targetFilePath)}": VSCode可能未安装或未添加到PATH,且系统默认程序也无法打开该文件`
1380
+ });
1381
+ }
1382
+ }
1383
+ }
1384
+ } catch (error) {
1385
+ res.status(500).json({
1386
+ success: false,
1387
+ error: error.message
1388
+ });
1389
+ }
1390
+ }));
1391
+
1392
+ // 用 VSCode 打开目录
1393
+ app.post('/api/open-directory-with-vscode', asyncRoute(async (req, res) => {
1394
+ try {
1395
+ const { path: dirPath } = req.body;
1396
+ if (!dirPath) {
1397
+ throw new HttpError(400, '目录路径不能为空');
1398
+ }
1399
+
1400
+ try {
1401
+ await spawnDetached('code', [dirPath]);
1402
+ res.json({ success: true, message: '已用 VSCode 打开目录' });
1403
+ } catch {
1404
+ // fallback:通过 open 模块指定 code 应用
1405
+ try {
1406
+ await open(dirPath, { app: { name: 'code' } });
1407
+ res.json({ success: true, message: '已用 VSCode 打开目录' });
1408
+ } catch (openError) {
1409
+ res.status(400).json({ success: false, error: 'VSCode 可能未安装或未添加到 PATH' });
1410
+ }
1411
+ }
1412
+ } catch (error) {
1413
+ res.status(500).json({ success: false, error: error.message });
1414
+ }
1415
+ }));
1416
+
1417
+ // 用 Claude Code 打开目录
1418
+ app.post('/api/open-directory-with-claude-code', asyncRoute(async (req, res) => {
1419
+ try {
1420
+ const { path: dirPath, permissionMode } = req.body || {};
1421
+ if (!dirPath) {
1422
+ throw new HttpError(400, '目录路径不能为空');
1423
+ }
1424
+
1425
+ try {
1426
+ await fs.access(dirPath);
1427
+ } catch (error) {
1428
+ throw new HttpError(400, `目录不存在或不可访问: ${dirPath}`);
1429
+ }
1430
+
1431
+ try {
1432
+ await launchClaudeCode(dirPath, { permissionMode });
1433
+ const message = permissionMode
1434
+ ? `已用 Claude Code 打开目录(permission-mode=${permissionMode})`
1435
+ : '已用 Claude Code 打开目录';
1436
+ res.json({ success: true, message });
1437
+ } catch (error) {
1438
+ res.status(400).json({
1439
+ success: false,
1440
+ error: error.message || '未检测到 Claude Code,请先安装并确保可以在终端中直接运行 claude'
1441
+ });
1442
+ }
1443
+ } catch (error) {
1444
+ res.status(500).json({ success: false, error: error.message });
1445
+ }
1446
+ }));
1447
+
1448
+ // 用 Codex 打开目录
1449
+ app.post('/api/open-directory-with-codex', asyncRoute(async (req, res) => {
1450
+ try {
1451
+ const { path: dirPath, permissionMode } = req.body || {};
1452
+ if (!dirPath) {
1453
+ throw new HttpError(400, '目录路径不能为空');
1454
+ }
1455
+
1456
+ try {
1457
+ await fs.access(dirPath);
1458
+ } catch (error) {
1459
+ throw new HttpError(400, `目录不存在或不可访问: ${dirPath}`);
1460
+ }
1461
+
1462
+ try {
1463
+ await launchCodex(dirPath, { permissionMode });
1464
+ const codexArgs = buildCodexArgs(permissionMode);
1465
+ const message = codexArgs.length
1466
+ ? `已用 Codex 打开目录(${codexArgs.join(' ')})`
1467
+ : '已用 Codex 打开目录';
1468
+ res.json({ success: true, message });
1469
+ } catch (error) {
1470
+ res.status(400).json({
1471
+ success: false,
1472
+ error: error.message || '未检测到 Codex,请先安装并确保可以在终端中直接运行 codex'
1473
+ });
1474
+ }
1475
+ } catch (error) {
1476
+ res.status(500).json({ success: false, error: error.message });
1477
+ }
1478
+ }));
1479
+
1480
+ // 用 OpenCode 打开目录
1481
+ app.post('/api/open-directory-with-opencode', asyncRoute(async (req, res) => {
1482
+ try {
1483
+ const { path: dirPath, permissionMode } = req.body || {};
1484
+ if (!dirPath) {
1485
+ throw new HttpError(400, '目录路径不能为空');
1486
+ }
1487
+
1488
+ try {
1489
+ await fs.access(dirPath);
1490
+ } catch (error) {
1491
+ throw new HttpError(400, `目录不存在或不可访问: ${dirPath}`);
1492
+ }
1493
+
1494
+ try {
1495
+ await launchOpenCode(dirPath, { permissionMode });
1496
+ const message = OPENCODE_AUTO_MODES.has(permissionMode)
1497
+ ? '已用 OpenCode 打开目录(--auto:自动批准未被显式拒绝的权限)'
1498
+ : '已用 OpenCode 打开目录';
1499
+ res.json({ success: true, message });
1500
+ } catch (error) {
1501
+ res.status(400).json({
1502
+ success: false,
1503
+ error: error.message || '未检测到 OpenCode,请先安装并确保可以在终端中直接运行 opencode'
1504
+ });
1505
+ }
1506
+ } catch (error) {
1507
+ res.status(500).json({ success: false, error: error.message });
1508
+ }
1509
+ }));
1510
+
1511
+ // 用 CodeBuddy Code 打开目录
1512
+ app.post('/api/open-directory-with-codebuddy', asyncRoute(async (req, res) => {
1513
+ try {
1514
+ const { path: dirPath, permissionMode } = req.body || {};
1515
+ if (!dirPath) {
1516
+ throw new HttpError(400, '目录路径不能为空');
1517
+ }
1518
+
1519
+ try {
1520
+ await fs.access(dirPath);
1521
+ } catch (error) {
1522
+ throw new HttpError(400, `目录不存在或不可访问: ${dirPath}`);
1523
+ }
1524
+
1525
+ try {
1526
+ await launchCodebuddy(dirPath, { permissionMode });
1527
+ const message = CODEBUDDY_MODE_FLAGS.has(permissionMode)
1528
+ ? `已用 CodeBuddy Code 打开目录(permission-mode=${permissionMode})`
1529
+ : '已用 CodeBuddy Code 打开目录';
1530
+ res.json({ success: true, message });
1531
+ } catch (error) {
1532
+ res.status(400).json({
1533
+ success: false,
1534
+ error: error.message || '未检测到 CodeBuddy Code,请先安装并确保可以在终端中直接运行 codebuddy'
1535
+ });
1536
+ }
1537
+ } catch (error) {
1538
+ res.status(500).json({ success: false, error: error.message });
1539
+ }
1540
+ }));
1541
+
1542
+ // 用 Pi 打开目录
1543
+ app.post('/api/open-directory-with-pi', asyncRoute(async (req, res) => {
1544
+ try {
1545
+ const { path: dirPath } = req.body || {};
1546
+ if (!dirPath) {
1547
+ throw new HttpError(400, '目录路径不能为空');
1548
+ }
1549
+
1550
+ try {
1551
+ await fs.access(dirPath);
1552
+ } catch (error) {
1553
+ throw new HttpError(400, `目录不存在或不可访问: ${dirPath}`);
1554
+ }
1555
+
1556
+ try {
1557
+ await launchPi(dirPath);
1558
+ res.json({ success: true, message: '已用 Pi 打开目录' });
1559
+ } catch (error) {
1560
+ res.status(400).json({
1561
+ success: false,
1562
+ error: error.message || '未检测到 Pi,请先安装并确保可以在终端中直接运行 pi'
1563
+ });
1564
+ }
1565
+ } catch (error) {
1566
+ res.status(500).json({ success: false, error: error.message });
1567
+ }
1568
+ }));
1569
+
1570
+ // 用码道 CLI 打开目录
1571
+ app.post('/api/open-directory-with-codearts', asyncRoute(async (req, res) => {
1572
+ try {
1573
+ const { path: dirPath } = req.body || {};
1574
+ if (!dirPath) {
1575
+ throw new HttpError(400, '目录路径不能为空');
1576
+ }
1577
+
1578
+ try {
1579
+ await fs.access(dirPath);
1580
+ } catch (error) {
1581
+ throw new HttpError(400, `目录不存在或不可访问: ${dirPath}`);
1582
+ }
1583
+
1584
+ try {
1585
+ await launchCodeArts(dirPath);
1586
+ res.json({ success: true, message: '已用 CodeArts 码道打开目录' });
1587
+ } catch (error) {
1588
+ res.status(400).json({
1589
+ success: false,
1590
+ error: error.message || '未检测到 codearts,请先安装并确保可以在终端中直接运行 codearts'
1591
+ });
1592
+ }
1593
+ } catch (error) {
1594
+ res.status(500).json({ success: false, error: error.message });
1595
+ }
1596
+ }));
1597
+
1598
+ // 用 Kimi Code 打开目录
1599
+ app.post('/api/open-directory-with-kimi', asyncRoute(async (req, res) => {
1600
+ const { path: dirPath, permissionMode } = req.body || {};
1601
+ if (!dirPath) throw new HttpError(400, '目录路径不能为空');
1602
+ try {
1603
+ await fs.access(dirPath);
1604
+ await launchKimiCode(dirPath, { permissionMode });
1605
+ const message = KIMI_MODE_FLAGS.has(permissionMode)
1606
+ ? '已用 Kimi Code 打开目录(--auto:从不打断)'
1607
+ : '已用 Kimi Code 打开目录';
1608
+ res.json({ success: true, message });
1609
+ } catch (error) {
1610
+ res.status(400).json({ success: false, error: error.message || '未检测到 Kimi Code,请先安装 kimi CLI' });
1611
+ }
1612
+ }));
1613
+
1614
+ // 用 DeepSeek Harness 打开目录
1615
+ app.post('/api/open-directory-with-dsh', asyncRoute(async (req, res) => {
1616
+ const { path: dirPath } = req.body || {};
1617
+ if (!dirPath) throw new HttpError(400, '目录路径不能为空');
1618
+ try {
1619
+ await fs.access(dirPath);
1620
+ await launchDsh(dirPath);
1621
+ res.json({ success: true, message: '已启动 DeepSeek Harness' });
1622
+ } catch (error) {
1623
+ res.status(400).json({ success: false, error: error.message || '无法启动 DeepSeek Harness,请确认已安装 Node.js' });
1624
+ }
1625
+ }));
1626
+
1627
+ // 用 ZCode 打开目录
1628
+ app.post('/api/open-directory-with-zcode', asyncRoute(async (req, res) => {
1629
+ const { path: dirPath } = req.body || {};
1630
+ if (!dirPath) throw new HttpError(400, '目录路径不能为空');
1631
+ try {
1632
+ await fs.access(dirPath);
1633
+ await launchZCode(dirPath);
1634
+ res.json({ success: true, message: '已用 ZCode 打开目录' });
1635
+ } catch (error) {
1636
+ res.status(400).json({ success: false, error: error.message || '未检测到 ZCode,请从官网下载桌面安装包' });
1637
+ }
1638
+ }));
1639
+
1640
+ // 安装本地工具:前端只能传固定 tool id,实际命令由服务端按平台从白名单选择。
1641
+ app.post('/api/install-tool', asyncRoute(async (req, res) => {
1642
+ const { tool } = req.body || {};
1643
+ const installers = getToolInstallers();
1644
+ const installer = installers[tool];
1645
+
1646
+ if (!installer) {
1647
+ throw new HttpError(400, '不支持的工具');
1648
+ }
1649
+ if (!installer.supported) {
1650
+ throw new HttpError(400, installer.note || '当前系统不支持一键安装,请按照官方文档手动安装');
1651
+ }
1652
+
1653
+ await launchToolInstaller(installer);
1654
+ res.json({
1655
+ success: true,
1656
+ message: '安装命令已在新终端中启动',
1657
+ });
1658
+ }));
1659
+
1660
+ // 更新本地工具:与 install-tool 同一套白名单,只是换用 updateXxx 命令变体
1661
+ // (npm @latest / winget upgrade / brew upgrade / snap refresh / kimi 脚本重跑)。
1662
+ // 没有 updateCommand 的工具(如 zcode 桌面应用)返回 400。
1663
+ app.post('/api/update-tool', asyncRoute(async (req, res) => {
1664
+ const { tool } = req.body || {};
1665
+ const installers = getToolInstallers();
1666
+ const installer = installers[tool];
1667
+
1668
+ if (!installer) {
1669
+ throw new HttpError(400, '不支持的工具');
1670
+ }
1671
+ if (!installer.updateCommand) {
1672
+ throw new HttpError(400, installer.updateNote || '当前工具不支持一键更新,请按官方文档手动升级');
1673
+ }
1674
+
1675
+ await launchToolInstaller(installer, process.cwd(), { update: true });
1676
+ res.json({
1677
+ success: true,
1678
+ message: '更新命令已在新终端中启动',
1679
+ });
1680
+ }));
1681
+
1682
+ // 查询 npm registry 上的最新版本,供前端更新菜单显示「当前 v1 → 最新 v2」。
1683
+ // 只支持 TOOL_INSTALL_PACKAGES 里的 npm 工具;网络失败的工具返回 null(前端降级为原文案)。
1684
+ // body 可选 { tools: ['claude', ...] } —— 缺省查全部 npm 工具。
1685
+ app.post('/api/latest-tool-versions', asyncRoute(async (req, res) => {
1686
+ const { tools } = req.body || {};
1687
+ let requested;
1688
+ if (tools === undefined) {
1689
+ requested = Object.keys(TOOL_INSTALL_PACKAGES);
1690
+ } else {
1691
+ if (!Array.isArray(tools) || tools.some((t) => typeof t !== 'string')) {
1692
+ throw new HttpError(400, 'tools 必须是字符串数组');
1693
+ }
1694
+ const unknown = tools.filter((t) => !TOOL_INSTALL_PACKAGES[t]);
1695
+ if (unknown.length) {
1696
+ throw new HttpError(400, `不支持的工具: ${unknown.join(', ')}`);
1697
+ }
1698
+ requested = tools;
1699
+ }
1700
+
1701
+ const entries = await Promise.all(
1702
+ requested.map(async (tool) => [tool, await fetchLatestVersion(TOOL_INSTALL_PACKAGES[tool])]),
1703
+ );
1704
+ res.json({
1705
+ success: true,
1706
+ latest: Object.fromEntries(entries),
1707
+ });
1708
+ }));
1709
+
1710
+ // 打开某个工具的官方桌面应用(顶栏 / 项目菜单里的「打开 X 桌面端」走这里)。
1711
+ // 没装桌面端时返回 400 并带上官网地址 —— 前端在检测阶段就分叉了(没装直接
1712
+ // window.open 官网),会走到这里说明是检测之后才卸载的边角情况。
1713
+ app.post('/api/open-desktop-app', asyncRoute(async (req, res) => {
1714
+ const { tool } = req.body || {};
1715
+ const app = TOOL_DESKTOP_APPS[tool];
1716
+ if (!app) {
1717
+ throw new HttpError(400, '该工具没有桌面端');
1718
+ }
1719
+ const found = await findDesktopApp(tool);
1720
+ if (!found.installed || !found.target) {
1721
+ throw new HttpError(400, `未检测到 ${app.name},请先到官网下载安装:${app.downloadUrl}`);
1722
+ }
1723
+ try {
1724
+ await launchDesktopAppTarget(found.target);
1725
+ res.json({ success: true, message: `已打开 ${app.name}` });
1726
+ } catch (error) {
1727
+ res.status(500).json({ success: false, error: error.message || `无法启动 ${app.name}` });
1728
+ }
1729
+ }));
1730
+
1731
+ // 检测本地工具是否已安装 + 采集版本号(供前端决定按钮行为、tooltip 显示版本)
1732
+ // 检测方式: spawn 'tool --version',exit 0 即视为已安装;stdout/stderr 里提取 semver。
1733
+ // 超时 15s:Claude CLI / opencode 等 Node 包装的工具首次冷启动
1734
+ // 在 Windows 上经常 5-7s(磁盘 cache + Defender 扫描),3s 永远命中超时。
1735
+ app.get('/api/check-tools', asyncRoute(async (req, res) => {
1736
+ const checkCmd = (cmd) => new Promise((resolve) => {
1737
+ const child = spawn(cmd, ['--version'], {
1738
+ // 要捕获输出来解析版本号,不能再用 'ignore'
1739
+ stdio: ['ignore', 'pipe', 'pipe'],
1740
+ shell: process.platform === 'win32',
1741
+ windowsHide: true,
1742
+ });
1743
+ let output = '';
1744
+ let done = false;
1745
+ const finish = (ok) => {
1746
+ if (done) return;
1747
+ done = true;
1748
+ try { child.kill('SIGKILL'); } catch {}
1749
+ resolve({ installed: ok, version: parseVersionOutput(output) });
1750
+ };
1751
+ // 有些 CLI 把版本号打到 stderr(如部分 Node 包装工具),两边都收
1752
+ child.stdout.on('data', (chunk) => { output += chunk; });
1753
+ child.stderr.on('data', (chunk) => { output += chunk; });
1754
+ child.on('error', () => finish(false));
1755
+ child.on('exit', (code) => finish(code === 0));
1756
+ setTimeout(() => finish(false), 15000);
1757
+ });
1758
+
1759
+ // dsh / kimi 走 find*Executable 拿到完整路径,再补一次 --version 采版本。
1760
+ // zcode 是纯 GUI 桌面应用,ZCode.exe --version 行为未知(可能弹窗),不采集。
1761
+ const versionOfExecutable = (executable) => {
1762
+ if (!executable) return Promise.resolve(null);
1763
+ return new Promise((resolve) => {
1764
+ const child = spawn(executable, ['--version'], {
1765
+ stdio: ['ignore', 'pipe', 'pipe'],
1766
+ shell: process.platform === 'win32',
1767
+ windowsHide: true,
1768
+ });
1769
+ let output = '';
1770
+ let done = false;
1771
+ const finish = () => {
1772
+ if (done) return;
1773
+ done = true;
1774
+ try { child.kill('SIGKILL'); } catch {}
1775
+ resolve(parseVersionOutput(output));
1776
+ };
1777
+ child.stdout.on('data', (chunk) => { output += chunk; });
1778
+ child.stderr.on('data', (chunk) => { output += chunk; });
1779
+ child.on('error', () => finish());
1780
+ child.on('exit', () => finish());
1781
+ setTimeout(() => finish(), 15000);
1782
+ });
1783
+ };
1784
+
1785
+ // dsh / kimi 的版本采集依赖 find 结果,用 then 链接保持全并行
1786
+ // (find 会跑两次——一次拿路径一次链版本——都是纯 fs/注册表查询,开销可忽略)
1787
+ // ⚠️ codebuddy 的 --version 是安全的:实测它只打印版本号就退出,不起引擎服务器
1788
+ // (裸跑 codebuddy 才会占 62443,与 checkCmd 的 --version 用法无关)。
1789
+ const [vscodeRes, claudeRes, codexRes, opencodeRes, codebuddyRes, piRes, codeArtsExecutable, kimiExecutable, zcodeExecutable, dshExecutable, kimiVersion, dshVersion, codeArtsVersion] = await Promise.all([
1790
+ checkCmd('code'),
1791
+ checkCmd('claude'),
1792
+ checkCmd('codex'),
1793
+ checkCmd('opencode'),
1794
+ checkCmd('codebuddy'),
1795
+ checkCmd('pi'),
1796
+ findCodeArtsExecutable(),
1797
+ findKimiExecutable(),
1798
+ findZCodeExecutable(),
1799
+ findDshExecutable(),
1800
+ findKimiExecutable().then(versionOfExecutable),
1801
+ findDshExecutable().then(versionOfExecutable),
1802
+ findCodeArtsExecutable().then(versionOfExecutable),
1803
+ ]);
1804
+
1805
+ const installers = getToolInstallers();
1806
+ // 官方桌面应用探测:与 CLI 检测并行跑(各自只做 fs / 注册表查询,不起进程)。
1807
+ // 前端据此把菜单项翻成「打开 X 桌面端」(装了)/「下载 X 桌面端」(没装)。
1808
+ // downloadUrl 可能是函数(dsh 按平台/架构给固定下载地址),在这里统一解析成
1809
+ // 字符串再下发 —— 前端只认一个地址,平台判断留在服务端(与 UI 同机)。
1810
+ const desktopEntries = await Promise.all(
1811
+ Object.entries(TOOL_DESKTOP_APPS).map(async ([tool, app]) => {
1812
+ const found = await findDesktopApp(tool);
1813
+ return [tool, {
1814
+ installed: found.installed,
1815
+ name: app.name,
1816
+ downloadUrl: typeof app.downloadUrl === 'function'
1817
+ ? app.downloadUrl(process.platform, process.arch)
1818
+ : app.downloadUrl,
1819
+ }];
1820
+ }),
1821
+ );
1822
+ res.json({
1823
+ success: true,
1824
+ platform: process.platform,
1825
+ installers: publicInstallerInfo(installers),
1826
+ desktopApps: Object.fromEntries(desktopEntries),
1827
+ vscode: vscodeRes.installed,
1828
+ claude: claudeRes.installed,
1829
+ codex: codexRes.installed,
1830
+ opencode: opencodeRes.installed,
1831
+ codebuddy: codebuddyRes.installed,
1832
+ pi: piRes.installed,
1833
+ codearts: !!codeArtsExecutable,
1834
+ kimi: !!kimiExecutable,
1835
+ zcode: !!zcodeExecutable,
1836
+ dsh: !!dshExecutable,
1837
+ versions: {
1838
+ vscode: vscodeRes.version,
1839
+ claude: claudeRes.version,
1840
+ codex: codexRes.version,
1841
+ opencode: opencodeRes.version,
1842
+ codebuddy: codebuddyRes.version,
1843
+ pi: piRes.version,
1844
+ codearts: codeArtsVersion,
1845
+ kimi: kimiExecutable ? kimiVersion : null,
1846
+ zcode: null, // 桌面应用,无 CLI 版本通道
1847
+ dsh: dshExecutable ? dshVersion : null,
1848
+ },
1849
+ });
1850
+ }));
1851
+ }