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/bin/restore.mjs
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* dsh-any-agent-restore -- 把客户端配置退回某一次备份的状态。
|
|
4
|
+
*
|
|
5
|
+
* 为什么需要它:setup 每次写入前都会留 <file>.bak-dsh-<stamp>,但真出事时
|
|
6
|
+
* 手动去每个目录里翻文件名、判断哪个是最新的、再复制回去,很容易挑错一份。
|
|
7
|
+
* 这个命令把「看有哪些备份」和「退回某一份」变成两个明确的操作。
|
|
8
|
+
*
|
|
9
|
+
* dsh-any-agent-restore --list 列出所有备份(新的在前)
|
|
10
|
+
* dsh-any-agent-restore --latest 退回每个文件最新的那份备份
|
|
11
|
+
* dsh-any-agent-restore --stamp 2026-10-30T01-35 退回指定批次
|
|
12
|
+
* dsh-any-agent-restore --file <备份文件> 退回指定的一份
|
|
13
|
+
* dsh-any-agent-restore --latest --dry-run 只看会做什么
|
|
14
|
+
*
|
|
15
|
+
* 还原本身也是破坏性操作,所以还原前会先把当前状态另存一份
|
|
16
|
+
* (后缀 .bak-dsh-pre-restore-<stamp>),退错了还能再退回来。
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { copyFileSync, existsSync, readdirSync, statSync } from 'node:fs';
|
|
20
|
+
import { basename, dirname, join } from 'node:path';
|
|
21
|
+
|
|
22
|
+
import { backupFile, clientList, resolveDirs, ENTRY_NAME } from '../src/clients.mjs';
|
|
23
|
+
|
|
24
|
+
const argv = process.argv.slice(2);
|
|
25
|
+
const has = (f) => argv.includes(f);
|
|
26
|
+
const val = (f, d) => {
|
|
27
|
+
const i = argv.indexOf(f);
|
|
28
|
+
return i >= 0 && argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[i + 1] : d;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const JSON_OUT = has('--json');
|
|
32
|
+
const log = (s = '') => (JSON_OUT ? process.stderr.write(s + '\n') : process.stdout.write(s + '\n'));
|
|
33
|
+
|
|
34
|
+
const dirs = resolveDirs(val('--home'));
|
|
35
|
+
const clients = clientList(dirs);
|
|
36
|
+
|
|
37
|
+
/** 每个客户端配置目录下的备份,按时间从新到旧。 */
|
|
38
|
+
function backupsOf(path) {
|
|
39
|
+
const dir = dirname(path);
|
|
40
|
+
const prefix = basename(path) + '.bak-dsh-';
|
|
41
|
+
if (!existsSync(dir)) return [];
|
|
42
|
+
return readdirSync(dir)
|
|
43
|
+
.filter((f) => f.startsWith(prefix))
|
|
44
|
+
.map((f) => {
|
|
45
|
+
const full = join(dir, f);
|
|
46
|
+
return { file: full, stamp: f.slice(prefix.length), mtime: statSync(full).mtimeMs };
|
|
47
|
+
})
|
|
48
|
+
.sort((a, b) => b.mtime - a.mtime);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const all = [];
|
|
52
|
+
for (const c of clients) {
|
|
53
|
+
const path = c.pathOf(dirs);
|
|
54
|
+
for (const b of backupsOf(path)) all.push({ client: c.id, target: path, ...b });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (has('--help') || has('-h') || argv.length === 0) {
|
|
58
|
+
log(
|
|
59
|
+
'dsh-any-agent-restore -- roll client configs back to a previous backup\n\n' +
|
|
60
|
+
'Usage:\n' +
|
|
61
|
+
' --list list every backup, newest first\n' +
|
|
62
|
+
' --latest restore the newest backup of each file\n' +
|
|
63
|
+
' --stamp <stamp> restore the batch with this stamp\n' +
|
|
64
|
+
' --file <path> restore one specific backup\n' +
|
|
65
|
+
' --dry-run show what would happen, write nothing\n' +
|
|
66
|
+
' --json machine-readable output on stdout\n\n' +
|
|
67
|
+
'A restore is itself destructive: the current file is copied aside as\n' +
|
|
68
|
+
'.bak-dsh-pre-restore-<stamp> first, so a restore can be undone.\n',
|
|
69
|
+
);
|
|
70
|
+
process.exit(has('--help') || has('-h') ? 0 : 1);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (has('--list')) {
|
|
74
|
+
if (JSON_OUT) {
|
|
75
|
+
process.stdout.write(JSON.stringify({ ok: true, backups: all }, null, 2) + '\n');
|
|
76
|
+
} else if (!all.length) {
|
|
77
|
+
log('没有任何备份。');
|
|
78
|
+
} else {
|
|
79
|
+
log('共 ' + all.length + ' 份备份(新的在前):');
|
|
80
|
+
for (const b of all) {
|
|
81
|
+
log(' ' + new Date(b.mtime).toISOString() + ' ' + b.client.padEnd(15) + ' ' + b.file);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
process.exit(0);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const wantStamp = val('--stamp');
|
|
88
|
+
const wantFile = val('--file');
|
|
89
|
+
const latest = has('--latest');
|
|
90
|
+
const dryRun = has('--dry-run');
|
|
91
|
+
|
|
92
|
+
if (!wantStamp && !wantFile && !latest) {
|
|
93
|
+
log('需要 --list / --latest / --stamp <stamp> / --file <path> 之一。用 --help 看用法。');
|
|
94
|
+
process.exit(1);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
let chosen;
|
|
98
|
+
if (wantFile) {
|
|
99
|
+
chosen = all.filter((b) => b.file === wantFile);
|
|
100
|
+
if (!chosen.length) {
|
|
101
|
+
log('没有这份备份: ' + wantFile);
|
|
102
|
+
process.exit(1);
|
|
103
|
+
}
|
|
104
|
+
} else if (wantStamp) {
|
|
105
|
+
chosen = all.filter((b) => b.stamp.startsWith(wantStamp));
|
|
106
|
+
if (!chosen.length) {
|
|
107
|
+
log('没有 stamp 以 ' + wantStamp + ' 开头的备份。用 --list 看有哪些。');
|
|
108
|
+
process.exit(1);
|
|
109
|
+
}
|
|
110
|
+
} else {
|
|
111
|
+
// 每个文件只取最新的一份。
|
|
112
|
+
const byTarget = new Map();
|
|
113
|
+
for (const b of all) if (!byTarget.has(b.target)) byTarget.set(b.target, b);
|
|
114
|
+
chosen = [...byTarget.values()];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (!chosen.length) {
|
|
118
|
+
log('没有可还原的备份。');
|
|
119
|
+
process.exit(0);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
123
|
+
const results = [];
|
|
124
|
+
for (const b of chosen) {
|
|
125
|
+
const row = { client: b.client, target: b.target, from: b.file, action: dryRun ? 'would-restore' : 'restored' };
|
|
126
|
+
if (!dryRun) {
|
|
127
|
+
try {
|
|
128
|
+
// 还原也是破坏性操作:先把「现在」也存一份。
|
|
129
|
+
if (existsSync(b.target)) row.safety = backupFile(b.target, 'pre-restore-' + stamp);
|
|
130
|
+
copyFileSync(b.file, b.target);
|
|
131
|
+
} catch (e) {
|
|
132
|
+
row.action = 'failed';
|
|
133
|
+
row.error = e.message;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
results.push(row);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const failed = results.filter((r) => r.action === 'failed').length;
|
|
140
|
+
if (JSON_OUT) {
|
|
141
|
+
process.stdout.write(JSON.stringify({ ok: failed === 0, dryRun, results }, null, 2) + '\n');
|
|
142
|
+
} else {
|
|
143
|
+
for (const r of results) {
|
|
144
|
+
log(' ' + (r.action === 'failed' ? 'FAIL ' : 'OK ') + r.client.padEnd(15) +
|
|
145
|
+
(dryRun ? ' 会从 ' : ' 已从 ') + basename(r.from) + (r.safety ? ' (当前状态另存为 ' + basename(r.safety) + ')' : ''));
|
|
146
|
+
}
|
|
147
|
+
log('');
|
|
148
|
+
log(dryRun ? '预演,没有写任何文件。' : '还原完成。重启对应的客户端即可生效。');
|
|
149
|
+
}
|
|
150
|
+
process.exit(failed ? 1 : 0);
|
package/bin/setup.mjs
CHANGED
|
@@ -1,31 +1,45 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* dsh-any-agent setup --
|
|
3
|
+
* dsh-any-agent setup -- 把本服务注册进你已有的 MCP 客户端。
|
|
4
4
|
*
|
|
5
|
-
* dsh-any-agent
|
|
6
|
-
* dsh-any-agent
|
|
7
|
-
* dsh-any-agent
|
|
8
|
-
* dsh-any-agent
|
|
9
|
-
* dsh-any-agent
|
|
5
|
+
* dsh-any-agent-setup 检测并预览;一个字节都不写
|
|
6
|
+
* dsh-any-agent-setup --yes 真正写入(先备份)
|
|
7
|
+
* dsh-any-agent-setup --yes --only cursor,zed
|
|
8
|
+
* dsh-any-agent-setup --yes --permission read-only
|
|
9
|
+
* dsh-any-agent-setup --json 机器可读的计划
|
|
10
|
+
* dsh-any-agent-setup --remove --yes 只把自己那一条摘掉
|
|
10
11
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
12
|
+
* 这个脚本会改别人的配置文件,所以下面每一条约束都是硬要求:
|
|
13
|
+
*
|
|
14
|
+
* 1) 默认只预览。动别人的配置,先看清要动什么。
|
|
15
|
+
* 2) 每次写入前留 <file>.bak-dsh-<stamp>;内容等于写入前的原始字节。
|
|
16
|
+
* 3) JSON 客户端做文本级定点编辑:只替换/插入/删除我们那一个条目所占的
|
|
17
|
+
* 字符区间。缩进、键顺序、行尾、BOM、有无末尾换行全部原样保留。
|
|
18
|
+
* 曾经的做法是 JSON.parse 再 JSON.stringify(...,2) 整份重写,
|
|
19
|
+
* 值虽然对,但会把用户手写的格式全部重排掉。
|
|
20
|
+
* 4) 删掉最后一条时若容器变空,连容器一起收掉,好让「装一次再卸掉」
|
|
21
|
+
* 能把文件还原成安装前的样子。
|
|
22
|
+
* 5) 只读文件、坏 JSON、非对象容器:拒绝并保持原文件不动,绝不写半截。
|
|
23
|
+
* 6) 计划模式不启动任何会立刻写盘的东西;Claude Code 的预览只做只读探测。
|
|
24
|
+
* 7) --json 时 stdout 只有一个 JSON 对象,人类日志全部走 stderr。
|
|
18
25
|
*/
|
|
19
26
|
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
22
|
-
import { join, dirname } from 'node:path';
|
|
27
|
+
import { readFileSync } from 'node:fs';
|
|
28
|
+
import { dirname, join } from 'node:path';
|
|
23
29
|
import { fileURLToPath } from 'node:url';
|
|
24
|
-
import { spawnSync } from 'node:child_process';
|
|
25
|
-
|
|
26
|
-
const NAME = 'dsh-any-agent';
|
|
27
30
|
|
|
28
|
-
|
|
31
|
+
import {
|
|
32
|
+
ENTRY_NAME,
|
|
33
|
+
applyClient,
|
|
34
|
+
clientList,
|
|
35
|
+
defaultLaunch,
|
|
36
|
+
desiredEnv,
|
|
37
|
+
isPresent,
|
|
38
|
+
planClient,
|
|
39
|
+
resolveDirs,
|
|
40
|
+
} from '../src/clients.mjs';
|
|
41
|
+
|
|
42
|
+
/** 版本号以 package.json 为准,避免 npm version 之后这里变旧。 */
|
|
29
43
|
const VERSION = (() => {
|
|
30
44
|
try {
|
|
31
45
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
@@ -34,259 +48,214 @@ const VERSION = (() => {
|
|
|
34
48
|
})();
|
|
35
49
|
|
|
36
50
|
const argv = process.argv.slice(2);
|
|
37
|
-
|
|
51
|
+
const has = (f) => argv.includes(f);
|
|
52
|
+
const val = (f, d) => {
|
|
53
|
+
const i = argv.indexOf(f);
|
|
54
|
+
return i >= 0 && argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[i + 1] : d;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
if (has('--version') || has('-v')) {
|
|
38
58
|
process.stdout.write(VERSION + '\n');
|
|
39
59
|
process.exit(0);
|
|
40
60
|
}
|
|
41
|
-
|
|
61
|
+
|
|
62
|
+
const JSON_OUT = has('--json');
|
|
63
|
+
|
|
64
|
+
if (has('--help') || has('-h')) {
|
|
65
|
+
const ids = clientList(resolveDirs()).map((c) => c.id).join(', ');
|
|
42
66
|
process.stdout.write(
|
|
43
67
|
'dsh-any-agent-setup ' + VERSION + '\n\n' +
|
|
44
|
-
'Detect installed MCP clients and register ' +
|
|
68
|
+
'Detect installed MCP clients and register ' + ENTRY_NAME + ' with each.\n\n' +
|
|
45
69
|
'Usage:\n' +
|
|
46
70
|
' dsh-any-agent-setup preview (writes nothing)\n' +
|
|
47
71
|
' dsh-any-agent-setup --yes apply\n' +
|
|
48
72
|
' dsh-any-agent-setup --yes --only a,b only these clients\n' +
|
|
49
73
|
' dsh-any-agent-setup --yes --permission read-only\n' +
|
|
50
|
-
' dsh-any-agent-setup --remove --yes unregister\n
|
|
51
|
-
'
|
|
52
|
-
'
|
|
53
|
-
'
|
|
74
|
+
' dsh-any-agent-setup --remove --yes unregister\n' +
|
|
75
|
+
' dsh-any-agent-setup --json machine-readable result on stdout\n' +
|
|
76
|
+
' dsh-any-agent-setup --home <dir> use another home (sandbox/testing)\n' +
|
|
77
|
+
' dsh-any-agent-setup --force create configs even if the client\n' +
|
|
78
|
+
' is not detected; replace an entry\n' +
|
|
79
|
+
' whose shape we do not recognise\n\n' +
|
|
80
|
+
'Clients: ' + ids + '\n\n' +
|
|
81
|
+
'Scope of the change -- this is the whole promise:\n' +
|
|
82
|
+
' * exactly one entry per client, named "' + ENTRY_NAME + '";\n' +
|
|
83
|
+
' * --remove takes out exactly that entry, nothing else;\n' +
|
|
84
|
+
' * your other MCP servers, top-level settings, comments and indentation\n' +
|
|
85
|
+
' are never rewritten -- JSON configs are edited in place as text, so\n' +
|
|
86
|
+
' 2-space, 4-space, tab, single-line, CRLF and BOM all survive;\n' +
|
|
87
|
+
' * Claude Code is edited the same way as the rest: ~/.claude.json is\n' +
|
|
88
|
+
' scanned by character range and only our own entry is touched -- that\n' +
|
|
89
|
+
' file holds hundreds of project entries and duplicate keys differing\n' +
|
|
90
|
+
' only in case, so it is never re-serialised from a parsed object;\n' +
|
|
91
|
+
' * every write is preceded by <file>.bak-dsh-<timestamp>.\n\n' +
|
|
92
|
+
'The path recorded in each client is absolute. If you move or delete this\n' +
|
|
93
|
+
'installation those clients report CONNECTION_CLOSED until setup is re-run\n' +
|
|
94
|
+
'from the new location (or --remove to clean up).\n',
|
|
54
95
|
);
|
|
55
96
|
process.exit(0);
|
|
56
97
|
}
|
|
98
|
+
// ── 参数 ───────────────────────────────────────────────────────────
|
|
57
99
|
|
|
58
|
-
const
|
|
59
|
-
const val = (f, d) => {
|
|
60
|
-
const i = argv.indexOf(f);
|
|
61
|
-
return i >= 0 && argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[i + 1] : d;
|
|
62
|
-
};
|
|
63
|
-
|
|
64
|
-
const WRITE = has('--yes') || has('-y');
|
|
100
|
+
const WRITE = (has('--yes') || has('-y')) && !has('--dry-run') && !has('--preview');
|
|
65
101
|
const REMOVE = has('--remove');
|
|
66
|
-
const ONLY = (val('--only', '') || '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
102
|
+
const ONLY = (val('--only', '') || val('--clients', '') || '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
67
103
|
const PERMISSION = val('--permission', 'workspace-write');
|
|
104
|
+
const HOME_OVERRIDE = val('--home');
|
|
105
|
+
const FORCE = has('--force');
|
|
106
|
+
const ALL = has('--all');
|
|
68
107
|
const STAMP = new Date().toISOString().replace(/[:.]/g, '-');
|
|
69
108
|
|
|
70
|
-
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
109
|
+
/** 人类可读的日志:--json 时一律走 stderr,别污染 stdout 里的那个对象。 */
|
|
110
|
+
const log = (s = '') => {
|
|
111
|
+
if (JSON_OUT) process.stderr.write(s + '\n');
|
|
112
|
+
else process.stdout.write(s + '\n');
|
|
113
|
+
};
|
|
75
114
|
const tick = (s) => log(' OK ' + s);
|
|
76
115
|
const dash = (s) => log(' --- ' + s);
|
|
77
116
|
const bang = (s) => log(' FAIL ' + s);
|
|
78
117
|
|
|
79
|
-
|
|
80
|
-
const probe = spawnSync(IS_WIN ? 'where' : 'which', [cmd], { encoding: 'utf8', shell: IS_WIN });
|
|
81
|
-
return probe.status === 0;
|
|
82
|
-
}
|
|
118
|
+
// ── 主流程 ─────────────────────────────────────────────────────────
|
|
83
119
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
return env;
|
|
88
|
-
}
|
|
120
|
+
const dirs = resolveDirs(HOME_OVERRIDE);
|
|
121
|
+
const clients = clientList(dirs);
|
|
122
|
+
const byId = new Map(clients.map((c) => [c.id, c]));
|
|
89
123
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
124
|
+
const unknown = ONLY.filter((id) => !byId.has(id));
|
|
125
|
+
if (unknown.length) {
|
|
126
|
+
const msg = 'unknown client id(s): ' + unknown.join(', ') + ' (known: ' + clients.map((c) => c.id).join(', ') + ')';
|
|
127
|
+
if (JSON_OUT) {
|
|
128
|
+
process.stdout.write(JSON.stringify({
|
|
129
|
+
ok: false, mode: 'error', error: msg, clients: [], changed: 0, skipped: 0, failed: unknown.length,
|
|
130
|
+
}) + '\n');
|
|
131
|
+
} else {
|
|
132
|
+
bang(msg);
|
|
133
|
+
}
|
|
134
|
+
process.exit(2);
|
|
96
135
|
}
|
|
97
136
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
return dst;
|
|
102
|
-
}
|
|
137
|
+
const launch = defaultLaunch();
|
|
138
|
+
const env = desiredEnv(PERMISSION);
|
|
139
|
+
const ctx = { dirs, remove: REMOVE, write: WRITE, launch, env, byId };
|
|
103
140
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
141
|
+
if (!JSON_OUT) {
|
|
142
|
+
log('');
|
|
143
|
+
log(WRITE
|
|
144
|
+
? (REMOVE ? 'Removing ' + ENTRY_NAME + ' from MCP clients' : 'Installing ' + ENTRY_NAME + ' into MCP clients')
|
|
145
|
+
: 'Preview only -- nothing is written. Re-run with --yes to apply.');
|
|
146
|
+
log(' home : ' + dirs.home);
|
|
147
|
+
log(' permission : ' + PERMISSION);
|
|
148
|
+
log(' launch : ' + launch.command + (launch.args.length ? ' ' + launch.args.join(' ') : '') + ' (' + launch.style + ')');
|
|
149
|
+
log(' DSH_BIN : ' + (process.env.DSH_BIN || '(unset -- auto-detect at runtime)'));
|
|
150
|
+
log('');
|
|
109
151
|
}
|
|
110
152
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
153
|
+
const results = [];
|
|
154
|
+
let changed = 0;
|
|
155
|
+
let skipped = 0;
|
|
156
|
+
let failed = 0;
|
|
157
|
+
|
|
158
|
+
for (const client of clients) {
|
|
159
|
+
// --json 时仍把每个客户端都报出来,只是标成 filtered:
|
|
160
|
+
// 调用方拿到的永远是一份完整的清单,而不是「这次碰巧选中的那几个」。
|
|
161
|
+
if (ONLY.length && !ONLY.includes(client.id)) {
|
|
162
|
+
if (JSON_OUT) {
|
|
163
|
+
results.push({
|
|
164
|
+
id: client.id, label: client.label, path: client.pathOf(dirs),
|
|
165
|
+
status: 'filtered', summary: 'not selected by --only', changed: false,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
continue;
|
|
117
169
|
}
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
const CLIENTS = [
|
|
121
|
-
{
|
|
122
|
-
id: 'claude',
|
|
123
|
-
label: 'Claude Code',
|
|
124
|
-
detect: () => which('claude') && existsSync(join(HOME, '.claude.json')),
|
|
125
|
-
apply() {
|
|
126
|
-
const run = (a) => spawnSync('claude', a, { encoding: 'utf8', shell: IS_WIN });
|
|
127
|
-
const tried = (r) => (((r.stdout || '') + (r.stderr || '')).toLowerCase());
|
|
128
170
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
// `add` refuses to overwrite, so clear any existing entry first. A
|
|
155
|
-
// missing entry is the normal path, not an error.
|
|
156
|
-
const existing = run(['mcp', 'remove', NAME, '-s', 'user']);
|
|
157
|
-
const hadIt = existing.status === 0;
|
|
158
|
-
|
|
159
|
-
const { command, args } = launch();
|
|
160
|
-
const envs = [];
|
|
161
|
-
for (const [k, v] of Object.entries(desiredEnv())) envs.push('-e', k + '=' + v);
|
|
162
|
-
// On Windows the child is spawned through cmd (shell: true), so an
|
|
163
|
-
// argument containing spaces must be quoted or it is split: a command of
|
|
164
|
-
// "C:\Program Files\nodejs\node.exe" arrives as "C:\Program" plus a
|
|
165
|
-
// stray "Files\nodejs\node.exe" argument, and Claude Code then fails
|
|
166
|
-
// to connect with CONNECTION_CLOSED. Quote anything with a space.
|
|
167
|
-
const quote = (s) => (IS_WIN && /\s/.test(s) ? '"' + s + '"' : s);
|
|
168
|
-
const r = run(['mcp', 'add', NAME, '-s', 'user', ...envs, '--', quote(command), ...args.map(quote)]);
|
|
169
|
-
if (r.status !== 0) {
|
|
170
|
-
return { error: tried(r).trim().split('\n')[0] || 'claude exited ' + r.status };
|
|
171
|
-
}
|
|
172
|
-
return { done: 'registered via \`claude mcp add\` (user scope)' + (hadIt ? ', replacing the old entry' : '') };
|
|
173
|
-
},
|
|
174
|
-
},
|
|
175
|
-
{
|
|
176
|
-
id: 'codex',
|
|
177
|
-
label: 'Codex',
|
|
178
|
-
detect: () => existsSync(join(HOME, '.codex', 'config.toml')),
|
|
179
|
-
apply() {
|
|
180
|
-
const cfg = join(HOME, '.codex', 'config.toml');
|
|
181
|
-
const raw = readFileSync(cfg, 'utf8');
|
|
182
|
-
const kept = [];
|
|
183
|
-
let skip = false;
|
|
184
|
-
const re = new RegExp('^mcp_servers\\.(?:' + NAME + '|"' + NAME + '")(?:\\.|$)');
|
|
185
|
-
for (const line of raw.split(/\r?\n/)) {
|
|
186
|
-
const m = line.match(/^\s*\[([^\]]+)\]\s*(?:#.*)?$/);
|
|
187
|
-
if (m) skip = re.test(m[1]);
|
|
188
|
-
if (!skip) kept.push(line);
|
|
189
|
-
}
|
|
190
|
-
let out = kept.join('\n').trimEnd();
|
|
191
|
-
if (!REMOVE) {
|
|
192
|
-
const { command, args } = launch();
|
|
193
|
-
const envLines = Object.entries(desiredEnv())
|
|
194
|
-
.map(([k, v]) => k + ' = ' + JSON.stringify(v)).join('\n');
|
|
195
|
-
out += (out ? '\n\n' : '') +
|
|
196
|
-
'[mcp_servers.' + NAME + ']\ncommand = ' + JSON.stringify(command) +
|
|
197
|
-
'\nargs = ' + JSON.stringify(args) +
|
|
198
|
-
'\nstartup_timeout_sec = 20.0\ntool_timeout_sec = 120.0\n\n' +
|
|
199
|
-
'[mcp_servers.' + NAME + '.env]\n' + envLines;
|
|
200
|
-
}
|
|
201
|
-
out += '\n';
|
|
202
|
-
if (out === raw) return { skipped: 'already up to date' };
|
|
203
|
-
if (!WRITE) return { preview: REMOVE ? 'TOML block would be removed' : 'TOML block would be written' };
|
|
204
|
-
backup(cfg);
|
|
205
|
-
atomicWrite(cfg, out);
|
|
206
|
-
return { done: REMOVE ? 'TOML block removed' : 'TOML block written' };
|
|
207
|
-
},
|
|
208
|
-
},
|
|
209
|
-
{ id: 'cursor', label: 'Cursor', json: () => join(HOME, '.cursor', 'mcp.json'), key: 'mcpServers' },
|
|
210
|
-
{ id: 'windsurf', label: 'Windsurf', json: () => join(HOME, '.codeium', 'windsurf', 'mcp_config.json'), key: 'mcpServers' },
|
|
211
|
-
{ id: 'zed', label: 'Zed', json: () => join(IS_WIN ? APPDATA : join(HOME, '.config'), IS_WIN ? join('Zed','settings.json') : join('zed','settings.json')), key: 'context_servers' },
|
|
212
|
-
];
|
|
171
|
+
let present = false;
|
|
172
|
+
try { present = isPresent(client, dirs); } catch { present = false; }
|
|
173
|
+
if (!JSON_OUT) log(client.label + (present ? '' : ' (not detected)'));
|
|
174
|
+
|
|
175
|
+
if (!present && !(FORCE || ALL)) {
|
|
176
|
+
if (!JSON_OUT) { dash('skipped: client not found'); log(''); }
|
|
177
|
+
results.push({
|
|
178
|
+
id: client.id, label: client.label, path: client.pathOf(dirs),
|
|
179
|
+
status: 'skipped', summary: 'client not detected', changed: false,
|
|
180
|
+
});
|
|
181
|
+
skipped++;
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
// 对没装过的客户端做 --remove 毫无意义:既没有文件,也没写过东西。
|
|
185
|
+
if (!present && REMOVE) {
|
|
186
|
+
if (!JSON_OUT) { dash('skipped: not present'); log(''); }
|
|
187
|
+
results.push({
|
|
188
|
+
id: client.id, label: client.label, path: client.pathOf(dirs),
|
|
189
|
+
status: 'absent', summary: 'not present', changed: false,
|
|
190
|
+
});
|
|
191
|
+
skipped++;
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
213
194
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
195
|
+
let plan;
|
|
196
|
+
try {
|
|
197
|
+
plan = planClient(client, ctx);
|
|
198
|
+
} catch (e) {
|
|
199
|
+
plan = { id: client.id, label: client.label, path: client.pathOf(dirs), status: 'failed', changed: false, summary: e.message };
|
|
200
|
+
}
|
|
219
201
|
|
|
220
|
-
|
|
221
|
-
|
|
202
|
+
let finalPlan = plan;
|
|
203
|
+
try {
|
|
204
|
+
finalPlan = applyClient(plan, ctx, STAMP);
|
|
205
|
+
} catch (e) {
|
|
206
|
+
finalPlan = { ...plan, status: 'failed', changed: false, summary: 'unexpected error: ' + e.message };
|
|
207
|
+
}
|
|
222
208
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
209
|
+
const row = {
|
|
210
|
+
id: finalPlan.id,
|
|
211
|
+
label: finalPlan.label,
|
|
212
|
+
path: finalPlan.path,
|
|
213
|
+
status: finalPlan.status,
|
|
214
|
+
summary: finalPlan.summary,
|
|
215
|
+
changed: Boolean(finalPlan.changed),
|
|
216
|
+
};
|
|
217
|
+
// 备份路径必须报出来:用户要回滚时得找得到那一份。
|
|
218
|
+
if (finalPlan.backup) row.backup = finalPlan.backup;
|
|
219
|
+
results.push(row);
|
|
220
|
+
|
|
221
|
+
if (finalPlan.status === 'failed') {
|
|
222
|
+
if (!JSON_OUT) { bang(finalPlan.summary); log(''); }
|
|
223
|
+
failed++;
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
if (finalPlan.changed) {
|
|
227
|
+
if (!JSON_OUT) {
|
|
228
|
+
tick(finalPlan.summary);
|
|
229
|
+
if (finalPlan.backup) log(' backup: ' + finalPlan.backup);
|
|
230
|
+
log('');
|
|
231
|
+
}
|
|
232
|
+
changed++;
|
|
226
233
|
} else {
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
if (JSON.stringify(bag[NAME]) === JSON.stringify(next)) return { skipped: 'already up to date' };
|
|
230
|
-
bag[NAME] = next;
|
|
234
|
+
if (!JSON_OUT) { dash('skipped: ' + finalPlan.summary); log(''); }
|
|
235
|
+
skipped++;
|
|
231
236
|
}
|
|
232
|
-
root[c.key] = bag;
|
|
233
|
-
|
|
234
|
-
const text = JSON.stringify(root, null, 2) + '\n';
|
|
235
|
-
if (text === parsed.raw.trimEnd() + '\n') return { skipped: 'no change' };
|
|
236
|
-
JSON.parse(text); // self-check before touching disk
|
|
237
|
-
|
|
238
|
-
// Dry-run must stop here. This file used to write unconditionally and only
|
|
239
|
-
// reported "would write" in the orchestration layer, which meant `setup`
|
|
240
|
-
// without --yes silently modified every client config it found -- the exact
|
|
241
|
-
// opposite of what the docs promise.
|
|
242
|
-
if (!WRITE) return { preview: REMOVE ? 'entry would be removed' : 'entry would be written' };
|
|
243
|
-
|
|
244
|
-
backup(file);
|
|
245
|
-
atomicWrite(file, text);
|
|
246
|
-
const back = parseJson(file); // self-check after writing
|
|
247
|
-
if (back.error) return { error: 'wrote a file that no longer parses (' + back.error + '); restore the .bak' };
|
|
248
|
-
return { done: REMOVE ? 'entry removed' : 'entry written' };
|
|
249
237
|
}
|
|
250
238
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
log(' DSH_BIN : ' + (process.env.DSH_BIN || '(unset -- auto-detect at runtime)'));
|
|
261
|
-
log('');
|
|
262
|
-
|
|
263
|
-
let changed = 0, skipped = 0, failed = 0;
|
|
264
|
-
|
|
265
|
-
for (const c of CLIENTS) {
|
|
266
|
-
if (ONLY.length && !ONLY.includes(c.id)) continue;
|
|
267
|
-
|
|
268
|
-
let present = false;
|
|
269
|
-
try { present = c.detect ? c.detect() : existsSync(c.json()); } catch {}
|
|
270
|
-
log(c.label + (present ? '' : ' (not installed)'));
|
|
271
|
-
|
|
272
|
-
if (!present) { dash('skipped: client not found'); skipped++; log(''); continue; }
|
|
273
|
-
|
|
274
|
-
let r;
|
|
275
|
-
try { r = c.apply ? c.apply() : applyJson(c); }
|
|
276
|
-
catch (e) { r = { error: e.message }; }
|
|
239
|
+
const ok = failed === 0;
|
|
240
|
+
const summary = {
|
|
241
|
+
ok,
|
|
242
|
+
mode: REMOVE ? 'remove' : (WRITE ? 'apply' : 'plan'),
|
|
243
|
+
clients: results,
|
|
244
|
+
changed,
|
|
245
|
+
skipped,
|
|
246
|
+
failed,
|
|
247
|
+
};
|
|
277
248
|
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
log('');
|
|
249
|
+
if (JSON_OUT) {
|
|
250
|
+
process.stdout.write(JSON.stringify(summary, null, 2) + '\n');
|
|
251
|
+
} else {
|
|
252
|
+
log('Summary: ' + changed + ' ' + (WRITE ? 'changed' : 'would change') + ', ' + skipped + ' skipped, ' + failed + ' failed');
|
|
253
|
+
if (!WRITE && changed) log('Nothing was written. Re-run with --yes to apply.');
|
|
254
|
+
if (failed) {
|
|
255
|
+
log('');
|
|
256
|
+
log('A failure above left no damage: every writer validates the result and keeps');
|
|
257
|
+
log(' a .bak-dsh-* copy beside the file it touched.');
|
|
258
|
+
}
|
|
283
259
|
}
|
|
284
260
|
|
|
285
|
-
|
|
286
|
-
if (!WRITE && changed) log('Nothing was written. Re-run with --yes to apply.');
|
|
287
|
-
if (failed) {
|
|
288
|
-
log('');
|
|
289
|
-
log('A failure above left no damage: every writer validates the result and keeps');
|
|
290
|
-
log('a .bak-dsh-* copy beside the file it touched.');
|
|
291
|
-
}
|
|
292
|
-
process.exit(failed ? 1 : 0);
|
|
261
|
+
process.exit(ok ? 0 : 1);
|