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,529 +1,548 @@
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
- // `g mcp install` —— 把自己登记成别的 Agent 的 MCP 服务。
16
- //
17
- // 为什么要有它:MCP 的接入方式本质上是"在那个 Agent 的配置文件里加一段",而
18
- // 每个产品的配置**文件路径与格式都不一样**(Claude Desktop 是 json、Codex 是 toml、
19
- // VS Code 的 mcp.json 用 servers 键、OpenCode 的 server 直接挂在 mcp 下且 command
20
- // 是数组)。让用户自己手抄路径是最容易出错的一步 —— 写错位置的表现是"配了但没生效",
21
- // 而错误日志往往指向别处。
22
- //
23
- // 三条设计约束:
24
- // 1. **只加不覆盖**:合并式写入,保留文件里原有的 server 与其它配置项。
25
- // 写之前先备份(.bak),出事能退回去。
26
- // 2. **不猜未安装的产品**:目标文件所在目录都不存在就说明没装过,标记为"未检测到"
27
- // 并跳过(除非显式 --force)。免得凭空造出一堆没人用的配置文件。
28
- // 3. **幂等**:已经登记过且命令一致 → 报告"已存在",不重复写。
29
- //
30
- // 这个模块有**两个消费者**:
31
- // · CLI 的 `g mcp install`(runMcpInstall,普通命令行,往 stdout 打印人看的文本);
32
- // · g ui 的「顶栏编辑器按钮右键 → 添加 zen-gitsync MCP」(routes/mcpInstall.js,
33
- // 同一份 install/uninstall/listTargets,不另写一套)。
34
- // 所以下面所有函数都不碰 console(除 runMcpInstall 自己),也不假设调用方是终端。
35
- //
36
- // ⚠️ 这个入口是**普通命令行**,往 stdout 打印正常文本。这与 `g mcp`(stdio)不同 ——
37
- // 那条路 stdout 是协议通道,一句多余输出就会让客户端解析失败。两者靠 argv[3] 分流。
38
- import os from 'node:os';
39
- import path from 'node:path';
40
- import { existsSync } from 'node:fs';
41
- import { promises as fs } from 'node:fs';
42
-
43
- import { AI_MCP_FILE } from '../paths.js';
44
- import { fileExists } from './util.js';
45
-
46
- /** 服务 id:写进各产品配置的键名 */
47
- export const SERVER_ID = 'zen-gitsync';
48
- /** 启动命令。用 `g` 而不是绝对路径 —— 用户在 PATH 里装的就是它 */
49
- const COMMAND = 'g';
50
- const ARGS = ['mcp'];
51
-
52
- /**
53
- * 默认 server 条目:stdio 形态的 command + args。
54
- * Claude 系(Claude Desktop / Claude Code CLI / Cursor / Windsurf)、VS Code、
55
- * Pi Agent 都是这个形状;形状不同的目标用自己的 `entry` 覆盖(见 TARGETS)。
56
- */
57
- const DEFAULT_ENTRY = { command: COMMAND, args: ARGS };
58
-
59
- /**
60
- * 家目录解析。测试用 ZEN_MCP_HOME 指到临时目录(与 paths.js 里
61
- * ZEN_LOCAL_REPOS_CACHE 同一套路):TARGETS 的 file() 全是**调用时求值**,
62
- * 所以 import 之后设环境变量也来得及,不需要像 paths.js 那样抢在 import 前。
63
- */
64
- function homeDir() {
65
- return process.env.ZEN_MCP_HOME || os.homedir();
66
- }
67
-
68
- /** Windows 的 APPDATA(Claude Desktop / VS Code 的用户级配置都在它下面)。ZEN_MCP_APPDATA 为测试钩子。 */
69
- function appDataDir() {
70
- return process.env.ZEN_MCP_APPDATA || process.env.APPDATA || '';
71
- }
72
-
73
- /** 平台相关的配置文件位置。返回 null 表示该平台不支持这个目标。 */
74
- function claudeConfigPath() {
75
- if (process.platform === 'win32') {
76
- return appDataDir() ? path.join(appDataDir(), 'Claude', 'claude_desktop_config.json') : null;
77
- }
78
- if (process.platform === 'darwin') {
79
- return path.join(homeDir(), 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json');
80
- }
81
- return path.join(homeDir(), '.config', 'Claude', 'claude_desktop_config.json');
82
- }
83
-
84
- /**
85
- * VS Code **用户级** mcp.json —— 命令面板 "MCP: Open User Configuration" 打开的
86
- * 就是它(用户 profile 目录下),对所有工作区生效。注意键名是 `servers` 而不是
87
- * `mcpServers`(VS Code 自己的格式;工作区里那份可移植的 .mcp.json 才用 mcpServers)。
88
- */
89
- function vscodeConfigPath() {
90
- if (process.platform === 'win32') {
91
- return appDataDir() ? path.join(appDataDir(), 'Code', 'User', 'mcp.json') : null;
92
- }
93
- if (process.platform === 'darwin') {
94
- return path.join(homeDir(), 'Library', 'Application Support', 'Code', 'User', 'mcp.json');
95
- }
96
- return path.join(homeDir(), '.config', 'Code', 'User', 'mcp.json');
97
- }
98
-
99
- /** OpenCode 全局配置。XDG 路径,全平台一个样(~/.config/opencode/opencode.json)。 */
100
- function opencodeConfigPath() {
101
- return path.join(homeDir(), '.config', 'opencode', 'opencode.json');
102
- }
103
-
104
- /**
105
- * CodeBuddy 的 MCP 配置:官方优先级 ~/.codebuddy/.mcp.json > ~/.codebuddy/mcp.json
106
- * (deprecated) > ~/.codebuddy.json(legacy),**同作用域只读第一个存在的**。
107
- * ⚠️ 所以不能无脑建 .mcp.json:用户有 ~/.codebuddy.json 时建新文件,旧文件里的
108
- * 其他 server 会从 CodeBuddy 视野里消失(它不合并多个文件)。这里按"第一个存在的,
109
- * 都没有就建优先级最高的"来选,与官方写入规则一致。
110
- */
111
- function codebuddyConfigPath() {
112
- const candidates = [
113
- path.join(homeDir(), '.codebuddy', '.mcp.json'),
114
- path.join(homeDir(), '.codebuddy', 'mcp.json'),
115
- path.join(homeDir(), '.codebuddy.json'),
116
- ];
117
- return candidates.find(existsSync) || candidates[0];
118
- }
119
-
120
- /**
121
- * Pi Agent 的全局 MCP 配置:<agentDir>/mcp.json。agentDir 默认 ~/.pi/agent,
122
- * 可被官方环境变量 PI_CODING_AGENT_DIR 覆盖(executorModels.js 读 settings.json
123
- * 用的就是同一个口径,两处不能分叉)。
124
- */
125
- function piConfigPath() {
126
- const dir = process.env.PI_CODING_AGENT_DIR
127
- ? path.resolve(process.env.PI_CODING_AGENT_DIR)
128
- : path.join(homeDir(), '.pi', 'agent');
129
- return path.join(dir, 'mcp.json');
130
- }
131
-
132
- /**
133
- * 已知目标。
134
- * kind: 'json' = 顶层(或 jsonPath 指定的那层)有 server 表;
135
- * 'toml' = Codex 的 TOML,用 [mcp_servers.<id>] 段。
136
- *
137
- * 两个可选字段(不给就走默认):
138
- * jsonPath —— server 表在 JSON 里的路径。默认 ['mcpServers'](Claude 系);
139
- * VS Code 是 ['servers'];OpenCode 是 ['mcp'](server 名直接挂在 mcp 下,
140
- * v1 配置格式;当前 stable schema 同时兼容 mcp.servers,写 v1 形状
141
- * 1.x/2.x 都认)。
142
- * entry —— 写进去的 server 条目。默认 { command:'g', args:['mcp'] };
143
- * OpenCode 的 command 是**数组**且要 type/enabled;CodeBuddy 官方建议
144
- * 显式 type。各家形状不同,不硬凑一个公共词。
145
- */
146
- export const TARGETS = [
147
- {
148
- id: 'zen',
149
- label: 'zen-gitsync 自己(g ai)',
150
- kind: 'json',
151
- file: () => AI_MCP_FILE,
152
- note: '装完 g ai 就能读到本工具自己的各板块数据',
153
- },
154
- {
155
- id: 'claude',
156
- label: 'Claude Desktop',
157
- kind: 'json',
158
- file: claudeConfigPath,
159
- note: '重启 Claude Desktop 生效',
160
- },
161
- {
162
- id: 'claude-code',
163
- label: 'Claude Code(CLI)',
164
- kind: 'json',
165
- file: () => path.join(homeDir(), '.claude.json'),
166
- note: '重开 claude 会话生效',
167
- },
168
- {
169
- id: 'cursor',
170
- label: 'Cursor',
171
- kind: 'json',
172
- file: () => path.join(homeDir(), '.cursor', 'mcp.json'),
173
- },
174
- {
175
- id: 'vscode',
176
- label: 'VS Code',
177
- kind: 'json',
178
- file: vscodeConfigPath,
179
- jsonPath: ['servers'],
180
- note: '命令面板运行 MCP: List Servers 可查看',
181
- },
182
- {
183
- id: 'windsurf',
184
- label: 'Windsurf',
185
- kind: 'json',
186
- file: () => path.join(homeDir(), '.codeium', 'windsurf', 'mcp_config.json'),
187
- },
188
- {
189
- id: 'opencode',
190
- label: 'OpenCode',
191
- kind: 'json',
192
- file: opencodeConfigPath,
193
- jsonPath: ['mcp'],
194
- entry: { type: 'local', command: [COMMAND, ...ARGS], enabled: true },
195
- },
196
- {
197
- id: 'codebuddy',
198
- label: 'CodeBuddy Code',
199
- kind: 'json',
200
- file: codebuddyConfigPath,
201
- entry: { type: 'stdio', ...DEFAULT_ENTRY },
202
- note: '认第一个存在的配置文件,都没建过就建 ~/.codebuddy/.mcp.json',
203
- },
204
- {
205
- id: 'pi',
206
- label: 'Pi Agent',
207
- kind: 'json',
208
- file: piConfigPath,
209
- },
210
- {
211
- id: 'codex',
212
- label: 'Codex CLI',
213
- kind: 'toml',
214
- file: () => path.join(homeDir(), '.codex', 'config.toml'),
215
- },
216
- ];
217
-
218
- /** 目标所在目录是否存在 —— 用来判断"这个产品装过没有" */
219
- async function parentExists(file) {
220
- try {
221
- const stat = await fs.stat(path.dirname(file));
222
- return stat.isDirectory();
223
- } catch {
224
- return false;
225
- }
226
- }
227
-
228
- /** server 表在 JSON 里的路径(没配 jsonPath 的目标走 Claude 系的 mcpServers) */
229
- function jsonKeysOf(target) {
230
- return target.jsonPath || ['mcpServers'];
231
- }
232
-
233
- /** 顺着 jsonPath 下沉到 server 表本身;中间任何一层不是对象就回 null */
234
- function walkToServerMap(root, keys) {
235
- let node = root;
236
- for (const key of keys) {
237
- if (!node || typeof node !== 'object' || Array.isArray(node)) return null;
238
- node = node[key];
239
- }
240
- if (!node || typeof node !== 'object' || Array.isArray(node)) return null;
241
- return node;
242
- }
243
-
244
- /** 配置里当前是否已经登记了 zen-gitsync(只为判断"装过没有") */
245
- function currentServerConfig(raw, target) {
246
- if (!raw) return null;
247
- if (target.kind === 'toml') {
248
- const re = new RegExp(`^\\[\\[?mcp_servers\\.[^\\]]*${SERVER_ID}[^\\]]*\\]\\]?\\s*$`, 'm');
249
- return re.test(raw) ? { command: COMMAND } : null;
250
- }
251
- try {
252
- return walkToServerMap(JSON.parse(raw), jsonKeysOf(target))?.[SERVER_ID] || null;
253
- } catch {
254
- return null;
255
- }
256
- }
257
-
258
- /**
259
- * 列出所有目标及其状态。
260
- * @returns {Promise<Array<{ id, label, kind, file, exists, parent_exists, installed }>>}
261
- */
262
- export async function listTargets() {
263
- const out = [];
264
- for (const target of TARGETS) {
265
- const file = target.file();
266
- if (!file) {
267
- out.push({ id: target.id, label: target.label, kind: target.kind, file: null, exists: false, parent_exists: false, installed: false });
268
- continue;
269
- }
270
- const exists = await fileExists(file);
271
- const raw = exists ? await fs.readFile(file, 'utf8').catch(() => '') : '';
272
- out.push({
273
- id: target.id,
274
- label: target.label,
275
- kind: target.kind,
276
- file,
277
- exists,
278
- parent_exists: await parentExists(file),
279
- installed: !!currentServerConfig(raw, target),
280
- });
281
- }
282
- return out;
283
- }
284
-
285
- /** 写入前备份一次。失败不影响主流程(备份是保险,不是前置条件)。 */
286
- async function backup(file) {
287
- try {
288
- await fs.copyFile(file, `${file}.bak`);
289
- return true;
290
- } catch {
291
- return false;
292
- }
293
- }
294
-
295
- async function ensureDir(file) {
296
- await fs.mkdir(path.dirname(file), { recursive: true });
297
- }
298
-
299
- /** JSON 型:合并式写入,保留原有内容 */
300
- async function installJson(file, target, { force }) {
301
- const exists = await fileExists(file);
302
- const raw = exists ? await fs.readFile(file, 'utf8').catch(() => '') : '';
303
- let parsed = null;
304
- if (raw.trim()) {
305
- try {
306
- parsed = JSON.parse(raw);
307
- } catch {
308
- // 文件内容坏了 —— 不擅自覆盖用户的东西,除非他显式 --force。
309
- // (CodeBuddy 的配置允许 JSONC 注释,那种文件也会走到这里:宁可跳过并告知,
310
- // 也不要写坏。)
311
- if (!force) return { ok: false, error: `配置文件不是合法 JSON,已跳过(要覆盖请加 --force): ${file}` };
312
- parsed = null;
313
- }
314
- }
315
- if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) parsed = {};
316
-
317
- const keys = jsonKeysOf(target);
318
- // 逐级下沉:中间层不存在就建对象;已存在但不是对象 → 宁可跳过也不覆盖用户数据。
319
- let node = parsed;
320
- for (let i = 0; i < keys.length - 1; i++) {
321
- const key = keys[i];
322
- if (node[key] === undefined || node[key] === null) node[key] = {};
323
- if (typeof node[key] !== 'object' || Array.isArray(node[key])) {
324
- return { ok: false, error: `配置文件的 ${keys.slice(0, i + 1).join('.')} 不是对象,已跳过: ${file}` };
325
- }
326
- node = node[key];
327
- }
328
- const mapKey = keys[keys.length - 1];
329
- if (node[mapKey] === undefined || node[mapKey] === null) node[mapKey] = {};
330
- if (typeof node[mapKey] !== 'object' || Array.isArray(node[mapKey])) {
331
- return { ok: false, error: `配置文件的 ${keys.join('.')} 不是对象,已跳过: ${file}` };
332
- }
333
-
334
- const before = JSON.stringify(node[mapKey][SERVER_ID] || null);
335
- node[mapKey][SERVER_ID] = target.entry || DEFAULT_ENTRY;
336
- const after = JSON.stringify(node[mapKey][SERVER_ID]);
337
- const changed = before !== after;
338
-
339
- if (changed && exists) await backup(file);
340
- await ensureDir(file);
341
- await fs.writeFile(file, `${JSON.stringify(parsed, null, 2)}\n`, 'utf8');
342
- return { ok: true, changed, backup: changed && exists };
343
- }
344
-
345
- /**
346
- * TOML 型(Codex):替换已有的 [mcp_servers.zen-gitsync] 段,没有就追加到末尾。
347
- *
348
- * 为什么不引 TOML 库:这里只需要"定位一个段 / 在末尾追加一个段",而引一整个解析器
349
- * 会为了几行文本把依赖表变长。段头识别按行首 `[`(TOML 的 `[[a.b]]` 也是合法段头,
350
- * 一并认下)。
351
- */
352
- function tomlBlock() {
353
- return `[mcp_servers.${SERVER_ID}]\ncommand = "${COMMAND}"\nargs = [${ARGS.map(a => `"${a}"`).join(', ')}]\n`;
354
- }
355
-
356
- function isTomlSectionStart(line) {
357
- return typeof line === 'string' && /^\s*\[\[?[^\]]+\]\]?\s*$/.test(line);
358
- }
359
-
360
- async function installToml(file) {
361
- const exists = await fileExists(file);
362
- const raw = exists ? await fs.readFile(file, 'utf8').catch(() => '') : '';
363
- const block = tomlBlock();
364
-
365
- const lines = raw.split(/\r?\n/);
366
- const start = lines.findIndex(l => isTomlSectionStart(l) && l.includes(`mcp_servers.${SERVER_ID}`));
367
- if (start >= 0) {
368
- let next = start + 1;
369
- while (next < lines.length && !isTomlSectionStart(lines[next])) next += 1;
370
- const merged = [...lines.slice(0, start), ...block.trimEnd().split('\n'), ...lines.slice(next)];
371
- // ⚠️ 幂等比较必须拿**归一化之后**的文本比:merged 丢掉了行尾那个空串,
372
- // 直接和 raw 比会永远差一个换行 → 每次重装都报 changed、都重写 + 备份。
373
- const nextText = `${merged.join('\n').replace(/\s*$/, '')}\n`;
374
- const changed = nextText !== raw;
375
- if (changed && exists) await backup(file);
376
- await ensureDir(file);
377
- await fs.writeFile(file, nextText, 'utf8');
378
- return { ok: true, changed, backup: changed && exists };
379
- }
380
-
381
- if (exists) await backup(file);
382
- await ensureDir(file);
383
- const prefix = raw.trim() ? `${raw.replace(/\s*$/, '')}\n\n` : '';
384
- await fs.writeFile(file, `${prefix}${block}`, 'utf8');
385
- return { ok: true, changed: true, backup: exists };
386
- }
387
-
388
- /** 从 JSON 配置里移除 zen-gitsync 这一段(顺着 jsonPath 下沉后再删) */
389
- async function uninstallJson(file, target) {
390
- const raw = await fs.readFile(file, 'utf8');
391
- let parsed;
392
- try {
393
- parsed = JSON.parse(raw);
394
- } catch {
395
- return { ok: false, error: `配置文件不是合法 JSON,不敢动: ${file}` };
396
- }
397
- const map = walkToServerMap(parsed, jsonKeysOf(target));
398
- if (!map || !(SERVER_ID in map)) {
399
- return { ok: true, id: target.id, file, skipped: true, reason: '本来就没登记' };
400
- }
401
- delete map[SERVER_ID];
402
- await backup(file);
403
- await fs.writeFile(file, `${JSON.stringify(parsed, null, 2)}\n`, 'utf8');
404
- return { ok: true, id: target.id, file, changed: true, backup: true };
405
- }
406
-
407
- /**
408
- * 把一个目标登记上。
409
- * @returns {Promise<{ ok: boolean, id?: string, file?: string, changed?: boolean,
410
- * backup?: boolean, skipped?: boolean, reason?: string, error?: string }>}
411
- */
412
- export async function install(targetId, { force = false } = {}) {
413
- const target = TARGETS.find(t => t.id === targetId);
414
- if (!target) return { ok: false, error: `未知目标 "${targetId}"。可用: ${TARGETS.map(t => t.id).join(', ')}` };
415
- const file = target.file();
416
- if (!file) return { ok: false, id: target.id, error: '当前平台不适用' };
417
-
418
- // 目标产品的目录都不存在 = 多半没装过。除非 --force,否则不凭空造配置。
419
- if (!(await parentExists(file)) && !force) {
420
- return { ok: true, id: target.id, file, skipped: true, reason: `没检测到 ${target.label}(目录不存在)。确认装过的话加 --force。` };
421
- }
422
-
423
- try {
424
- const result = target.kind === 'toml' ? await installToml(file) : await installJson(file, target, { force });
425
- return { ...result, id: target.id, file };
426
- } catch (err) {
427
- return { ok: false, id: target.id, file, error: err?.message || String(err) };
428
- }
429
- }
430
-
431
- /** 从配置里移除 zen-gitsync 这一段 */
432
- export async function uninstall(targetId) {
433
- const target = TARGETS.find(t => t.id === targetId);
434
- if (!target) return { ok: false, error: `未知目标 "${targetId}"` };
435
- const file = target.file();
436
- if (!file || !(await fileExists(file))) return { ok: true, id: target.id, file, skipped: true, reason: '配置文件不存在,无需移除' };
437
-
438
- try {
439
- if (target.kind === 'toml') {
440
- const raw = await fs.readFile(file, 'utf8');
441
- const lines = raw.split(/\r?\n/);
442
- const start = lines.findIndex(l => isTomlSectionStart(l) && l.includes(`mcp_servers.${SERVER_ID}`));
443
- if (start < 0) return { ok: true, id: target.id, file, skipped: true, reason: '本来就没有这一段' };
444
- let next = start + 1;
445
- while (next < lines.length && !isTomlSectionStart(lines[next])) next += 1;
446
- const merged = [...lines.slice(0, start), ...lines.slice(next)];
447
- await backup(file);
448
- await fs.writeFile(file, `${merged.join('\n').replace(/\n{3,}/g, '\n\n').replace(/\s*$/, '')}\n`, 'utf8');
449
- return { ok: true, id: target.id, file, changed: true, backup: true };
450
- }
451
- return await uninstallJson(file, target);
452
- } catch (err) {
453
- return { ok: false, id: target.id, file, error: err?.message || String(err) };
454
- }
455
- }
456
-
457
- // ── 命令行 ─────────────────────────────────────────────────────
458
-
459
- const HELP = [
460
- 'g mcp install —— 把 zen-gitsync 登记成别的 Agent 的 MCP 服务',
461
- '',
462
- '用法:',
463
- ' g mcp install 登记到自家全局(g ai 立刻可用)',
464
- ' g mcp install --list 列出所有检测到的目标与状态',
465
- ' g mcp install claude 只登记到指定的目标',
466
- ' g mcp install --all 登记到所有目标(未检测到的会跳过)',
467
- ' g mcp install --remove claude 移除',
468
- '',
469
- '可选: --force(目标目录不存在也写 / 覆盖损坏的 JSON)',
470
- '',
471
- '可用目标:',
472
- ].join('\n');
473
-
474
- /**
475
- * CLI 入口。往 stdout 打印人类可读的结果(这个子命令不是 stdio 协议模式)。
476
- * @param {string[]} argv - `install` 之后的参数
477
- */
478
- export async function runMcpInstall(argv = []) {
479
- const has = flag => argv.includes(flag);
480
- const positional = argv.filter(a => !a.startsWith('--'));
481
-
482
- if (has('--help') || has('-h')) {
483
- console.log(HELP);
484
- for (const t of TARGETS) console.log(` ${t.id.padEnd(10)} ${t.label}`);
485
- return;
486
- }
487
-
488
- if (has('--list')) {
489
- const targets = await listTargets();
490
- console.log('MCP 接入目标:');
491
- for (const t of targets) {
492
- const state = t.installed ? '已登记' : (t.exists ? '未登记' : (t.parent_exists ? '未创建' : '未检测到'));
493
- console.log(` ${String(t.id).padEnd(10)} ${String(t.label).padEnd(24)} [${state}] ${t.file || '—'}`);
494
- }
495
- console.log(`\n登记时写入: { "command": "${COMMAND}", "args": ${JSON.stringify(ARGS)} }`);
496
- return;
497
- }
498
-
499
- const force = has('--force');
500
- const remove = has('--remove');
501
-
502
- let ids;
503
- if (has('--all')) {
504
- ids = (await listTargets()).filter(t => t.file).map(t => t.id);
505
- } else if (positional.length > 0) {
506
- ids = positional;
507
- } else {
508
- ids = ['zen'];
509
- }
510
-
511
- for (const id of ids) {
512
- const result = remove ? await uninstall(id) : await install(id, { force });
513
- const label = (TARGETS.find(t => t.id === id) || {}).label || id;
514
- if (!result.ok) {
515
- console.log(`✗ ${label}: ${result.error}`);
516
- continue;
517
- }
518
- if (result.skipped) {
519
- console.log(`- ${label}: 跳过 —— ${result.reason}`);
520
- continue;
521
- }
522
- const verb = remove ? '已移除' : (result.changed ? '已登记' : '已存在(无需改动)');
523
- const suffix = result.backup ? '(已备份 .bak)' : '';
524
- console.log(`✓ ${label}: ${verb} ${suffix}\n ${result.file}`);
525
- if (!remove) console.log(` 写入: { "command": "${COMMAND}", "args": ${JSON.stringify(ARGS)} }`);
526
- }
527
- }
528
-
529
- export default { runMcpInstall, install, uninstall, listTargets, TARGETS, SERVER_ID };
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
+ // `g mcp install` —— 把自己登记成别的 Agent 的 MCP 服务。
16
+ //
17
+ // 为什么要有它:MCP 的接入方式本质上是"在那个 Agent 的配置文件里加一段",而
18
+ // 每个产品的配置**文件路径与格式都不一样**(Claude Desktop 是 json、Codex 是 toml、
19
+ // VS Code 的 mcp.json 用 servers 键、OpenCode 的 server 直接挂在 mcp 下且 command
20
+ // 是数组)。让用户自己手抄路径是最容易出错的一步 —— 写错位置的表现是"配了但没生效",
21
+ // 而错误日志往往指向别处。
22
+ //
23
+ // 三条设计约束:
24
+ // 1. **只加不覆盖**:合并式写入,保留文件里原有的 server 与其它配置项。
25
+ // 写之前先备份(.bak),出事能退回去。
26
+ // 2. **不猜未安装的产品**:目标文件所在目录都不存在就说明没装过,标记为"未检测到"
27
+ // 并跳过(除非显式 --force)。免得凭空造出一堆没人用的配置文件。
28
+ // 3. **幂等**:已经登记过且命令一致 → 报告"已存在",不重复写。
29
+ //
30
+ // 这个模块有**两个消费者**:
31
+ // · CLI 的 `g mcp install`(runMcpInstall,普通命令行,往 stdout 打印人看的文本);
32
+ // · g ui 的「顶栏编辑器按钮右键 → 添加 zen-gitsync MCP」(routes/mcpInstall.js,
33
+ // 同一份 install/uninstall/listTargets,不另写一套)。
34
+ // 所以下面所有函数都不碰 console(除 runMcpInstall 自己),也不假设调用方是终端。
35
+ //
36
+ // ⚠️ 这个入口是**普通命令行**,往 stdout 打印正常文本。这与 `g mcp`(stdio)不同 ——
37
+ // 那条路 stdout 是协议通道,一句多余输出就会让客户端解析失败。两者靠 argv[3] 分流。
38
+ import os from 'node:os';
39
+ import path from 'node:path';
40
+ import { existsSync } from 'node:fs';
41
+ import { promises as fs } from 'node:fs';
42
+
43
+ import { AI_MCP_FILE } from '../paths.js';
44
+ import { fileExists } from './util.js';
45
+
46
+ /** 服务 id:写进各产品配置的键名 */
47
+ export const SERVER_ID = 'zen-gitsync';
48
+ /** 启动命令。用 `g` 而不是绝对路径 —— 用户在 PATH 里装的就是它 */
49
+ const COMMAND = 'g';
50
+ const ARGS = ['mcp'];
51
+
52
+ /**
53
+ * 默认 server 条目:stdio 形态的 command + args。
54
+ * Claude 系(Claude Desktop / Claude Code CLI / Cursor / Windsurf)、VS Code、
55
+ * Pi Agent 都是这个形状;形状不同的目标用自己的 `entry` 覆盖(见 TARGETS)。
56
+ */
57
+ const DEFAULT_ENTRY = { command: COMMAND, args: ARGS };
58
+
59
+ /**
60
+ * 家目录解析。测试用 ZEN_MCP_HOME 指到临时目录(与 paths.js 里
61
+ * ZEN_LOCAL_REPOS_CACHE 同一套路):TARGETS 的 file() 全是**调用时求值**,
62
+ * 所以 import 之后设环境变量也来得及,不需要像 paths.js 那样抢在 import 前。
63
+ */
64
+ function homeDir() {
65
+ return process.env.ZEN_MCP_HOME || os.homedir();
66
+ }
67
+
68
+ /** Windows 的 APPDATA(Claude Desktop / VS Code 的用户级配置都在它下面)。ZEN_MCP_APPDATA 为测试钩子。 */
69
+ function appDataDir() {
70
+ return process.env.ZEN_MCP_APPDATA || process.env.APPDATA || '';
71
+ }
72
+
73
+ /** 平台相关的配置文件位置。返回 null 表示该平台不支持这个目标。 */
74
+ function claudeConfigPath() {
75
+ if (process.platform === 'win32') {
76
+ return appDataDir() ? path.join(appDataDir(), 'Claude', 'claude_desktop_config.json') : null;
77
+ }
78
+ if (process.platform === 'darwin') {
79
+ return path.join(homeDir(), 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json');
80
+ }
81
+ return path.join(homeDir(), '.config', 'Claude', 'claude_desktop_config.json');
82
+ }
83
+
84
+ /**
85
+ * VS Code **用户级** mcp.json —— 命令面板 "MCP: Open User Configuration" 打开的
86
+ * 就是它(用户 profile 目录下),对所有工作区生效。注意键名是 `servers` 而不是
87
+ * `mcpServers`(VS Code 自己的格式;工作区里那份可移植的 .mcp.json 才用 mcpServers)。
88
+ */
89
+ function vscodeConfigPath() {
90
+ if (process.platform === 'win32') {
91
+ return appDataDir() ? path.join(appDataDir(), 'Code', 'User', 'mcp.json') : null;
92
+ }
93
+ if (process.platform === 'darwin') {
94
+ return path.join(homeDir(), 'Library', 'Application Support', 'Code', 'User', 'mcp.json');
95
+ }
96
+ return path.join(homeDir(), '.config', 'Code', 'User', 'mcp.json');
97
+ }
98
+
99
+ /** OpenCode 全局配置。XDG 路径,全平台一个样(~/.config/opencode/opencode.json)。 */
100
+ function opencodeConfigPath() {
101
+ return path.join(homeDir(), '.config', 'opencode', 'opencode.json');
102
+ }
103
+
104
+ /**
105
+ * CodeBuddy 的 MCP 配置:官方优先级 ~/.codebuddy/.mcp.json > ~/.codebuddy/mcp.json
106
+ * (deprecated) > ~/.codebuddy.json(legacy),**同作用域只读第一个存在的**。
107
+ * ⚠️ 所以不能无脑建 .mcp.json:用户有 ~/.codebuddy.json 时建新文件,旧文件里的
108
+ * 其他 server 会从 CodeBuddy 视野里消失(它不合并多个文件)。这里按"第一个存在的,
109
+ * 都没有就建优先级最高的"来选,与官方写入规则一致。
110
+ */
111
+ function codebuddyConfigPath() {
112
+ const candidates = [
113
+ path.join(homeDir(), '.codebuddy', '.mcp.json'),
114
+ path.join(homeDir(), '.codebuddy', 'mcp.json'),
115
+ path.join(homeDir(), '.codebuddy.json'),
116
+ ];
117
+ return candidates.find(existsSync) || candidates[0];
118
+ }
119
+
120
+ /**
121
+ * 码道 CLI 的 MCP 配置:~/.codeartsdoer/codearts_cli.json 的 mcp 段。
122
+ * 路径与文件名取自它自己 shim 里的 OPENCODE_CONFIG / OPENCODE_CONFIG_FILE
123
+ * (与 taskRunner.buildCodeArtsEnv 同一口径,两处不能分叉)。
124
+ * 它是 opencode 的 fork,所以 entry 形状照抄 opencode 那条(type: local + command 数组)。
125
+ */
126
+ function codeArtsConfigPath() {
127
+ return path.join(homeDir(), '.codeartsdoer', 'codearts_cli.json');
128
+ }
129
+
130
+ /**
131
+ * Pi Agent 的全局 MCP 配置:<agentDir>/mcp.json。agentDir 默认 ~/.pi/agent,
132
+ * 可被官方环境变量 PI_CODING_AGENT_DIR 覆盖(executorModels.js 读 settings.json
133
+ * 用的就是同一个口径,两处不能分叉)。
134
+ */
135
+ function piConfigPath() {
136
+ const dir = process.env.PI_CODING_AGENT_DIR
137
+ ? path.resolve(process.env.PI_CODING_AGENT_DIR)
138
+ : path.join(homeDir(), '.pi', 'agent');
139
+ return path.join(dir, 'mcp.json');
140
+ }
141
+
142
+ /**
143
+ * 已知目标。
144
+ * kind: 'json' = 顶层(或 jsonPath 指定的那层)有 server 表;
145
+ * 'toml' = Codex 的 TOML,用 [mcp_servers.<id>] 段。
146
+ *
147
+ * 两个可选字段(不给就走默认):
148
+ * jsonPath —— server 表在 JSON 里的路径。默认 ['mcpServers'](Claude 系);
149
+ * VS Code 是 ['servers'];OpenCode 是 ['mcp'](server 名直接挂在 mcp 下,
150
+ * v1 配置格式;当前 stable schema 同时兼容 mcp.servers,写 v1 形状
151
+ * 1.x/2.x 都认)。
152
+ * entry —— 写进去的 server 条目。默认 { command:'g', args:['mcp'] };
153
+ * OpenCode 的 command 是**数组**且要 type/enabled;CodeBuddy 官方建议
154
+ * 显式 type。各家形状不同,不硬凑一个公共词。
155
+ */
156
+ export const TARGETS = [
157
+ {
158
+ id: 'zen',
159
+ label: 'zen-gitsync 自己(g ai)',
160
+ kind: 'json',
161
+ file: () => AI_MCP_FILE,
162
+ note: '装完 g ai 就能读到本工具自己的各板块数据',
163
+ },
164
+ {
165
+ id: 'claude',
166
+ label: 'Claude Desktop',
167
+ kind: 'json',
168
+ file: claudeConfigPath,
169
+ note: '重启 Claude Desktop 生效',
170
+ },
171
+ {
172
+ id: 'claude-code',
173
+ label: 'Claude Code(CLI)',
174
+ kind: 'json',
175
+ file: () => path.join(homeDir(), '.claude.json'),
176
+ note: '重开 claude 会话生效',
177
+ },
178
+ {
179
+ id: 'cursor',
180
+ label: 'Cursor',
181
+ kind: 'json',
182
+ file: () => path.join(homeDir(), '.cursor', 'mcp.json'),
183
+ },
184
+ {
185
+ id: 'vscode',
186
+ label: 'VS Code',
187
+ kind: 'json',
188
+ file: vscodeConfigPath,
189
+ jsonPath: ['servers'],
190
+ note: '命令面板运行 MCP: List Servers 可查看',
191
+ },
192
+ {
193
+ id: 'windsurf',
194
+ label: 'Windsurf',
195
+ kind: 'json',
196
+ file: () => path.join(homeDir(), '.codeium', 'windsurf', 'mcp_config.json'),
197
+ },
198
+ {
199
+ id: 'opencode',
200
+ label: 'OpenCode',
201
+ kind: 'json',
202
+ file: opencodeConfigPath,
203
+ jsonPath: ['mcp'],
204
+ entry: { type: 'local', command: [COMMAND, ...ARGS], enabled: true },
205
+ },
206
+ {
207
+ id: 'codebuddy',
208
+ label: 'CodeBuddy Code',
209
+ kind: 'json',
210
+ file: codebuddyConfigPath,
211
+ entry: { type: 'stdio', ...DEFAULT_ENTRY },
212
+ note: '认第一个存在的配置文件,都没建过就建 ~/.codebuddy/.mcp.json',
213
+ },
214
+ {
215
+ id: 'pi',
216
+ label: 'Pi Agent',
217
+ kind: 'json',
218
+ file: piConfigPath,
219
+ },
220
+ {
221
+ id: 'codearts',
222
+ label: 'CodeArts 码道',
223
+ kind: 'json',
224
+ file: codeArtsConfigPath,
225
+ jsonPath: ['mcp'],
226
+ entry: { type: 'local', command: [COMMAND, ...ARGS], enabled: true },
227
+ note: '写进 codearts_cli.json(码道是 opencode 的 fork,段形状同 OpenCode)',
228
+ },
229
+ {
230
+ id: 'codex',
231
+ label: 'Codex CLI',
232
+ kind: 'toml',
233
+ file: () => path.join(homeDir(), '.codex', 'config.toml'),
234
+ },
235
+ ];
236
+
237
+ /** 目标所在目录是否存在 —— 用来判断"这个产品装过没有" */
238
+ async function parentExists(file) {
239
+ try {
240
+ const stat = await fs.stat(path.dirname(file));
241
+ return stat.isDirectory();
242
+ } catch {
243
+ return false;
244
+ }
245
+ }
246
+
247
+ /** server 表在 JSON 里的路径(没配 jsonPath 的目标走 Claude 系的 mcpServers) */
248
+ function jsonKeysOf(target) {
249
+ return target.jsonPath || ['mcpServers'];
250
+ }
251
+
252
+ /** 顺着 jsonPath 下沉到 server 表本身;中间任何一层不是对象就回 null */
253
+ function walkToServerMap(root, keys) {
254
+ let node = root;
255
+ for (const key of keys) {
256
+ if (!node || typeof node !== 'object' || Array.isArray(node)) return null;
257
+ node = node[key];
258
+ }
259
+ if (!node || typeof node !== 'object' || Array.isArray(node)) return null;
260
+ return node;
261
+ }
262
+
263
+ /** 配置里当前是否已经登记了 zen-gitsync(只为判断"装过没有") */
264
+ function currentServerConfig(raw, target) {
265
+ if (!raw) return null;
266
+ if (target.kind === 'toml') {
267
+ const re = new RegExp(`^\\[\\[?mcp_servers\\.[^\\]]*${SERVER_ID}[^\\]]*\\]\\]?\\s*$`, 'm');
268
+ return re.test(raw) ? { command: COMMAND } : null;
269
+ }
270
+ try {
271
+ return walkToServerMap(JSON.parse(raw), jsonKeysOf(target))?.[SERVER_ID] || null;
272
+ } catch {
273
+ return null;
274
+ }
275
+ }
276
+
277
+ /**
278
+ * 列出所有目标及其状态。
279
+ * @returns {Promise<Array<{ id, label, kind, file, exists, parent_exists, installed }>>}
280
+ */
281
+ export async function listTargets() {
282
+ const out = [];
283
+ for (const target of TARGETS) {
284
+ const file = target.file();
285
+ if (!file) {
286
+ out.push({ id: target.id, label: target.label, kind: target.kind, file: null, exists: false, parent_exists: false, installed: false });
287
+ continue;
288
+ }
289
+ const exists = await fileExists(file);
290
+ const raw = exists ? await fs.readFile(file, 'utf8').catch(() => '') : '';
291
+ out.push({
292
+ id: target.id,
293
+ label: target.label,
294
+ kind: target.kind,
295
+ file,
296
+ exists,
297
+ parent_exists: await parentExists(file),
298
+ installed: !!currentServerConfig(raw, target),
299
+ });
300
+ }
301
+ return out;
302
+ }
303
+
304
+ /** 写入前备份一次。失败不影响主流程(备份是保险,不是前置条件)。 */
305
+ async function backup(file) {
306
+ try {
307
+ await fs.copyFile(file, `${file}.bak`);
308
+ return true;
309
+ } catch {
310
+ return false;
311
+ }
312
+ }
313
+
314
+ async function ensureDir(file) {
315
+ await fs.mkdir(path.dirname(file), { recursive: true });
316
+ }
317
+
318
+ /** JSON 型:合并式写入,保留原有内容 */
319
+ async function installJson(file, target, { force }) {
320
+ const exists = await fileExists(file);
321
+ const raw = exists ? await fs.readFile(file, 'utf8').catch(() => '') : '';
322
+ let parsed = null;
323
+ if (raw.trim()) {
324
+ try {
325
+ parsed = JSON.parse(raw);
326
+ } catch {
327
+ // 文件内容坏了 —— 不擅自覆盖用户的东西,除非他显式 --force。
328
+ // (CodeBuddy 的配置允许 JSONC 注释,那种文件也会走到这里:宁可跳过并告知,
329
+ // 也不要写坏。)
330
+ if (!force) return { ok: false, error: `配置文件不是合法 JSON,已跳过(要覆盖请加 --force): ${file}` };
331
+ parsed = null;
332
+ }
333
+ }
334
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) parsed = {};
335
+
336
+ const keys = jsonKeysOf(target);
337
+ // 逐级下沉:中间层不存在就建对象;已存在但不是对象 → 宁可跳过也不覆盖用户数据。
338
+ let node = parsed;
339
+ for (let i = 0; i < keys.length - 1; i++) {
340
+ const key = keys[i];
341
+ if (node[key] === undefined || node[key] === null) node[key] = {};
342
+ if (typeof node[key] !== 'object' || Array.isArray(node[key])) {
343
+ return { ok: false, error: `配置文件的 ${keys.slice(0, i + 1).join('.')} 不是对象,已跳过: ${file}` };
344
+ }
345
+ node = node[key];
346
+ }
347
+ const mapKey = keys[keys.length - 1];
348
+ if (node[mapKey] === undefined || node[mapKey] === null) node[mapKey] = {};
349
+ if (typeof node[mapKey] !== 'object' || Array.isArray(node[mapKey])) {
350
+ return { ok: false, error: `配置文件的 ${keys.join('.')} 不是对象,已跳过: ${file}` };
351
+ }
352
+
353
+ const before = JSON.stringify(node[mapKey][SERVER_ID] || null);
354
+ node[mapKey][SERVER_ID] = target.entry || DEFAULT_ENTRY;
355
+ const after = JSON.stringify(node[mapKey][SERVER_ID]);
356
+ const changed = before !== after;
357
+
358
+ if (changed && exists) await backup(file);
359
+ await ensureDir(file);
360
+ await fs.writeFile(file, `${JSON.stringify(parsed, null, 2)}\n`, 'utf8');
361
+ return { ok: true, changed, backup: changed && exists };
362
+ }
363
+
364
+ /**
365
+ * TOML 型(Codex):替换已有的 [mcp_servers.zen-gitsync] 段,没有就追加到末尾。
366
+ *
367
+ * 为什么不引 TOML 库:这里只需要"定位一个段 / 在末尾追加一个段",而引一整个解析器
368
+ * 会为了几行文本把依赖表变长。段头识别按行首 `[`(TOML 的 `[[a.b]]` 也是合法段头,
369
+ * 一并认下)。
370
+ */
371
+ function tomlBlock() {
372
+ return `[mcp_servers.${SERVER_ID}]\ncommand = "${COMMAND}"\nargs = [${ARGS.map(a => `"${a}"`).join(', ')}]\n`;
373
+ }
374
+
375
+ function isTomlSectionStart(line) {
376
+ return typeof line === 'string' && /^\s*\[\[?[^\]]+\]\]?\s*$/.test(line);
377
+ }
378
+
379
+ async function installToml(file) {
380
+ const exists = await fileExists(file);
381
+ const raw = exists ? await fs.readFile(file, 'utf8').catch(() => '') : '';
382
+ const block = tomlBlock();
383
+
384
+ const lines = raw.split(/\r?\n/);
385
+ const start = lines.findIndex(l => isTomlSectionStart(l) && l.includes(`mcp_servers.${SERVER_ID}`));
386
+ if (start >= 0) {
387
+ let next = start + 1;
388
+ while (next < lines.length && !isTomlSectionStart(lines[next])) next += 1;
389
+ const merged = [...lines.slice(0, start), ...block.trimEnd().split('\n'), ...lines.slice(next)];
390
+ // ⚠️ 幂等比较必须拿**归一化之后**的文本比:merged 丢掉了行尾那个空串,
391
+ // 直接和 raw 比会永远差一个换行 → 每次重装都报 changed、都重写 + 备份。
392
+ const nextText = `${merged.join('\n').replace(/\s*$/, '')}\n`;
393
+ const changed = nextText !== raw;
394
+ if (changed && exists) await backup(file);
395
+ await ensureDir(file);
396
+ await fs.writeFile(file, nextText, 'utf8');
397
+ return { ok: true, changed, backup: changed && exists };
398
+ }
399
+
400
+ if (exists) await backup(file);
401
+ await ensureDir(file);
402
+ const prefix = raw.trim() ? `${raw.replace(/\s*$/, '')}\n\n` : '';
403
+ await fs.writeFile(file, `${prefix}${block}`, 'utf8');
404
+ return { ok: true, changed: true, backup: exists };
405
+ }
406
+
407
+ /** 从 JSON 配置里移除 zen-gitsync 这一段(顺着 jsonPath 下沉后再删) */
408
+ async function uninstallJson(file, target) {
409
+ const raw = await fs.readFile(file, 'utf8');
410
+ let parsed;
411
+ try {
412
+ parsed = JSON.parse(raw);
413
+ } catch {
414
+ return { ok: false, error: `配置文件不是合法 JSON,不敢动: ${file}` };
415
+ }
416
+ const map = walkToServerMap(parsed, jsonKeysOf(target));
417
+ if (!map || !(SERVER_ID in map)) {
418
+ return { ok: true, id: target.id, file, skipped: true, reason: '本来就没登记' };
419
+ }
420
+ delete map[SERVER_ID];
421
+ await backup(file);
422
+ await fs.writeFile(file, `${JSON.stringify(parsed, null, 2)}\n`, 'utf8');
423
+ return { ok: true, id: target.id, file, changed: true, backup: true };
424
+ }
425
+
426
+ /**
427
+ * 把一个目标登记上。
428
+ * @returns {Promise<{ ok: boolean, id?: string, file?: string, changed?: boolean,
429
+ * backup?: boolean, skipped?: boolean, reason?: string, error?: string }>}
430
+ */
431
+ export async function install(targetId, { force = false } = {}) {
432
+ const target = TARGETS.find(t => t.id === targetId);
433
+ if (!target) return { ok: false, error: `未知目标 "${targetId}"。可用: ${TARGETS.map(t => t.id).join(', ')}` };
434
+ const file = target.file();
435
+ if (!file) return { ok: false, id: target.id, error: '当前平台不适用' };
436
+
437
+ // 目标产品的目录都不存在 = 多半没装过。除非 --force,否则不凭空造配置。
438
+ if (!(await parentExists(file)) && !force) {
439
+ return { ok: true, id: target.id, file, skipped: true, reason: `没检测到 ${target.label}(目录不存在)。确认装过的话加 --force。` };
440
+ }
441
+
442
+ try {
443
+ const result = target.kind === 'toml' ? await installToml(file) : await installJson(file, target, { force });
444
+ return { ...result, id: target.id, file };
445
+ } catch (err) {
446
+ return { ok: false, id: target.id, file, error: err?.message || String(err) };
447
+ }
448
+ }
449
+
450
+ /** 从配置里移除 zen-gitsync 这一段 */
451
+ export async function uninstall(targetId) {
452
+ const target = TARGETS.find(t => t.id === targetId);
453
+ if (!target) return { ok: false, error: `未知目标 "${targetId}"` };
454
+ const file = target.file();
455
+ if (!file || !(await fileExists(file))) return { ok: true, id: target.id, file, skipped: true, reason: '配置文件不存在,无需移除' };
456
+
457
+ try {
458
+ if (target.kind === 'toml') {
459
+ const raw = await fs.readFile(file, 'utf8');
460
+ const lines = raw.split(/\r?\n/);
461
+ const start = lines.findIndex(l => isTomlSectionStart(l) && l.includes(`mcp_servers.${SERVER_ID}`));
462
+ if (start < 0) return { ok: true, id: target.id, file, skipped: true, reason: '本来就没有这一段' };
463
+ let next = start + 1;
464
+ while (next < lines.length && !isTomlSectionStart(lines[next])) next += 1;
465
+ const merged = [...lines.slice(0, start), ...lines.slice(next)];
466
+ await backup(file);
467
+ await fs.writeFile(file, `${merged.join('\n').replace(/\n{3,}/g, '\n\n').replace(/\s*$/, '')}\n`, 'utf8');
468
+ return { ok: true, id: target.id, file, changed: true, backup: true };
469
+ }
470
+ return await uninstallJson(file, target);
471
+ } catch (err) {
472
+ return { ok: false, id: target.id, file, error: err?.message || String(err) };
473
+ }
474
+ }
475
+
476
+ // ── 命令行 ─────────────────────────────────────────────────────
477
+
478
+ const HELP = [
479
+ 'g mcp install —— 把 zen-gitsync 登记成别的 Agent 的 MCP 服务',
480
+ '',
481
+ '用法:',
482
+ ' g mcp install 登记到自家全局(g ai 立刻可用)',
483
+ ' g mcp install --list 列出所有检测到的目标与状态',
484
+ ' g mcp install claude 只登记到指定的目标',
485
+ ' g mcp install --all 登记到所有目标(未检测到的会跳过)',
486
+ ' g mcp install --remove claude 移除',
487
+ '',
488
+ '可选: --force(目标目录不存在也写 / 覆盖损坏的 JSON)',
489
+ '',
490
+ '可用目标:',
491
+ ].join('\n');
492
+
493
+ /**
494
+ * CLI 入口。往 stdout 打印人类可读的结果(这个子命令不是 stdio 协议模式)。
495
+ * @param {string[]} argv - `install` 之后的参数
496
+ */
497
+ export async function runMcpInstall(argv = []) {
498
+ const has = flag => argv.includes(flag);
499
+ const positional = argv.filter(a => !a.startsWith('--'));
500
+
501
+ if (has('--help') || has('-h')) {
502
+ console.log(HELP);
503
+ for (const t of TARGETS) console.log(` ${t.id.padEnd(10)} ${t.label}`);
504
+ return;
505
+ }
506
+
507
+ if (has('--list')) {
508
+ const targets = await listTargets();
509
+ console.log('MCP 接入目标:');
510
+ for (const t of targets) {
511
+ const state = t.installed ? '已登记' : (t.exists ? '未登记' : (t.parent_exists ? '未创建' : '未检测到'));
512
+ console.log(` ${String(t.id).padEnd(10)} ${String(t.label).padEnd(24)} [${state}] ${t.file || '—'}`);
513
+ }
514
+ console.log(`\n登记时写入: { "command": "${COMMAND}", "args": ${JSON.stringify(ARGS)} }`);
515
+ return;
516
+ }
517
+
518
+ const force = has('--force');
519
+ const remove = has('--remove');
520
+
521
+ let ids;
522
+ if (has('--all')) {
523
+ ids = (await listTargets()).filter(t => t.file).map(t => t.id);
524
+ } else if (positional.length > 0) {
525
+ ids = positional;
526
+ } else {
527
+ ids = ['zen'];
528
+ }
529
+
530
+ for (const id of ids) {
531
+ const result = remove ? await uninstall(id) : await install(id, { force });
532
+ const label = (TARGETS.find(t => t.id === id) || {}).label || id;
533
+ if (!result.ok) {
534
+ console.log(`✗ ${label}: ${result.error}`);
535
+ continue;
536
+ }
537
+ if (result.skipped) {
538
+ console.log(`- ${label}: 跳过 —— ${result.reason}`);
539
+ continue;
540
+ }
541
+ const verb = remove ? '已移除' : (result.changed ? '已登记' : '已存在(无需改动)');
542
+ const suffix = result.backup ? '(已备份 .bak)' : '';
543
+ console.log(`✓ ${label}: ${verb} ${suffix}\n ${result.file}`);
544
+ if (!remove) console.log(` 写入: { "command": "${COMMAND}", "args": ${JSON.stringify(ARGS)} }`);
545
+ }
546
+ }
547
+
548
+ export default { runMcpInstall, install, uninstall, listTargets, TARGETS, SERVER_ID };