dsh-any-agent 0.1.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +144 -77
- package/README.zh.md +148 -34
- package/bin/restore.mjs +150 -0
- package/bin/setup.mjs +204 -235
- package/package.json +4 -3
- package/src/clients.mjs +830 -0
package/src/clients.mjs
ADDED
|
@@ -0,0 +1,830 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 客户端 MCP 配置的「只改自己那一条」写入引擎。
|
|
3
|
+
*
|
|
4
|
+
* 为什么要单独一个模块:这段代码改的是别人(Claude Code、Codex、Cursor、Zed……)
|
|
5
|
+
* 的配置文件。一个会重排缩进、丢注释、把 CRLF 换成 LF 的写入器,即使「值都对」,
|
|
6
|
+
* 也是在毁用户的东西 —— 那些文件里还躺着用户手写的注释、别的 MCP 服务器、
|
|
7
|
+
* 顶层设置和几十个项目条目。
|
|
8
|
+
*
|
|
9
|
+
* 因此这里的写入全部是**文本级定点编辑**,不是「解析成对象再整体序列化」:
|
|
10
|
+
* - 只替换/插入/删除我们那一个条目所占的字符区间;
|
|
11
|
+
* - 缩进(2 空格 / 4 空格 / Tab)、键顺序、行尾(LF/CRLF)、UTF-8 BOM、
|
|
12
|
+
* 有无末尾换行,全部原样保留;
|
|
13
|
+
* - 删除后再写回,能把文件还原成写入前的字节(JSON 全程可逆)。
|
|
14
|
+
*
|
|
15
|
+
* 解析仍然要真做:只有先把 JSON/JSONC 扫成一棵带区间的树,才知道「我们那条」
|
|
16
|
+
* 从哪里到哪里。扫描器容忍注释与尾随逗号(JSONC),因为 Zed / VS Code 的
|
|
17
|
+
* 配置文件允许它们。
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import {
|
|
21
|
+
copyFileSync,
|
|
22
|
+
existsSync,
|
|
23
|
+
mkdirSync,
|
|
24
|
+
readFileSync,
|
|
25
|
+
renameSync,
|
|
26
|
+
unlinkSync,
|
|
27
|
+
writeFileSync,
|
|
28
|
+
} from 'node:fs';
|
|
29
|
+
import { platform } from 'node:os';
|
|
30
|
+
import { dirname } from 'node:path';
|
|
31
|
+
|
|
32
|
+
/** 写进每个客户端的条目名。同时也是 --remove 唯一的识别依据。 */
|
|
33
|
+
export const ENTRY_NAME = 'dsh-any-agent';
|
|
34
|
+
|
|
35
|
+
export const IS_WIN = platform() === 'win32';
|
|
36
|
+
export const IS_MAC = platform() === 'darwin';
|
|
37
|
+
|
|
38
|
+
// ── 文件读写:备份 + 原子写 ─────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
/** 写入前的备份文件名:<file>.bak-dsh-<时间戳>。 */
|
|
41
|
+
export function backupSuffix(stamp = new Date().toISOString().replace(/[:.]/g, '-')) {
|
|
42
|
+
return '.bak-dsh-' + stamp;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 备份一个文件,返回备份路径。
|
|
47
|
+
* 同一毫秒内对同一文件备份两次会撞名:撞了就加序号,绝不覆盖已有备份 ——
|
|
48
|
+
* 覆盖掉的那一份可能正是用户唯一能回滚到的状态。
|
|
49
|
+
*/
|
|
50
|
+
export function backupFile(file, stamp) {
|
|
51
|
+
const base = file + backupSuffix(stamp);
|
|
52
|
+
let dst = base;
|
|
53
|
+
for (let i = 2; existsSync(dst); i++) dst = base + '-' + i;
|
|
54
|
+
copyFileSync(file, dst);
|
|
55
|
+
return dst;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 先写临时文件再改名,避免崩溃时留下半截配置。
|
|
60
|
+
* 临时名带 pid + 随机后缀:同名会在并发运行时互相覆盖。
|
|
61
|
+
* 任何一步失败都必须删掉临时文件 —— 曾经有版本在 rename 失败后
|
|
62
|
+
* 把 mcp.json.tmp-dsh-<pid> 留在了客户端目录里。
|
|
63
|
+
*/
|
|
64
|
+
export function atomicWrite(file, text) {
|
|
65
|
+
const tmp = file + '.tmp-dsh-' + process.pid + '-' + Math.random().toString(36).slice(2, 8);
|
|
66
|
+
try {
|
|
67
|
+
writeFileSync(tmp, text, 'utf8');
|
|
68
|
+
renameSync(tmp, file);
|
|
69
|
+
} catch (e) {
|
|
70
|
+
try { if (existsSync(tmp)) unlinkSync(tmp); } catch { /* 尽力而为 */ }
|
|
71
|
+
throw e;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* 读一个配置文件,把 BOM 拆出来单独记着。
|
|
77
|
+
* 直接 JSON.parse 带 BOM 的文本会抛 —— 真实存在的编辑器配置里就有 BOM。
|
|
78
|
+
*/
|
|
79
|
+
export function readConfigFile(file) {
|
|
80
|
+
const raw = readFileSync(file, 'utf8');
|
|
81
|
+
const bom = raw.startsWith('\uFEFF') ? '\uFEFF' : '';
|
|
82
|
+
return { raw, bom, text: bom ? raw.slice(1) : raw };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ── JSON 扫描器:容忍注释与尾随逗号,返回带字符区间的树 ──────────────
|
|
86
|
+
|
|
87
|
+
const NL = String.fromCharCode(10);
|
|
88
|
+
const isWs = (c) => c === ' ' || c === '\t' || c === '\n' || c === '\r';
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* 把 JSON/JSONC 文本扫成节点树。每个节点都带 [start, end) 区间,
|
|
92
|
+
* 对象的每个条目还带 key 与 value 的区间 —— 定点编辑全靠它们。
|
|
93
|
+
* 不构造值对象(除了 key 字符串),所以大文件也不会解析两遍。
|
|
94
|
+
*/
|
|
95
|
+
export function scanJson(text) {
|
|
96
|
+
let i = 0;
|
|
97
|
+
const n = text.length;
|
|
98
|
+
const fail = (msg) => {
|
|
99
|
+
const err = new Error(msg + ' at offset ' + i);
|
|
100
|
+
err.code = 'EJSONSCAN';
|
|
101
|
+
throw err;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
function skipTrivia() {
|
|
105
|
+
for (;;) {
|
|
106
|
+
while (i < n && isWs(text[i])) i++;
|
|
107
|
+
if (text[i] === '/' && text[i + 1] === '/') {
|
|
108
|
+
while (i < n && text[i] !== '\n') i++;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (text[i] === '/' && text[i + 1] === '*') {
|
|
112
|
+
i += 2;
|
|
113
|
+
while (i < n && !(text[i] === '*' && text[i + 1] === '/')) i++;
|
|
114
|
+
i = Math.min(i + 2, n);
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function scanString() {
|
|
122
|
+
const start = i;
|
|
123
|
+
i++;
|
|
124
|
+
while (i < n) {
|
|
125
|
+
if (text[i] === '\\') { i += 2; continue; }
|
|
126
|
+
if (text[i] === '"') {
|
|
127
|
+
i++;
|
|
128
|
+
let value;
|
|
129
|
+
try { value = JSON.parse(text.slice(start, i)); } catch { value = text.slice(start + 1, i - 1); }
|
|
130
|
+
return { type: 'string', start, end: i, value };
|
|
131
|
+
}
|
|
132
|
+
i++;
|
|
133
|
+
}
|
|
134
|
+
fail('unterminated string');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function scanValue() {
|
|
138
|
+
skipTrivia();
|
|
139
|
+
const c = text[i];
|
|
140
|
+
if (c === undefined) fail('unexpected end of input');
|
|
141
|
+
if (c === '{') return scanObject();
|
|
142
|
+
if (c === '[') return scanArray();
|
|
143
|
+
if (c === '"') return scanString();
|
|
144
|
+
const start = i;
|
|
145
|
+
while (i < n && !isWs(text[i]) && text[i] !== ',' && text[i] !== '}' && text[i] !== ']') i++;
|
|
146
|
+
if (i === start) fail('unexpected character ' + JSON.stringify(c));
|
|
147
|
+
return { type: 'literal', start, end: i, raw: text.slice(start, i) };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function scanObject() {
|
|
151
|
+
const start = i;
|
|
152
|
+
i++; // {
|
|
153
|
+
const entries = [];
|
|
154
|
+
for (;;) {
|
|
155
|
+
skipTrivia();
|
|
156
|
+
if (text[i] === '}') { i++; return { type: 'object', start, end: i, entries }; }
|
|
157
|
+
if (text[i] === undefined) fail('unterminated object');
|
|
158
|
+
if (text[i] !== '"') fail('expected a quoted key');
|
|
159
|
+
const key = scanString();
|
|
160
|
+
skipTrivia();
|
|
161
|
+
if (text[i] !== ':') fail('expected ":"');
|
|
162
|
+
i++;
|
|
163
|
+
const value = scanValue();
|
|
164
|
+
entries.push({ key: key.value, keyStart: key.start, keyEnd: key.end, value });
|
|
165
|
+
skipTrivia();
|
|
166
|
+
if (text[i] === ',') { i++; continue; }
|
|
167
|
+
if (text[i] === '}') { i++; return { type: 'object', start, end: i, entries }; }
|
|
168
|
+
fail('expected "," or "}"');
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function scanArray() {
|
|
173
|
+
const start = i;
|
|
174
|
+
i++; // [
|
|
175
|
+
const items = [];
|
|
176
|
+
for (;;) {
|
|
177
|
+
skipTrivia();
|
|
178
|
+
if (text[i] === ']') { i++; return { type: 'array', start, end: i, items }; }
|
|
179
|
+
const value = scanValue();
|
|
180
|
+
items.push(value);
|
|
181
|
+
skipTrivia();
|
|
182
|
+
if (text[i] === ',') { i++; continue; }
|
|
183
|
+
if (text[i] === ']') { i++; return { type: 'array', start, end: i, items }; }
|
|
184
|
+
fail('expected "," or "]"');
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const root = scanValue();
|
|
189
|
+
skipTrivia();
|
|
190
|
+
if (i !== n) fail('trailing content');
|
|
191
|
+
return root;
|
|
192
|
+
}
|
|
193
|
+
// ── 缩进/行尾探测 ───────────────────────────────────────────────────
|
|
194
|
+
|
|
195
|
+
/** 某一位置所在行的前导空白。 */
|
|
196
|
+
function leadingWsOfLine(text, pos) {
|
|
197
|
+
let s = pos;
|
|
198
|
+
while (s > 0 && text[s - 1] !== '\n' && text[s - 1] !== '\r') s--;
|
|
199
|
+
let e = s;
|
|
200
|
+
while (e < text.length && (text[e] === ' ' || text[e] === '\t')) e++;
|
|
201
|
+
return text.slice(s, e);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** 若 pos 只被空白推到行首,返回这段空白;否则 null(说明前面还有别的内容)。 */
|
|
205
|
+
function lineIndentBefore(text, pos) {
|
|
206
|
+
let s = pos;
|
|
207
|
+
while (s > 0 && (text[s - 1] === ' ' || text[s - 1] === '\t')) s--;
|
|
208
|
+
if (s === 0 || text[s - 1] === '\n' || text[s - 1] === '\r') return text.slice(s, pos);
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** 文件用什么行尾:出现过 CRLF 就按 CRLF 写,否则 LF。 */
|
|
213
|
+
export function detectEol(text) {
|
|
214
|
+
return text.includes('\r\n') ? '\r\n' : '\n';
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* 探测一个缩进单位:取根对象里最浅那一层键的缩进(2 空格 / 4 空格 / Tab)。
|
|
219
|
+
* 认不出来时返回 null,由调用方退回两空格。
|
|
220
|
+
*/
|
|
221
|
+
export function detectIndentUnit(text, root) {
|
|
222
|
+
if (!root || root.type !== 'object') return null;
|
|
223
|
+
let best = null;
|
|
224
|
+
for (const e of root.entries) {
|
|
225
|
+
const ind = lineIndentBefore(text, e.keyStart);
|
|
226
|
+
if (ind === null || ind === '') continue;
|
|
227
|
+
if (best === null || ind.length < best.length || (ind.length === best.length && ind.includes('\t'))) best = ind;
|
|
228
|
+
}
|
|
229
|
+
return best;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** 把值渲染成 JSON 文本,再按 pos 处的缩进把后续行补齐。 */
|
|
233
|
+
export function renderValue(value, { unit, eol, indent }) {
|
|
234
|
+
const raw = JSON.stringify(value, null, unit);
|
|
235
|
+
if (!raw.includes('\n')) return raw;
|
|
236
|
+
return raw.replace(/\n/g, eol + indent);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ── 定点编辑:插入 / 替换 / 删除一个对象的键 ───────────────────────
|
|
240
|
+
|
|
241
|
+
function indexOfEntry(obj, key) {
|
|
242
|
+
return obj.entries.findIndex((e) => e.key === key);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** 在对象 obj 里插入 key;valueJson 是已渲染好的 JSON 文本。 */
|
|
246
|
+
function insertKey(text, obj, key, valueJson, { unit, eol }) {
|
|
247
|
+
const keyJson = JSON.stringify(key);
|
|
248
|
+
const slice = text.slice(obj.start, obj.end);
|
|
249
|
+
const multiline = slice.includes('\n');
|
|
250
|
+
|
|
251
|
+
if (!obj.entries.length) {
|
|
252
|
+
if (!multiline) {
|
|
253
|
+
const inner = text.slice(obj.start + 1, obj.end - 1);
|
|
254
|
+
const pad = inner.trim() === '' ? (inner === '' ? '' : inner) : ' ';
|
|
255
|
+
return text.slice(0, obj.start + 1) + pad + keyJson + ': ' + valueJson + pad + text.slice(obj.end - 1);
|
|
256
|
+
}
|
|
257
|
+
const baseIndent = leadingWsOfLine(text, obj.start);
|
|
258
|
+
const indent = baseIndent + unit;
|
|
259
|
+
const body = eol + indent + keyJson + ': ' + valueJson + eol + baseIndent;
|
|
260
|
+
return text.slice(0, obj.start + 1) + body + text.slice(obj.end - 1);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const last = obj.entries[obj.entries.length - 1];
|
|
264
|
+
const at = last.value.end;
|
|
265
|
+
if (!multiline) {
|
|
266
|
+
return text.slice(0, at) + ', ' + keyJson + ': ' + valueJson + text.slice(at);
|
|
267
|
+
}
|
|
268
|
+
const indent = lineIndentBefore(text, last.keyStart) ?? (leadingWsOfLine(text, last.keyStart) + unit);
|
|
269
|
+
const body = ',' + eol + indent + keyJson + ': ' + valueJson;
|
|
270
|
+
return text.slice(0, at) + body + text.slice(at);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function replaceValue(text, entry, valueJson) {
|
|
274
|
+
return text.slice(0, entry.value.start) + valueJson + text.slice(entry.value.end);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* 从对象 obj 里删掉 key 所在条目,并吃掉一个相邻逗号,
|
|
279
|
+
* 保证不会留下孤立的逗号,也不动别人条目的缩进。
|
|
280
|
+
*/
|
|
281
|
+
function removeKey(text, obj, key) {
|
|
282
|
+
const idx = indexOfEntry(obj, key);
|
|
283
|
+
if (idx < 0) return null;
|
|
284
|
+
const entry = obj.entries[idx];
|
|
285
|
+
|
|
286
|
+
// 只有这一个条目:还原成空对象,保留它原本的写法(单行/多行)。
|
|
287
|
+
if (obj.entries.length === 1) {
|
|
288
|
+
const slice = text.slice(obj.start, obj.end);
|
|
289
|
+
const eol = detectEol(text);
|
|
290
|
+
if (!slice.includes('\n')) return text.slice(0, obj.start) + '{}' + text.slice(obj.end);
|
|
291
|
+
const baseIndent = leadingWsOfLine(text, obj.start);
|
|
292
|
+
return text.slice(0, obj.start) + '{' + eol + baseIndent + '}' + text.slice(obj.end);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// 优先吃掉自己前面那个逗号(后面条目的缩进保持不动)。
|
|
296
|
+
const start = entry.keyStart;
|
|
297
|
+
let p = start - 1;
|
|
298
|
+
while (p >= obj.start && isWs(text[p])) p--;
|
|
299
|
+
if (text[p] === ',') {
|
|
300
|
+
let e = entry.value.end;
|
|
301
|
+
while (e < obj.end && (text[e] === ' ' || text[e] === '\t')) e++;
|
|
302
|
+
return text.slice(0, p) + text.slice(e);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// 自己是第一个条目:吃掉后面那个逗号与随后的空白。
|
|
306
|
+
let e = entry.value.end;
|
|
307
|
+
let q = e;
|
|
308
|
+
while (q < obj.end && isWs(text[q])) q++;
|
|
309
|
+
if (text[q] === ',') {
|
|
310
|
+
e = q + 1;
|
|
311
|
+
while (e < obj.end && (text[e] === ' ' || text[e] === '\t')) e++;
|
|
312
|
+
return text.slice(0, start) + text.slice(e);
|
|
313
|
+
}
|
|
314
|
+
return text.slice(0, start) + text.slice(e);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* 在 JSON/JSONC 文本里设置(value 为 null 时删除)containerKey -> entryKey 这一条。
|
|
319
|
+
* 返回 { changed, text }。不解析、不重排任何其它内容。
|
|
320
|
+
*/
|
|
321
|
+
export function editJsonEntry(rawText, containerKey, entryKey, value, options = {}) {
|
|
322
|
+
// BOM 不属于 JSON 正文,但必须原样保留:先把它们拆开,最后再拼回去。
|
|
323
|
+
const bom = rawText.startsWith('\uFEFF') ? '\uFEFF' : '';
|
|
324
|
+
const text = bom ? rawText.slice(1) : rawText;
|
|
325
|
+
const result = editJsonEntryBody(text, containerKey, entryKey, value, options);
|
|
326
|
+
return { changed: result.changed, text: bom + result.text };
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* 摘掉最后一条之后容器是空的:空容器不带任何信息,一并收掉。
|
|
331
|
+
* 这样「装一次再卸掉」能把文件还原成安装前的样子,而不是留下一个
|
|
332
|
+
* "mcpServers": {} 的脚印。只在容器确实变空时动手,别人的空容器不碰。
|
|
333
|
+
*/
|
|
334
|
+
function dropContainerIfEmpty(text, containerKey) {
|
|
335
|
+
let root;
|
|
336
|
+
try { root = scanJson(text); } catch { return text; }
|
|
337
|
+
const idx = indexOfEntry(root, containerKey);
|
|
338
|
+
if (idx < 0) return text;
|
|
339
|
+
const cont = root.entries[idx];
|
|
340
|
+
if (cont.value.type !== 'object' || cont.value.entries.length !== 0) return text;
|
|
341
|
+
const without = removeKey(text, root, containerKey);
|
|
342
|
+
if (without === null) return text;
|
|
343
|
+
// 丢容器只允许让文件更短,且必须仍然可解析;否则宁可留着空容器。
|
|
344
|
+
try { scanJson(without); } catch { return text; }
|
|
345
|
+
return without;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function editJsonEntryBody(text, containerKey, entryKey, value, options) {
|
|
349
|
+
const eol = detectEol(text);
|
|
350
|
+
const root = scanJson(text);
|
|
351
|
+
if (root.type !== 'object') throw new Error('the top level of this config is not a JSON object');
|
|
352
|
+
|
|
353
|
+
const unit = detectIndentUnit(text, root) ?? ' ';
|
|
354
|
+
const contIdx = indexOfEntry(root, containerKey);
|
|
355
|
+
|
|
356
|
+
if (value === null) {
|
|
357
|
+
if (contIdx < 0) return { changed: false, text };
|
|
358
|
+
const cont = root.entries[contIdx];
|
|
359
|
+
if (cont.value.type !== 'object') throw new Error('"' + containerKey + '" is not an object');
|
|
360
|
+
if (indexOfEntry(cont.value, entryKey) < 0) return { changed: false, text };
|
|
361
|
+
const next = removeKey(text, cont.value, entryKey);
|
|
362
|
+
if (next === null) return { changed: false, text };
|
|
363
|
+
const repaired = options.dropEmptyContainer ? dropContainerIfEmpty(next, containerKey) : next;
|
|
364
|
+
return { changed: repaired !== text, text: repaired };
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if (contIdx >= 0) {
|
|
368
|
+
const cont = root.entries[contIdx];
|
|
369
|
+
if (cont.value.type !== 'object') {
|
|
370
|
+
throw new Error('"' + containerKey + '" exists but is not an object, refusing to touch it');
|
|
371
|
+
}
|
|
372
|
+
const entryIdx = indexOfEntry(cont.value, entryKey);
|
|
373
|
+
if (entryIdx >= 0) {
|
|
374
|
+
const existing = cont.value.entries[entryIdx];
|
|
375
|
+
const currentText = text.slice(existing.value.start, existing.value.end);
|
|
376
|
+
const indent = lineIndentBefore(text, existing.keyStart);
|
|
377
|
+
const compact = indent === null || !text.slice(cont.value.start, cont.value.end).includes(NL);
|
|
378
|
+
const desired = compact ? JSON.stringify(value) : renderValue(value, { unit, eol, indent });
|
|
379
|
+
if (stripWs(currentText) === stripWs(desired)) return { changed: false, text };
|
|
380
|
+
return { changed: true, text: replaceValue(text, existing, desired) };
|
|
381
|
+
}
|
|
382
|
+
const indent = leadingWsOfLine(text, cont.keyStart);
|
|
383
|
+
const childIndent = indent + unit;
|
|
384
|
+
// 单行容器里塞一个多行值会把用户的一行配置拆散:跟着容器的写法走。
|
|
385
|
+
const compact = !text.slice(cont.value.start, cont.value.end).includes(NL);
|
|
386
|
+
const rendered = compact ? JSON.stringify(value) : renderValue(value, { unit, eol, indent: childIndent });
|
|
387
|
+
return { changed: true, text: insertKey(text, cont.value, entryKey, rendered, { unit, eol }) };
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// 连容器键都还没有:把容器连同条目一起插进去。
|
|
391
|
+
const indent = leadingWsOfLine(text, root.start);
|
|
392
|
+
const contIndent = indent + unit;
|
|
393
|
+
const compact = !text.includes('\n');
|
|
394
|
+
const renderedCont = compact
|
|
395
|
+
? JSON.stringify({ [entryKey]: value })
|
|
396
|
+
: renderValue({ [entryKey]: value }, { unit, eol, indent: contIndent });
|
|
397
|
+
return { changed: true, text: insertKey(text, root, containerKey, renderedCont, { unit, eol }) };
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** 去掉字符串字面量之外的空白,用来判断「除空白外是否等价」。 */
|
|
401
|
+
export function stripWs(text) {
|
|
402
|
+
let out = '';
|
|
403
|
+
let inStr = false;
|
|
404
|
+
let esc = false;
|
|
405
|
+
for (const ch of String(text)) {
|
|
406
|
+
if (inStr) {
|
|
407
|
+
out += ch;
|
|
408
|
+
if (esc) esc = false;
|
|
409
|
+
else if (ch === '\\') esc = true;
|
|
410
|
+
else if (ch === '"') inStr = false;
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
if (ch === '"') { inStr = true; out += ch; continue; }
|
|
414
|
+
if (isWs(ch) || ch === '\uFEFF') continue;
|
|
415
|
+
out += ch;
|
|
416
|
+
}
|
|
417
|
+
return out;
|
|
418
|
+
}
|
|
419
|
+
// ── TOML(Codex):按行编辑,保留注释/顶层键/CRLF ────────────────────
|
|
420
|
+
|
|
421
|
+
const TOML_HEADER = /^\s*\[([^\]]+)\]\s*(?:#.*)?$/;
|
|
422
|
+
|
|
423
|
+
/** 这个表头是不是我们的([mcp_servers.dsh-any-agent] 及其子表)。 */
|
|
424
|
+
function isOurTable(name) {
|
|
425
|
+
const bare = name.trim();
|
|
426
|
+
const m = /^mcp_servers\s*\.\s*(?:"dsh-any-agent"|'dsh-any-agent'|dsh-any-agent)(?:\s*\.\s*.+)?$/.exec(bare);
|
|
427
|
+
return Boolean(m);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** 找出 Codex 配置里属于我们的行区间。 */
|
|
431
|
+
function findTomlRanges(lines) {
|
|
432
|
+
const ranges = [];
|
|
433
|
+
let cur = null;
|
|
434
|
+
for (let k = 0; k < lines.length; k++) {
|
|
435
|
+
const m = TOML_HEADER.exec(lines[k]);
|
|
436
|
+
if (m) {
|
|
437
|
+
// 关闭上一个区间时必须把 end 定在「这张表的最后一行」,
|
|
438
|
+
// 而不是文件末尾 —— 否则我们这段会一路吞掉后面所有的表。
|
|
439
|
+
if (cur) { cur.end = k; ranges.push(cur); cur = null; }
|
|
440
|
+
if (isOurTable(m[1])) cur = { start: k, end: lines.length };
|
|
441
|
+
continue;
|
|
442
|
+
}
|
|
443
|
+
if (/^\s*(?:"dsh-any-agent"|'dsh-any-agent'|dsh-any-agent)\s*=/.test(lines[k])) {
|
|
444
|
+
ranges.push({ start: k, end: k + 1, inline: true });
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
if (cur) ranges.push(cur);
|
|
448
|
+
return ranges;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** 去掉一个区间时顺带收掉多出来的空行,避免越删越空。 */
|
|
452
|
+
function trimRanges(lines, ranges) {
|
|
453
|
+
return ranges.map((r) => {
|
|
454
|
+
let { start, end } = r;
|
|
455
|
+
while (end > start + 1 && lines[end - 1].trim() === '') end--;
|
|
456
|
+
if (!r.inline && start > 0 && lines[start - 1].trim() === '') start -= 1;
|
|
457
|
+
return { start, end };
|
|
458
|
+
}).sort((a, b) => b.start - a.start);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* 在 Codex 的 config.toml 里写入或删除我们的表。
|
|
463
|
+
* 只碰 [mcp_servers.dsh-any-agent] 与它的子表;顶层键、注释、其它表、
|
|
464
|
+
* 行尾全部原样。block 为 null 表示删除。
|
|
465
|
+
*/
|
|
466
|
+
export function editTomlBlock(text, block) {
|
|
467
|
+
const eol = detectEol(text);
|
|
468
|
+
// 结尾的空行数量也是文件的一部分:记下来,删除时原样还回去。
|
|
469
|
+
const trailing = (text.match(/(?:\r?\n)+$/) || [''])[0];
|
|
470
|
+
const body = trailing ? text.slice(0, text.length - trailing.length) : text;
|
|
471
|
+
const lines = body === '' ? [] : body.split(/\r?\n/);
|
|
472
|
+
|
|
473
|
+
const ranges = findTomlRanges(lines);
|
|
474
|
+
const hadOurs = ranges.length > 0;
|
|
475
|
+
const finish = (outLines) => (outLines.length ? outLines.join(eol) : '') + trailing;
|
|
476
|
+
|
|
477
|
+
if (block === null) {
|
|
478
|
+
if (!hadOurs) return { changed: false, text };
|
|
479
|
+
const kept = lines.slice();
|
|
480
|
+
for (const r of trimRanges(kept, ranges)) kept.splice(r.start, r.end - r.start);
|
|
481
|
+
while (kept.length && kept[kept.length - 1].trim() === '') kept.pop();
|
|
482
|
+
const out = finish(kept);
|
|
483
|
+
return { changed: out !== text, text: out };
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const blockLines = block.split('\n');
|
|
487
|
+
if (hadOurs) {
|
|
488
|
+
// 已经一模一样就不动:幂等的关键。
|
|
489
|
+
const current = lines.slice(ranges[0].start, ranges[ranges.length - 1].end)
|
|
490
|
+
.filter((l) => l.trim() !== '');
|
|
491
|
+
if (current.join('\n') === blockLines.filter((l) => l.trim() !== '').join('\n')) {
|
|
492
|
+
return { changed: false, text };
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const kept = lines.slice();
|
|
497
|
+
for (const r of trimRanges(kept, ranges)) kept.splice(r.start, r.end - r.start);
|
|
498
|
+
while (kept.length && kept[kept.length - 1].trim() === '') kept.pop();
|
|
499
|
+
const out = finish(kept.length ? kept.concat([''], blockLines) : blockLines.slice());
|
|
500
|
+
return { changed: true, text: out };
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// ── 各客户端的配置形状 ──────────────────────────────────────────────
|
|
504
|
+
|
|
505
|
+
/** 各客户端写 MCP 配置用的容器键。 */
|
|
506
|
+
export const CONTAINER_KEY = {
|
|
507
|
+
'claude-desktop': 'mcpServers',
|
|
508
|
+
cursor: 'mcpServers',
|
|
509
|
+
windsurf: 'mcpServers',
|
|
510
|
+
gemini: 'mcpServers',
|
|
511
|
+
zed: 'context_servers',
|
|
512
|
+
vscode: 'servers',
|
|
513
|
+
opencode: 'mcp',
|
|
514
|
+
};
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* 每种客户端对「一条 stdio MCP 服务器」的写法都不同,这里不猜:
|
|
518
|
+
* zed -> command 是对象 { path, args, env }
|
|
519
|
+
* vscode -> 需要 type: "stdio"
|
|
520
|
+
* opencode -> command 是数组,环境变量叫 environment
|
|
521
|
+
* 其余(claude-desktop / cursor / windsurf / gemini)是 { command, args, env }。
|
|
522
|
+
*/
|
|
523
|
+
export function entryShape(shape, launch, env, shapeMode) {
|
|
524
|
+
switch (shape) {
|
|
525
|
+
case 'zed': {
|
|
526
|
+
// Zed 自己的文档写的是 { command: { path, args, env } },但真实机器上
|
|
527
|
+
// 也可能是扁平的 { command, args, env }(历史版本/手工编辑留下的)。
|
|
528
|
+
// 覆盖既有条目时跟随文件里已有的写法,新建时用文档写法。
|
|
529
|
+
if (shapeMode === 'flat') return { command: launch.command, args: launch.args, env };
|
|
530
|
+
return { command: { path: launch.command, args: launch.args, env } };
|
|
531
|
+
}
|
|
532
|
+
case 'vscode':
|
|
533
|
+
return { type: 'stdio', command: launch.command, args: launch.args, env };
|
|
534
|
+
case 'opencode':
|
|
535
|
+
return { type: 'local', command: [launch.command, ...launch.args], enabled: true, environment: env };
|
|
536
|
+
default:
|
|
537
|
+
return { command: launch.command, args: launch.args, env };
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/** 从既有条目里取出可比较的形状(用于 --status 与幂等判断)。 */
|
|
542
|
+
export function describeEntry(shape, entry) {
|
|
543
|
+
if (!entry || typeof entry !== 'object') return null;
|
|
544
|
+
if (shape === 'opencode') {
|
|
545
|
+
const cmd = Array.isArray(entry.command) ? entry.command : null;
|
|
546
|
+
if (!cmd || !cmd.length) return null;
|
|
547
|
+
return { command: cmd[0], args: cmd.slice(1), env: entry.environment ?? entry.env ?? {} };
|
|
548
|
+
}
|
|
549
|
+
if (shape === 'zed') {
|
|
550
|
+
const c = entry.command;
|
|
551
|
+
if (c && typeof c === 'object') return { command: c.path, args: c.args ?? [], env: c.env ?? {} };
|
|
552
|
+
if (typeof c === 'string') return { command: c, args: entry.args ?? [], env: entry.env ?? {} };
|
|
553
|
+
return null;
|
|
554
|
+
}
|
|
555
|
+
if (typeof entry.command !== 'string') return null;
|
|
556
|
+
return { command: entry.command, args: Array.isArray(entry.args) ? entry.args : [], env: entry.env ?? {} };
|
|
557
|
+
}
|
|
558
|
+
// ── 客户端注册表 ────────────────────────────────────────────────────
|
|
559
|
+
|
|
560
|
+
import { spawnSync } from 'node:child_process';
|
|
561
|
+
import { homedir } from 'node:os';
|
|
562
|
+
import { join, resolve } from 'node:path';
|
|
563
|
+
|
|
564
|
+
/** 解析出本次要用的 home 与 appdata(--home / 环境变量 / 平台默认)。 */
|
|
565
|
+
export function resolveDirs(homeOverride) {
|
|
566
|
+
// Windows 上优先 USERPROFILE、POSIX 上优先 HOME:MSYS/Git-Bash 会把 HOME
|
|
567
|
+
// 指向别处,那会让配置写到客户端根本不会读的位置。
|
|
568
|
+
const envHome = IS_WIN
|
|
569
|
+
? (process.env.USERPROFILE || process.env.HOME)
|
|
570
|
+
: (process.env.HOME || process.env.USERPROFILE);
|
|
571
|
+
const home = homeOverride ? resolve(homeOverride) : (envHome ? resolve(envHome) : homedir());
|
|
572
|
+
let appData;
|
|
573
|
+
if (homeOverride || !process.env.APPDATA) {
|
|
574
|
+
// --home 必须把「所有」路径都带走:环境里的 APPDATA 还指着真实用户目录,
|
|
575
|
+
// 沿用它会让我们去改真机的 Claude Desktop / Zed / VS Code 配置。
|
|
576
|
+
appData = IS_WIN
|
|
577
|
+
? join(home, 'AppData', 'Roaming')
|
|
578
|
+
: (IS_MAC ? join(home, 'Library', 'Application Support') : join(home, '.config'));
|
|
579
|
+
} else appData = resolve(process.env.APPDATA);
|
|
580
|
+
return { home, appData };
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
export function which(cmd) {
|
|
584
|
+
const probe = spawnSync(IS_WIN ? 'where' : 'which', [cmd], {
|
|
585
|
+
encoding: 'utf8',
|
|
586
|
+
shell: IS_WIN,
|
|
587
|
+
windowsHide: true,
|
|
588
|
+
});
|
|
589
|
+
return probe.status === 0;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function jsonClient(id, label, shape, containerKey, pathOf) {
|
|
593
|
+
return { id, label, kind: 'json', shape, containerKey, pathOf };
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* 受支持的客户端。
|
|
598
|
+
*
|
|
599
|
+
* 注意三处形状差异,它们不是笔误:
|
|
600
|
+
* - VS Code 的 mcp.json 用 servers,不是 mcpServers;
|
|
601
|
+
* - opencode 用容器 mcp,且 command 是一个数组;
|
|
602
|
+
* - Zed 的 context_servers 里 command 是对象 { path, args, env }。
|
|
603
|
+
* Claude Code 不走文件:它的用户级配置在 ~/.claude.json,那个文件的 projects
|
|
604
|
+
* 映射里存在大小写不同的重复键,朴素 JSON.parse 会直接失败 —— 重写它等于
|
|
605
|
+
* 毁掉用户的项目列表。所以只经它自己的 claude mcp 子命令。
|
|
606
|
+
*/
|
|
607
|
+
/** 既有条目用的是什么写法(只对形状有多种合法写法的客户端有意义)。 */
|
|
608
|
+
export function existingShapeMode(client, file) {
|
|
609
|
+
if (client.shape !== 'zed' || !existsSync(file)) return undefined;
|
|
610
|
+
try {
|
|
611
|
+
const { raw } = readConfigFile(file);
|
|
612
|
+
const root = scanJson(raw);
|
|
613
|
+
const cont = root.entries.find((e) => e.key === client.containerKey);
|
|
614
|
+
if (!cont || cont.value.type !== 'object') return undefined;
|
|
615
|
+
const ours = cont.value.entries.find((e) => e.key === ENTRY_NAME);
|
|
616
|
+
if (!ours || ours.value.type !== 'object') return undefined;
|
|
617
|
+
const front = raw.slice(ours.value.start, ours.value.end);
|
|
618
|
+
// command 后面是不是紧跟一个 ":" —— 是的话就是扁平写法。
|
|
619
|
+
const cmdIdx = front.indexOf('"command"');
|
|
620
|
+
if (cmdIdx < 0) return undefined;
|
|
621
|
+
const afterCmd = front.slice(cmdIdx + 9).trimStart();
|
|
622
|
+
if (afterCmd.startsWith(':') && afterCmd.slice(1).trimStart().startsWith('"')) return 'flat';
|
|
623
|
+
return 'nested';
|
|
624
|
+
} catch { return undefined; }
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
export function clientList(dirs) {
|
|
628
|
+
const { home, appData } = dirs;
|
|
629
|
+
return [
|
|
630
|
+
{ ...jsonClient('claude-code', 'Claude Code', 'vscode', 'mcpServers', () => join(home, '.claude.json')), verifyCli: 'claude' },
|
|
631
|
+
jsonClient(
|
|
632
|
+
'claude-desktop', 'Claude Desktop', 'default', 'mcpServers',
|
|
633
|
+
() => IS_WIN || IS_MAC
|
|
634
|
+
? join(appData, 'Claude', 'claude_desktop_config.json')
|
|
635
|
+
: join(home, '.config', 'Claude', 'claude_desktop_config.json'),
|
|
636
|
+
),
|
|
637
|
+
{ id: 'codex', label: 'Codex', kind: 'toml', pathOf: () => join(home, '.codex', 'config.toml') },
|
|
638
|
+
jsonClient('cursor', 'Cursor', 'default', 'mcpServers', () => join(home, '.cursor', 'mcp.json')),
|
|
639
|
+
jsonClient('windsurf', 'Windsurf', 'default', 'mcpServers',
|
|
640
|
+
() => join(home, '.codeium', 'windsurf', 'mcp_config.json')),
|
|
641
|
+
jsonClient('zed', 'Zed', 'zed', 'context_servers',
|
|
642
|
+
() => IS_WIN || IS_MAC ? join(appData, 'Zed', 'settings.json') : join(home, '.config', 'zed', 'settings.json')),
|
|
643
|
+
jsonClient('gemini', 'Gemini CLI', 'default', 'mcpServers', () => join(home, '.gemini', 'settings.json')),
|
|
644
|
+
jsonClient('opencode', 'opencode', 'opencode', 'mcp', () => join(home, '.config', 'opencode', 'opencode.json')),
|
|
645
|
+
jsonClient('vscode', 'VS Code', 'vscode', 'servers',
|
|
646
|
+
() => IS_WIN || IS_MAC
|
|
647
|
+
? join(appData, 'Code', 'User', 'mcp.json')
|
|
648
|
+
: join(home, '.config', 'Code', 'User', 'mcp.json')),
|
|
649
|
+
];
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/** 让每个客户端「能被检测到」的依据各不相同,但都不写盘。 */
|
|
653
|
+
export function isPresent(client, dirs) {
|
|
654
|
+
if (client.kind === 'cli') return which(client.cli);
|
|
655
|
+
return existsSync(client.pathOf(dirs));
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// ── 计划 / 执行 ─────────────────────────────────────────────────────
|
|
659
|
+
|
|
660
|
+
export function defaultLaunch() {
|
|
661
|
+
// 与本模块相邻的 bin/dsh-any-agent.mjs 优先;否则退回 PATH 上的命令。
|
|
662
|
+
const here = dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, '$1'));
|
|
663
|
+
const sibling = join(here, '..', 'bin', 'dsh-any-agent.mjs');
|
|
664
|
+
if (existsSync(sibling)) return { command: process.execPath, args: [sibling], style: 'node + sibling entry' };
|
|
665
|
+
return { command: ENTRY_NAME, args: [], style: 'PATH command' };
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
export function codexBlock(launch, env) {
|
|
669
|
+
return [
|
|
670
|
+
'[mcp_servers.' + ENTRY_NAME + ']',
|
|
671
|
+
'command = ' + JSON.stringify(launch.command),
|
|
672
|
+
'args = ' + JSON.stringify(launch.args),
|
|
673
|
+
'startup_timeout_sec = 20.0',
|
|
674
|
+
'tool_timeout_sec = 120.0',
|
|
675
|
+
'',
|
|
676
|
+
'[mcp_servers.' + ENTRY_NAME + '.env]',
|
|
677
|
+
...Object.keys(env).map((k) => k + ' = ' + JSON.stringify(env[k])),
|
|
678
|
+
].join('\n');
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/** 我们期望写进去的 env。只有显式设置的项,不塞密钥,也不继承整份环境。 */
|
|
682
|
+
export function desiredEnv(permission, extra = {}) {
|
|
683
|
+
const env = { DSH_MCP_PERMISSION: permission ?? 'workspace-write', DSH_MCP_LOG: 'silent' };
|
|
684
|
+
if (process.env.DSH_BIN) env.DSH_BIN = process.env.DSH_BIN;
|
|
685
|
+
return { ...env, ...extra };
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/**
|
|
689
|
+
* 对单个客户端算出「要不要动、动什么」。不写盘。
|
|
690
|
+
* 返回的 next 是把要写入的完整文件内容(没有就不带 next)。
|
|
691
|
+
*/
|
|
692
|
+
export function planClient(client, ctx) {
|
|
693
|
+
const { dirs, remove, launch, env } = ctx;
|
|
694
|
+
const file = client.pathOf(dirs);
|
|
695
|
+
const base = { id: client.id, label: client.label, path: file };
|
|
696
|
+
|
|
697
|
+
if (client.kind === 'cli') {
|
|
698
|
+
// 计划模式绝不调用会立刻写盘的 add/remove:只做只读探测。
|
|
699
|
+
if (!which(client.cli)) {
|
|
700
|
+
return { ...base, status: 'skipped', changed: false, summary: client.cli + ' CLI not found; its file is never edited by hand' };
|
|
701
|
+
}
|
|
702
|
+
const probe = spawnSync(client.cli, ['mcp', 'get', ENTRY_NAME], {
|
|
703
|
+
encoding: 'utf8', shell: IS_WIN, windowsHide: true,
|
|
704
|
+
});
|
|
705
|
+
const registered = probe.status === 0;
|
|
706
|
+
if (remove) {
|
|
707
|
+
return registered
|
|
708
|
+
? { ...base, status: 'planned', changed: true, cliAction: 'remove', summary: 'would unregister via ' + client.cli + ' mcp remove' }
|
|
709
|
+
: { ...base, status: 'absent', changed: false, summary: 'not registered' };
|
|
710
|
+
}
|
|
711
|
+
return {
|
|
712
|
+
...base,
|
|
713
|
+
status: 'planned',
|
|
714
|
+
changed: true,
|
|
715
|
+
cliAction: 'add',
|
|
716
|
+
summary: registered ? 'would re-register (replacing the existing entry)' : 'would register via ' + client.cli + ' mcp add',
|
|
717
|
+
};
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
if (client.kind === 'toml') {
|
|
721
|
+
const block = remove ? null : codexBlock(launch, env);
|
|
722
|
+
if (!existsSync(file)) {
|
|
723
|
+
if (remove) return { ...base, status: 'absent', changed: false, summary: 'config file does not exist' };
|
|
724
|
+
return { ...base, status: 'planned', changed: true, next: editTomlBlock('', block).text, summary: 'would create the config file with our table' };
|
|
725
|
+
}
|
|
726
|
+
const { raw, text } = readConfigFile(file);
|
|
727
|
+
void raw;
|
|
728
|
+
const r = editTomlBlock(text, block);
|
|
729
|
+
if (!r.changed) {
|
|
730
|
+
return { ...base, status: 'unchanged', changed: false, summary: remove ? 'not present' : 'already up to date' };
|
|
731
|
+
}
|
|
732
|
+
return { ...base, status: 'planned', changed: true, next: r.text, summary: remove ? 'TOML block would be removed' : 'TOML block would be written' };
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
// JSON / JSONC
|
|
736
|
+
const desired = remove ? null : entryShape(client.shape, launch, env, existingShapeMode(client, file));
|
|
737
|
+
if (!existsSync(file)) {
|
|
738
|
+
if (remove) return { ...base, status: 'absent', changed: false, summary: 'config file does not exist' };
|
|
739
|
+
const seeded = editJsonEntry('{}\n', client.containerKey, ENTRY_NAME, desired);
|
|
740
|
+
return { ...base, status: 'planned', changed: true, next: seeded.text, summary: 'would create the config file with our entry' };
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
const { raw } = readConfigFile(file);
|
|
744
|
+
let r;
|
|
745
|
+
try {
|
|
746
|
+
r = editJsonEntry(raw, client.containerKey, ENTRY_NAME, desired, { dropEmptyContainer: remove === true });
|
|
747
|
+
} catch (e) {
|
|
748
|
+
// 已经有了一个解析不了的文件:拒绝,并且说清楚是「文件本身坏了」,
|
|
749
|
+
// 而不是我们写坏了 —— 一个把坏 JSON 重写掉的上手工具比没有更糟。
|
|
750
|
+
const summary = e.code === 'EJSONSCAN'
|
|
751
|
+
? 'existing file is not valid JSON (' + e.message + '); it was left untouched'
|
|
752
|
+
: 'refusing to edit: ' + e.message;
|
|
753
|
+
return { ...base, status: 'failed', changed: false, summary };
|
|
754
|
+
}
|
|
755
|
+
if (!r.changed) {
|
|
756
|
+
return { ...base, status: 'unchanged', changed: false, summary: remove ? 'not present' : 'already up to date' };
|
|
757
|
+
}
|
|
758
|
+
return { ...base, status: 'planned', changed: true, next: r.text, summary: remove ? 'entry would be removed' : 'entry would be written' };
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/**
|
|
762
|
+
* 把计划落到磁盘。write 为假时只返回计划;
|
|
763
|
+
* 任何一次写入都以 .bak-dsh-<stamp> 备份为前提。
|
|
764
|
+
*/
|
|
765
|
+
export function applyClient(plan, ctx, stamp) {
|
|
766
|
+
if (!plan || !plan.changed || !ctx.write) return plan;
|
|
767
|
+
|
|
768
|
+
const { remove, launch, env } = ctx;
|
|
769
|
+
const client = ctx.byId.get(plan.id);
|
|
770
|
+
|
|
771
|
+
if (client.kind === 'cli') {
|
|
772
|
+
const run = (a) => spawnSync(client.cli, a, { encoding: 'utf8', shell: IS_WIN, windowsHide: true });
|
|
773
|
+
const textOf = (r) => ((r.stdout || '') + (r.stderr || '')).toLowerCase();
|
|
774
|
+
|
|
775
|
+
if (plan.cliAction === 'remove') {
|
|
776
|
+
const r = run(['mcp', 'remove', ENTRY_NAME, '-s', 'user']);
|
|
777
|
+
if (r.status !== 0) {
|
|
778
|
+
const msg = textOf(r);
|
|
779
|
+
if (msg.includes('no mcp server named')) return { ...plan, status: 'absent', changed: false, summary: 'not registered' };
|
|
780
|
+
return { ...plan, status: 'failed', changed: false, summary: msg.trim().split('\n')[0] || client.cli + ' exited ' + r.status };
|
|
781
|
+
}
|
|
782
|
+
return { ...plan, status: 'removed', summary: 'removed via ' + client.cli + ' mcp remove (user scope)' };
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
// add 不会覆盖既有条目,所以先删再建;缺条目不是错误。
|
|
786
|
+
run(['mcp', 'remove', ENTRY_NAME, '-s', 'user']);
|
|
787
|
+
// Windows 上子进程经 cmd 拉起,含空格的参数不加引号会被拆开:
|
|
788
|
+
// "C:\Program Files\nodejs\node.exe" 会变成 "C:\Program" + 一个多余参数,
|
|
789
|
+
// 写出来的配置看着没错,客户端却报 CONNECTION_CLOSED。
|
|
790
|
+
const quote = (s) => (IS_WIN && /\s/.test(s) ? '"' + s + '"' : s);
|
|
791
|
+
const envs = [];
|
|
792
|
+
for (const [k, v] of Object.entries(env)) envs.push('-e', k + '=' + v);
|
|
793
|
+
const r = run(['mcp', 'add', ENTRY_NAME, '-s', 'user', ...envs, '--', quote(launch.command), ...launch.args.map(quote)]);
|
|
794
|
+
if (r.status !== 0) {
|
|
795
|
+
return { ...plan, status: 'failed', changed: false, summary: textOf(r).trim().split('\n')[0] || client.cli + ' exited ' + r.status };
|
|
796
|
+
}
|
|
797
|
+
return { ...plan, status: 'applied', summary: 'registered via ' + client.cli + ' mcp add (user scope)' };
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
if (typeof plan.next !== 'string') return plan;
|
|
801
|
+
|
|
802
|
+
try {
|
|
803
|
+
// 两个方向都备份:注册前备份,是为了「装错了能退回原样」;
|
|
804
|
+
// 删除前同样备份,是为了「删错了还能装回来」。备份内容一律是操作前的原始字节。
|
|
805
|
+
let backup;
|
|
806
|
+
if (existsSync(plan.path)) backup = backupFile(plan.path, stamp);
|
|
807
|
+
mkdirSync(dirname(plan.path), { recursive: true });
|
|
808
|
+
atomicWrite(plan.path, plan.next);
|
|
809
|
+
const base = {
|
|
810
|
+
...plan,
|
|
811
|
+
status: remove ? 'removed' : 'applied',
|
|
812
|
+
summary: remove ? 'entry removed' : 'entry written',
|
|
813
|
+
backup,
|
|
814
|
+
};
|
|
815
|
+
// 有的客户端自己维护这份文件(Claude Code 会缓存并回写 ~/.claude.json)。
|
|
816
|
+
// 有 CLI 时用它做一次只读确认,除了状态行不产生任何副作用。
|
|
817
|
+
if (client.verifyCli && which(client.verifyCli)) {
|
|
818
|
+
const probe = spawnSync(client.verifyCli, ['mcp', 'get', ENTRY_NAME], {
|
|
819
|
+
encoding: 'utf8', shell: IS_WIN, windowsHide: true,
|
|
820
|
+
});
|
|
821
|
+
const registered = probe.status === 0;
|
|
822
|
+
base.summary += registered
|
|
823
|
+
? ' (confirmed by ' + client.verifyCli + ' mcp get)'
|
|
824
|
+
: ' (note: ' + client.verifyCli + ' mcp get does not see it yet; restart the client)';
|
|
825
|
+
}
|
|
826
|
+
return base;
|
|
827
|
+
} catch (e) {
|
|
828
|
+
return { ...plan, status: 'failed', changed: false, summary: 'write failed: ' + e.message };
|
|
829
|
+
}
|
|
830
|
+
}
|