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.
@@ -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 -- wire this server into the MCP clients you already have.
3
+ * dsh-any-agent setup -- 把本服务注册进你已有的 MCP 客户端。
4
4
  *
5
- * dsh-any-agent setup detect and preview; writes nothing
6
- * dsh-any-agent setup --yes actually write (backs up first)
7
- * dsh-any-agent setup --yes --only cursor,zed
8
- * dsh-any-agent setup --yes --permission read-only
9
- * dsh-any-agent setup --remove --yes take it back out
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
- * Constraints, each one learned the hard way:
12
- * 1. Dry-run by default. Touching someone else's config deserves a preview.
13
- * 2. Every file is backed up to <file>.bak-dsh-<stamp> before it is written.
14
- * 3. JSON clients: parse the original, build the new object, parse the result
15
- * again, and only then write. A half-written config is worse than none.
16
- * 4. A client that is not installed is skipped, not failed.
17
- * 5. Claude Code and Codex have their own `mcp` subcommands; prefer them.
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 { existsSync, readFileSync, writeFileSync, copyFileSync, renameSync, unlinkSync } from 'node:fs';
21
- import { homedir, platform } from 'node:os';
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
- /** Same source of truth as the server: package.json, not a hard-coded copy. */
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
- if (argv.includes('--version') || argv.includes('-v')) {
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
- if (argv.includes('--help') || argv.includes('-h')) {
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 ' + NAME + ' with each.\n\n' +
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\n' +
51
- 'Clients: Claude Code, Codex, Cursor, Windsurf, Zed.\n' +
52
- 'Every write is preceded by a .bak-dsh-<timestamp> backup; JSON clients are\n' +
53
- 'validated before and after writing; re-running is idempotent.\n',
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 has = (f) => argv.includes(f);
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
- const HOME = homedir();
71
- const IS_WIN = platform() === 'win32';
72
- const APPDATA = process.env.APPDATA || join(HOME, 'AppData', 'Roaming');
73
-
74
- const log = (s = '') => console.log(s);
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
- function which(cmd) {
80
- const probe = spawnSync(IS_WIN ? 'where' : 'which', [cmd], { encoding: 'utf8', shell: IS_WIN });
81
- return probe.status === 0;
82
- }
118
+ // ── 主流程 ─────────────────────────────────────────────────────────
83
119
 
84
- function desiredEnv() {
85
- const env = { DSH_MCP_PERMISSION: PERMISSION, DSH_MCP_LOG: 'silent' };
86
- if (process.env.DSH_BIN) env.DSH_BIN = process.env.DSH_BIN;
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
- /** Prefer this package's own entry point; fall back to the PATH command. */
91
- function launch() {
92
- const here = dirname(fileURLToPath(import.meta.url));
93
- const sibling = join(here, 'dsh-any-agent.mjs');
94
- if (existsSync(sibling)) return { command: process.execPath, args: [sibling], style: 'node + sibling entry' };
95
- return { command: NAME, args: [], style: 'PATH command' };
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
- function backup(file) {
99
- const dst = file + '.bak-dsh-' + STAMP;
100
- copyFileSync(file, dst);
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
- /** Temp-then-rename so a crash cannot leave a truncated config. */
105
- function atomicWrite(file, text) {
106
- const tmp = file + '.tmp-dsh-' + process.pid;
107
- writeFileSync(tmp, text, 'utf8');
108
- renameSync(tmp, file);
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
- function parseJson(file) {
112
- const raw = readFileSync(file, 'utf8');
113
- try {
114
- return { json: JSON.parse(raw), raw };
115
- } catch (e) {
116
- return { error: e.message };
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
- // Dry-run must not shell out: `claude mcp add` writes immediately, so
130
- // previewing by running it would be a write, not a preview.
131
- if (!WRITE) {
132
- const probe = run(['mcp', 'get', NAME]);
133
- const registered = probe.status === 0;
134
- if (REMOVE) {
135
- return registered
136
- ? { preview: 'would be unregistered via \`claude mcp remove\`' }
137
- : { skipped: 'not registered' };
138
- }
139
- return { preview: registered
140
- ? 'would be re-registered via \`claude mcp add\` (replacing the existing entry)'
141
- : 'would be registered via \`claude mcp add\` (user scope)' };
142
- }
143
-
144
- if (REMOVE) {
145
- const r = run(['mcp', 'remove', NAME, '-s', 'user']);
146
- if (r.status !== 0) {
147
- return tried(r).includes('no mcp server named')
148
- ? { skipped: 'not registered' }
149
- : { error: tried(r).trim().split('\n')[0] || 'claude exited ' + r.status };
150
- }
151
- return { done: 'removed via \`claude mcp remove\` (user scope)' };
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
- /** Shared reader/writer for the JSON-shaped clients. */
215
- function applyJson(c) {
216
- const file = c.json();
217
- const parsed = parseJson(file);
218
- if (parsed.error) return { error: 'existing file is not valid JSON (' + parsed.error + ')' };
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
- const root = (parsed.json && typeof parsed.json === 'object') ? parsed.json : {};
221
- const bag = (root[c.key] && typeof root[c.key] === 'object') ? root[c.key] : {};
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
- if (REMOVE) {
224
- if (!Object.prototype.hasOwnProperty.call(bag, NAME)) return { skipped: 'not present' };
225
- delete bag[NAME];
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
- const { command, args } = launch();
228
- const next = { command, args, env: desiredEnv() };
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
- // --- main -----------------------------------------------------------
252
-
253
- const lc = launch();
254
- log('');
255
- log(WRITE
256
- ? (REMOVE ? 'Removing ' + NAME + ' from MCP clients' : 'Installing ' + NAME + ' into MCP clients')
257
- : 'Preview only -- nothing is written. Re-run with --yes to apply.');
258
- log(' permission : ' + PERMISSION);
259
- log(' launch : ' + lc.command + (lc.args.length ? ' ' + lc.args.join(' ') : '') + ' (' + lc.style + ')');
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
- if (r.error) { bang(r.error); failed++; }
279
- else if (r.skipped) { dash('skipped: ' + r.skipped); skipped++; }
280
- else if (r.preview) { tick(r.preview); changed++; }
281
- else { tick(r.done); changed++; }
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
- log('Summary: ' + changed + ' ' + (WRITE ? 'changed' : 'would change') + ', ' + skipped + ' skipped, ' + failed + ' failed');
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);