agent-syncer 0.1.1 → 0.1.2

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.
@@ -5,6 +5,7 @@ import { CONFIG_FILENAME, loadConfig } from '../config.js';
5
5
  import { applyInstall, findOrphans, planInstall } from '../install.js';
6
6
  import {
7
7
  RECORD_REL,
8
+ backupUnreadableRecord,
8
9
  carryOver,
9
10
  isTracked,
10
11
  planRemovals,
@@ -20,9 +21,9 @@ import {
20
21
  loadRepo,
21
22
  resolveSelection,
22
23
  } from '../manifest.js';
23
- import { dim, fail, info, ok, plain, title, warn } from '../log.js';
24
+ import { dim, fail, info, ok, plain, rel, title, warn } from '../log.js';
24
25
  import { isInteractive } from '../prompt.js';
25
- import { applyMergeAll } from '../merge.js';
26
+ import { applyMergeAll, keepByProtect } from '../merge.js';
26
27
  import { rmdirIfEmpty, rmdirTreeIfEmpty } from '../prune.js';
27
28
  import { fetchRepo, isGitUrl, localRefWarning } from '../source.js';
28
29
  import { CONTENT_ROOT, MERGE_KINDS, TOOLS } from '../target.js';
@@ -46,8 +47,27 @@ function printBundles(repoRoot) {
46
47
  plain(dim(' 只想微调?"include" / "exclude" 可以叠在模板之上,exclude 最后生效。'));
47
48
  }
48
49
 
49
- /** 统一的相对路径显示(正斜杠,跨平台一致) */
50
- const rel = (from, to) => path.relative(from, to).split(path.sep).join('/');
50
+ /**
51
+ * 要透传给 `link` 的选项。**白名单,不是把整个 flags 递过去。**
52
+ *
53
+ * 整个递过去踩过一次(实测):命令层的 flags 没有命名空间,而 `--src`/`--dst`
54
+ * 会把 link 拨到「直连模式」那条路上——于是 sync 装完了内容、写完了记录,却
55
+ * **一条托管链接都没建、.gitignore 也没写**,退出码还是 0。正好是这个工具最
56
+ * 不该出现的状态:链接不在忽略段里,git 会顺着它们把 `.agents/` 的内容再提交
57
+ * 一份,而全程没有一句提示。
58
+ *
59
+ * 这里只留 link 自己认识的开关。将来 link 加了新选项就**显式加进来**——
60
+ * 那点摩擦正是白名单该有的。
61
+ *
62
+ * @param {Record<string, any>} flags
63
+ */
64
+ function linkFlags(flags) {
65
+ const PASS = ['dry-run', 'force', 'prune', 'yes', 'no-save'];
66
+ /** @type {Record<string, any>} */
67
+ const out = {};
68
+ for (const k of PASS) if (flags[k] !== undefined) out[k] = flags[k];
69
+ return out;
70
+ }
51
71
 
52
72
  /**
53
73
  * 把 hooks / mcp 合并进各工具自己的配置文件。
@@ -66,17 +86,11 @@ const rel = (from, to) => path.relative(from, to).split(path.sep).join('/');
66
86
  function runMerge({ cwd, plan, config, record, dryRun, prune }) {
67
87
  /** @type {Record<string, Record<string, string>>} */
68
88
  const sourcesByKind = {};
69
- /** @type {Record<string, string[]>} */
70
- const keepByKind = {};
71
-
72
- /** @param {string} dir @param {string} id */
73
- const addKeep = (dir, id) => {
74
- const list = (keepByKind[dir] ??= []);
75
- if (!list.includes(id)) list.push(id);
76
- };
77
89
 
78
90
  // 注意 `plan` 里**不会有被 protect 锁住的条目**(sync 早就把它们滤掉了),
79
- // 所以这里不必再判一次 protect——锁住的那些由下面那个循环单独收。
91
+ // 所以这里不必再判一次 protect。锁住的那些由 `keepByProtect` 单独收:它们不在
92
+ // plan 里,却很可能已经合并进过工具配置——不显式算进来的话,「锁住它」到了
93
+ // --prune 就失效,而用户写 protect 的正是「别动这个」。
80
94
  for (const e of plan) {
81
95
  const dir = ITEM_KINDS[e.kind]?.dir;
82
96
  // scripts 没有条目类型,也不合并——它整目录同步,由 hook 按路径引用
@@ -84,23 +98,12 @@ function runMerge({ cwd, plan, config, record, dryRun, prune }) {
84
98
  (sourcesByKind[dir] ??= {})[e.id] = e.from;
85
99
  }
86
100
 
87
- // protect 里锁住、但**本次没选中**的条目也要算数。它们不在 plan 里,却很可能
88
- // 已经合并进过工具配置——不显式加进来的话,「锁住它」到了 --prune 就失效,
89
- // 而用户写 protect 的正是「别动这个」。
90
- for (const key of config.protect) {
91
- const colon = key.indexOf(':');
92
- if (colon === -1) continue;
93
- const dir = ITEM_KINDS[key.slice(0, colon)]?.dir;
94
- if (!dir || !MERGE_KINDS.includes(dir)) continue;
95
- addKeep(dir, key.slice(colon + 1));
96
- }
97
-
98
101
  return {
99
102
  ...applyMergeAll({
100
103
  projectRoot: cwd,
101
104
  tools: config.tools,
102
105
  sourcesByKind,
103
- keepByKind,
106
+ keepByKind: keepByProtect(config.protect),
104
107
  prevMerged: record.merged ?? {},
105
108
  dryRun,
106
109
  prune,
@@ -329,15 +332,22 @@ export async function run({ cwd, flags, input, output }) {
329
332
  for (const e of plan) (by[e.status] ??= []).push(e);
330
333
 
331
334
  // ---- 5. 报告 ----
332
- for (const e of by.blocked) fail(`${e.spec} 目标是链接,拒绝覆盖`);
335
+ for (const e of by.blocked) {
336
+ fail(`${e.spec} 路径上有链接,拒绝覆盖:${path.relative(cwd, e.linkAt ?? e.to)}`);
337
+ }
333
338
  for (const e of by.new) ok(`${e.spec} ${dim('新增')}`);
334
339
  for (const e of by.update) info(`${e.spec} ${dim('更新(本地同名内容会被覆盖)')}`);
335
340
 
336
341
  if (by.blocked.length > 0) {
337
342
  title('需要你处理');
338
- plain(' 下面这些路径是链接,sync 不会写进链接指向的地方:');
339
- for (const e of by.blocked) plain(` · ${path.relative(cwd, e.to)}`);
340
- plain(' 先删掉它们或改用 link 管理,再重跑。');
343
+ plain(' 下面这些路径(它自己或它的上层目录)是链接,sync 不会写进链接指向的地方:');
344
+ for (const e of by.blocked) {
345
+ const at = e.linkAt ?? e.to;
346
+ plain(` · ${path.relative(cwd, at)}${at === e.to ? '' : dim('(是上层目录)')}`);
347
+ }
348
+ // 链接指向的地方很可能还有别的项目在管同一批内容(每个项目的记录各管各的),
349
+ // 这边写进去、那边 --prune 删掉,会互相踩
350
+ plain(' 那儿可能还有别的项目在管同一批内容。先删掉链接或改用 link 管理,再重跑。');
341
351
  return 1;
342
352
  }
343
353
 
@@ -484,6 +494,13 @@ export async function run({ cwd, flags, input, output }) {
484
494
  // 注意**不能放进上面的 `if (record.usable)`**——首次运行时记录还不存在,
485
495
  // 那正是它最需要被写下来的时刻。
486
496
  if (!dryRun) {
497
+ // 读不出来的那份(坏 JSON / BOM / 别的版本写的)先留个档再重建:
498
+ // `writeRecord` 是整份重写,不备份的话盘上那份就永久没了,而它记的归属
499
+ // 没有第二处能推导出来。
500
+ if (!record.usable && record.reason) {
501
+ const bak = backupUnreadableRecord(cwd);
502
+ if (bak) warn(`原来那份记录读不了,已备份成 ${bak},本次重建一份新的`);
503
+ }
487
504
  writeRecord(cwd, carryOver(snapshotRecord(cwd, items, scripts, merge.merged), carry));
488
505
  }
489
506
 
@@ -518,11 +535,16 @@ export async function run({ cwd, flags, input, output }) {
518
535
  // ---- 10. 建链接 ----
519
536
  if (dryRun) {
520
537
  plain(dim('\n这是预演,未写盘。去掉 --dry-run 即可实际执行。'));
521
- return 0;
538
+ // 预演也要如实反映故障:同一份现场,`sync` 退 1 而 `sync --dry-run` 退 0
539
+ // 就是「预演是绿的、真跑是红的」——CI 里只看退出码的人会被骗一道。
540
+ // 「同一现场同一结论」是 merge.js 里 checkMergeAll 那段定下的口径。
541
+ return merge.problems.length > 0 ? 1 : 0;
522
542
  }
523
543
 
524
544
  plain('');
525
- const linkCode = await runLink({ cwd, flags, input, output });
545
+ // **只透传 link 认识的选项**,不能整个 flags 递过去——`--src`/`--dst` 会把
546
+ // 它拨到直连模式,托管链接就一条都不建了(见 linkFlags 那段)
547
+ const linkCode = await runLink({ cwd, flags: linkFlags(flags), input, output });
526
548
  // 合并出了硬问题(内容文件坏了、目标写不进去)也要反映在退出码上,
527
549
  // 否则 CI 里只看退出码的人会以为一切都好
528
550
  return linkCode !== 0 ? linkCode : merge.problems.length > 0 ? 1 : 0;
package/lib/config.js CHANGED
@@ -2,23 +2,17 @@
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { ITEM_KINDS, ITEM_KIND_NAMES, SUPPORT_DIR } from './manifest.js';
5
- // 借 merge.js 的原子写。这个文件的依赖方向确实有点逆(config 比 merge 底层),
6
- // 但「tmp + rename」是个**安全原语**,再抄一份出来比这个方向更糟——
7
- // link.js 里还有一份内联的,已经够多了。
8
- import { writeJsonFile } from './merge.js';
5
+ // 借 merge.js 的两样东西:原子写和剥 BOM。
6
+ //
7
+ // 这个文件的依赖方向确实有点逆(config 比 merge 底层),但两个都是**安全原语**
8
+ // ——「tmp + rename」和「JSON 文本怎么读」,再抄一份出来比这个方向更糟:
9
+ // link.js 里还有一份内联的原子写,而 BOM 这件事原先在三处各有一个说法,
10
+ // record.js 那份干脆没有,于是记事本改一下记录就让归属全丢。
11
+ import { isMissingPath, stripBom, writeJsonFile } from './merge.js';
9
12
  import { TOOL_NAMES, isTool, sortTools, specsOfTools } from './target.js';
10
13
 
11
14
  export const CONFIG_FILENAME = 'agents.json';
12
15
 
13
- /**
14
- * 有 BOM 就剥掉。
15
- *
16
- * Windows 上记事本、PowerShell 的 `>` 重定向默认都写带 BOM 的 UTF-8,而
17
- * `JSON.parse` 见 BOM 直接抛——用户会看到「不是合法的 JSON」,然后去改一个
18
- * 其实完全正常的文件。`merge.js` 的 `readJsonFile` 早就这么处理了,这里跟上。
19
- */
20
- const stripBom = (text) => (text.charCodeAt(0) === 0xfeff ? text.slice(1) : text);
21
-
22
16
  /**
23
17
  * 读 `agents.json` 的原文。三种结果分得很开,**混起来就是上次那个 blocker**:
24
18
  *
@@ -39,7 +33,7 @@ function readConfigText(projectRoot) {
39
33
  return fs.readFileSync(p, 'utf8');
40
34
  } catch (e) {
41
35
  const err = /** @type {any} */ (e);
42
- if (err.code === 'ENOENT' || err.code === 'ENOTDIR') return null;
36
+ if (isMissingPath(err)) return null;
43
37
  throw new Error(`${CONFIG_FILENAME} 读不了(${err.code ?? err.message}):${p}`);
44
38
  }
45
39
  }
package/lib/gitignore.js CHANGED
@@ -88,13 +88,24 @@ export function checkBlock(projectRoot, tools) {
88
88
  return { present: false, missing: [], extra: [] };
89
89
  }
90
90
 
91
- const block = content.slice(start, end);
92
- const expected = buildBlock(projectRoot, tools)
93
- .split('\n')
94
- .filter((l) => l && !l.startsWith('#'));
95
- const missing = expected.filter((l) => !block.includes(l));
91
+ /** 逐行拆出来:注释和空行不算,两边都用这把尺子 */
92
+ const linesIn = (/** @type {string} */ text) =>
93
+ text
94
+ .split('\n')
95
+ .map((l) => l.trim())
96
+ .filter((l) => l !== '' && !l.startsWith('#'));
96
97
 
97
- return { present: true, missing, extra: [] };
98
+ const expected = linesIn(buildBlock(projectRoot, tools));
99
+ const found = linesIn(content.slice(start + BEGIN_MARK.length, end));
100
+
101
+ // 按行比,不用 `block.includes(l)` 那种子串判断:期望项 `.claude/rules` 会被
102
+ // 段里的一行 `.claude/rules-old` 命中,于是「缺了这条」永远报不出来。
103
+ const missing = expected.filter((l) => !found.includes(l));
104
+ // 段里多出来的行。`writeBlock` 是**整段重建**的,这几行会在下一次 link 时
105
+ // 无声消失——报出来,别让「写在托管段里也能生效」变成一个事后才发现不成立的假设。
106
+ const extra = found.filter((l) => !expected.includes(l));
107
+
108
+ return { present: true, missing, extra };
98
109
  }
99
110
 
100
111
  /**
package/lib/install.js CHANGED
@@ -2,20 +2,10 @@
2
2
  import crypto from 'node:crypto';
3
3
  import fs from 'node:fs';
4
4
  import path from 'node:path';
5
- import { ITEM_KINDS, SUPPORT_DIR, itemPath, parseItem } from './manifest.js';
5
+ import { isSymlink } from './link.js';
6
+ import { ITEM_KINDS, SUPPORT_DIR, itemPath, parseItem, projectItemPath } from './manifest.js';
6
7
  import { CONTENT_ROOT } from './target.js';
7
8
 
8
- /** 条目在项目里的落点:.agents/<仓库目录名>/<id>[ext] */
9
- export function projectItemPath(projectRoot, kind, id) {
10
- const cfg = ITEM_KINDS[kind];
11
- return path.resolve(
12
- projectRoot,
13
- CONTENT_ROOT,
14
- cfg.dir,
15
- cfg.ext === null ? id : `${id}${cfg.ext}`,
16
- );
17
- }
18
-
19
9
  /**
20
10
  * 目录树的稳定指纹:所有文件的「相对路径 + 内容哈希」,按路径排序。
21
11
  * 单文件则直接返回内容哈希。
@@ -70,13 +60,35 @@ function statusOf(from, to) {
70
60
  }
71
61
  }
72
62
 
73
- /** 判断路径是不是链接——是的话绝不能覆盖 */
74
- function isLink(p) {
75
- try {
76
- return fs.lstatSync(p).isSymbolicLink();
77
- } catch {
78
- return false;
63
+ /**
64
+ * 落点路径上、**项目范围内**有没有链接——不含最后一段(那一段由调用方判,
65
+ * 消息不一样)。
66
+ *
67
+ * 为什么必须往上找:守卫原先只看叶子,于是 `.agents/skills` 自己是链接时,
68
+ * `.agents/skills/alpha` 不是链接,守卫整条放过——sync 就**写穿到链接指向的
69
+ * 地方**去了(实测过:内容落进了外部目录,退出码还是 0),而本工具对用户的
70
+ * 承诺是「不写进链接指向的地方」。
71
+ *
72
+ * 链接指向别处意味着那份内容可能**还有别的项目在管**(每个项目的记录是各管各的),
73
+ * 这边 `--prune` 一删,那边就凭空少东西。所以和叶子一样:拒绝,并说清怎么走。
74
+ *
75
+ * 上溯**从项目根开始**,不爬到文件系统根:项目根自己的祖先是不是链接
76
+ * (macOS 上 `/tmp` 就是)不归本工具管,管了只会全是误报。
77
+ *
78
+ * @param {string} projectRoot @param {string} abs
79
+ * @returns {string|null} 是链接的那一段的绝对路径
80
+ */
81
+ function linkInPath(projectRoot, abs) {
82
+ const rel = path.relative(projectRoot, abs);
83
+ if (rel === '' || path.isAbsolute(rel) || rel.startsWith('..')) return null; // 落点不在项目内
84
+
85
+ let cur = projectRoot;
86
+ // 去掉最后一段(叶子),只看中间目录
87
+ for (const part of rel.split(path.sep).slice(0, -1)) {
88
+ cur = path.join(cur, part);
89
+ if (isSymlink(cur)) return cur;
79
90
  }
91
+ return null;
80
92
  }
81
93
 
82
94
  /**
@@ -138,7 +150,7 @@ function collectSupport(repoRoot, projectRoot) {
138
150
  * @param {string} repoRoot @param {string} projectRoot @param {string[]} items
139
151
  */
140
152
  export function planInstall(repoRoot, projectRoot, items) {
141
- /** @type {{spec: string, kind: string, id: string, from: string, to: string, status: string}[]} */
153
+ /** @type {{spec: string, kind: string, id: string, from: string, to: string, status: string, linkAt?: string}[]} */
142
154
  const entries = [];
143
155
 
144
156
  for (const spec of items) {
@@ -159,10 +171,19 @@ export function planInstall(repoRoot, projectRoot, items) {
159
171
  // 仍然躺在 `.agents/hooks/` 里,它引用的脚本还得留着。
160
172
  if (wantsSupport(items)) entries.push(...collectSupport(repoRoot, projectRoot));
161
173
 
162
- // 目标是链接的一律拦下——那多半是 link 命令建的反向链接,
163
- // 覆盖它等于把内容仓库的东西写进一个链接指向的地方
174
+ // 路径上有链接的一律拦下——那多半是 link 命令建的反向链接,
175
+ // 写进去等于把内容仓库的东西写进一个链接指向的地方。叶子和中间目录都查。
164
176
  for (const e of entries) {
165
- if (isLink(e.to)) e.status = 'blocked';
177
+ if (isSymlink(e.to)) {
178
+ e.status = 'blocked';
179
+ e.linkAt = e.to;
180
+ continue;
181
+ }
182
+ const up = linkInPath(projectRoot, e.to);
183
+ if (up) {
184
+ e.status = 'blocked';
185
+ e.linkAt = up;
186
+ }
166
187
  }
167
188
 
168
189
  return entries;
package/lib/log.js CHANGED
@@ -1,4 +1,5 @@
1
1
  // @ts-check
2
+ import path from 'node:path';
2
3
  import process from 'node:process';
3
4
 
4
5
  // 颜色仅在 TTY 下启用,遵循 NO_COLOR 约定
@@ -56,3 +57,13 @@ export const title = (msg) => {
56
57
  export const plain = (msg = '') => {
57
58
  if (!silent()) console.log(msg);
58
59
  };
60
+
61
+ /**
62
+ * 给人看的相对路径:一律正斜杠,跨平台长得一样。
63
+ *
64
+ * 这是**显示**规则,不是路径处理——真正拼路径的地方一律 `path.join`/`path.resolve`。
65
+ * 放在这里是因为它服务于输出:`status` / `sync` 原先各写了一份一模一样的实现。
66
+ *
67
+ * @param {string} from @param {string} to
68
+ */
69
+ export const rel = (from, to) => path.relative(from, to).split(path.sep).join('/');
package/lib/manifest.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // @ts-check
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
+ import { CONTENT_ROOT } from './target.js';
4
5
 
5
6
  /** 当前支持的内容仓库 schema 版本 */
6
7
  export const SCHEMA_VERSION = 1;
@@ -38,6 +39,27 @@ export function itemPath(repoRoot, kind, id) {
38
39
  return path.resolve(repoRoot, cfg.dir, cfg.ext === null ? id : `${id}${cfg.ext}`);
39
40
  }
40
41
 
42
+ /**
43
+ * 条目在项目里的落点:`<项目根>/.agents/<仓库目录名>/<id>[ext]`。
44
+ *
45
+ * 和上面 `itemPath` 是镜像——一个问「内容仓库里在哪」,一个问「项目里在哪」。
46
+ * **放在一起是因为它们总是一起变**:加一类条目、改一次扩展名,两个函数必须同时
47
+ * 改对。(它原先住在 `install.js`,于是 `record.js` 为了拿它得反向依赖安装层——
48
+ * 一个「条目在哪」的问题,不该由「怎么把文件拷过去」那个模块来回答。)
49
+ *
50
+ * @param {string} projectRoot @param {string} kind @param {string} id
51
+ */
52
+ export function projectItemPath(projectRoot, kind, id) {
53
+ const cfg = ITEM_KINDS[kind];
54
+ if (!cfg) throw new Error(`未知条目类型 "${kind}"(可用:${ITEM_KIND_NAMES.join('、')})`);
55
+ return path.resolve(
56
+ projectRoot,
57
+ CONTENT_ROOT,
58
+ cfg.dir,
59
+ cfg.ext === null ? id : `${id}${cfg.ext}`,
60
+ );
61
+ }
62
+
41
63
  /**
42
64
  * 解析 `kind:id` 形式的条目引用。
43
65
  * @param {string} entry
package/lib/merge.js CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  PROJECT_DIR_ALIASES,
11
11
  TOOLS,
12
12
  isTool,
13
+ mergeKindsOf,
13
14
  mergeTarget,
14
15
  projectDirOf,
15
16
  } from './target.js';
@@ -63,6 +64,49 @@ function isPlain(value) {
63
64
  return value !== null && typeof value === 'object' && !Array.isArray(value);
64
65
  }
65
66
 
67
+ /**
68
+ * 有 BOM 就剥掉。
69
+ *
70
+ * Windows 上记事本、PowerShell 的 `>` 重定向默认都写带 BOM 的 UTF-8,而
71
+ * `JSON.parse` 见 BOM 直接抛。**这份知识以前散在好几个地方**:merge.js 在
72
+ * `readJsonFile` 里内联做了一次,config.js 抄了一份(还注明「merge.js 早就这么
73
+ * 处理了,这里跟上」),而 record.js 压根没做——于是记事本改一下
74
+ * `.agents/.agent-sync.json`,记录当场判为「不可用」,合并产物的归属跟着全丢。
75
+ * 收在这里一处,谁读 JSON 谁拿去用。
76
+ *
77
+ * @param {string} text
78
+ */
79
+ export function stripBom(text) {
80
+ return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
81
+ }
82
+
83
+ /**
84
+ * 这个错误是不是「路径上真的没有东西」。
85
+ *
86
+ * **只有 `ENOENT` / `ENOTDIR` 算不存在,其余(`EPERM` / `EACCES` / `EBUSY` /
87
+ * `ELOOP`…)一律是「有东西,但读不到」。** 别逐个列举权限类错误码:Windows 上
88
+ * 同一件事可能是 EPERM 也可能是 EACCES,漏一个就退化成「不存在」。
89
+ *
90
+ * 这个区分不是洁癖,两个方向都出过事:
91
+ *
92
+ * - **降级成「不存在」→ 接着写。** `readJsonFile` 的 blocker 就是这么来的:
93
+ * 读不到被当成「这文件没有」,调用方走「没坏、可以写」那条分支,把用户已有的
94
+ * `.mcp.json` 整份覆盖掉。
95
+ * - **降级成「不存在」→ 报给用户看。** `status` 的内容计数原先 `catch` 一律返回
96
+ * null,于是权限错、IO 错都打成「无此目录」——用户看到的是「这儿没东西」,
97
+ * 而真相是「这儿的东西我看不到」。
98
+ *
99
+ * 全项目四处要做这个判断(`readJsonFile`、`presentIn`、`config.js` 读配置、
100
+ * `status.js` 数内容),所以收在这里一份。文件在 merge.js 而不是某个新模块:
101
+ * 那三个调用方本来就都从 merge.js 拿东西,不用为此新增一条依赖边。
102
+ *
103
+ * @param {unknown} err
104
+ */
105
+ export function isMissingPath(err) {
106
+ const code = /** @type {NodeJS.ErrnoException} */ (err)?.code;
107
+ return code === 'ENOENT' || code === 'ENOTDIR';
108
+ }
109
+
66
110
  /**
67
111
  * 路径是否存在,**断链也算存在**。
68
112
  *
@@ -561,8 +605,8 @@ export function readJsonFile(abs) {
561
605
  } catch (e) {
562
606
  const code = /** @type {NodeJS.ErrnoException} */ (e).code;
563
607
  // ENOENT:真的没有。ENOTDIR:路径中间被一个文件挡住了(`.mcp.json/x`)——
564
- // 对我们的用途来说同样等于「这个路径上没有文件」。
565
- if (code === 'ENOENT' || code === 'ENOTDIR') {
608
+ // 对我们的用途来说同样等于「这个路径上没有文件」(判据见 isMissingPath)。
609
+ if (isMissingPath(e)) {
566
610
  return { exists: false, empty: false, bom: false, value: {}, error: null };
567
611
  }
568
612
  // 其余一律是「有东西,但读不到」。**绝不能降级成「不存在」**:那会让调用方
@@ -708,13 +752,27 @@ export function applyMerge({ projectRoot, tool, kind, sources, prev, keep = [],
708
752
  const problems = sourceProblems.map(tag);
709
753
  const warnings = sourceWarnings.map(tag);
710
754
 
711
- /** 收工:把已经攒下的诊断带上,别让调用方以为一切正常 */
755
+ /**
756
+ * 收工:把已经攒下的诊断带上,别让调用方以为一切正常。
757
+ *
758
+ * **`recorded` 的默认值是「上一轮那份」,不是空。** 走到这几条早退路径
759
+ * (目标是链接 / 是目录 / 读不了)时文件一个字节都没动过,而
760
+ * **「没动过」不等于「不是我们写的」**——抹掉归属是不可逆的:下一轮 `prev`
761
+ * 成了 `{}`,那几个 id 立刻落进「与记录之外的 server 重名」那一支,sync 会
762
+ * 劝用户「删掉那一份,或写进 protect」,而那份本来就是它自己写的。
763
+ *
764
+ * 触发方式简单到离谱(实测过):`.mcp.json` 里多一个尾逗号 → 读不了 →
765
+ * 归属当场消失,把文件修好也回不来。
766
+ *
767
+ * 同一条口径在 mergeMcp / mergeHooks 里也写着(读不懂现场时 `recorded: {...prev}`),
768
+ * 只有这里漏了。写失败那条路仍然显式传 `recorded`(那时确实是我们算出来的新归属)。
769
+ */
712
770
  const done = (extra = {}) => ({
713
771
  rel: target.rel,
714
772
  verified: target.verified,
715
773
  changed: false,
716
774
  created: false,
717
- recorded: {},
775
+ recorded: { ...prev },
718
776
  problems,
719
777
  conflicts: [],
720
778
  warnings,
@@ -848,6 +906,31 @@ export function checkMerge({ projectRoot, tool, kind, sources, prev, keep = [] }
848
906
  // 按 [工具 × 类型] 展开
849
907
  // ---------------------------------------------------------------------------
850
908
 
909
+ /**
910
+ * 把 `agents.json` 的 `protect` 折算成**合并层**的「整个不碰」名单。
911
+ *
912
+ * `protect` 是条目层的写法(`"hook:dept-hooks"`),合并层按目录分组(`hooks`),
913
+ * 中间要过一次 `ITEM_KINDS` 的映射。
914
+ *
915
+ * **这段原先在 sync / status / doctor 里各写了一遍**——三份一模一样的语义判断。
916
+ * 加一类合并内容时漏改任何一处,那一处的 `protect` 就会静默失效(用户以为锁上了,
917
+ * 实际照删),而这正是 `ITEM_KINDS` 那张表存在的理由。
918
+ *
919
+ * @param {string[]} [protect]
920
+ * @returns {Record<string, string[]>} 目录名 → 锁住的 id
921
+ */
922
+ export function keepByProtect(protect = []) {
923
+ /** @type {Record<string, string[]>} */
924
+ const out = Object.fromEntries(MERGE_KINDS.map((d) => [d, /** @type {string[]} */ ([])]));
925
+ for (const key of protect) {
926
+ const colon = key.indexOf(':');
927
+ if (colon === -1) continue;
928
+ const dir = ITEM_KINDS[key.slice(0, colon)]?.dir;
929
+ if (dir && MERGE_KINDS.includes(dir)) out[dir].push(key.slice(colon + 1));
930
+ }
931
+ return out;
932
+ }
933
+
851
934
  /**
852
935
  * 一次完整的合并:声明了的每个工具 × 每一类内容,各走一遍 `applyMerge`。
853
936
  *
@@ -997,8 +1080,7 @@ export function presentIn(projectRoot, dir) {
997
1080
  try {
998
1081
  names = fs.readdirSync(path.resolve(projectRoot, CONTENT_ROOT, dir));
999
1082
  } catch (e) {
1000
- const code = /** @type {NodeJS.ErrnoException} */ (e).code;
1001
- if (code === 'ENOENT' || code === 'ENOTDIR') return [];
1083
+ if (isMissingPath(e)) return [];
1002
1084
  return null;
1003
1085
  }
1004
1086
  return names.filter((f) => f.endsWith('.json')).map((f) => f.slice(0, -'.json'.length));
@@ -1108,3 +1190,66 @@ export function checkMergeAll({
1108
1190
 
1109
1191
  return out;
1110
1192
  }
1193
+
1194
+ /**
1195
+ * `status` / `doctor` 共用的「合并产物」只读检查。
1196
+ *
1197
+ * 两个命令原先各写一遍(各三十多行),只有文案不同:读记录 → `presentIn` →
1198
+ * 按 `protect` 折算 keep → `checkMergeAll` → 没有合并目标的工具 → 未实证的路径。
1199
+ * 文案各写各的没问题,**判断必须是同一份**——两处各写一套的下场,就是
1200
+ * `checkMergeAll` 注释里记的那种「status 报 drift、sync 故意不写,用户卡在
1201
+ * 退出码 1 没有任何命令能修好」。
1202
+ *
1203
+ * @param {{
1204
+ * projectRoot: string,
1205
+ * config: {tools: string[], protect: string[]},
1206
+ * record: {usable: boolean, installed: string[], merged?: Record<string, any>},
1207
+ * }} input
1208
+ */
1209
+ export function checkMergeView({ projectRoot, config, record }) {
1210
+ const merged = record?.merged ?? {};
1211
+ const present = Object.fromEntries(MERGE_KINDS.map((d) => [d, presentIn(projectRoot, d)]));
1212
+ const presentCount = MERGE_KINDS.reduce((n, d) => n + (present[d]?.length ?? 0), 0);
1213
+ // 「读不了」和「是空的」是两件事:前者我们根本不知道里面有什么,
1214
+ // 也就答不了「该合的合了没有」——调用方要各自说一句,别当成空
1215
+ const unreadable = MERGE_KINDS.filter((d) => present[d] === null);
1216
+
1217
+ /** 该工具根本没有这一类内容的合并目标(codex 的 mcp)——删掉 status 里那句
1218
+ * 「合并器未实现」之后,这是唯一还会为「装了不生效」说话的地方 */
1219
+ const unsupported = [];
1220
+ for (const tool of config.tools) {
1221
+ for (const dir of MERGE_KINDS) {
1222
+ if (mergeTarget(tool, dir)) continue;
1223
+ const count = present[dir]?.length ?? 0;
1224
+ if (count > 0) unsupported.push({ tool, dir, count });
1225
+ }
1226
+ }
1227
+
1228
+ /** 没实证的路径(目前只有 Trae)——不能报得跟 Claude 那条一样肯定 */
1229
+ const unverified = [];
1230
+ for (const tool of config.tools) {
1231
+ for (const dir of mergeKindsOf(tool)) {
1232
+ const t = mergeTarget(tool, dir);
1233
+ if (t && !t.verified) unverified.push({ tool, rel: t.rel });
1234
+ }
1235
+ }
1236
+
1237
+ return {
1238
+ present,
1239
+ presentCount,
1240
+ unreadable,
1241
+ /** 这一节有没有值得显示的东西——空项目不该因为「文件不存在」吃一笔警告 */
1242
+ hasWork: presentCount > 0 || unreadable.length > 0 || Object.keys(merged).length > 0,
1243
+ rows: checkMergeAll({
1244
+ projectRoot,
1245
+ tools: config.tools,
1246
+ sourcesByKind: Object.fromEntries(
1247
+ MERGE_KINDS.map((d) => [d, sourcesFromDisk(projectRoot, record, d)]),
1248
+ ),
1249
+ keepByKind: keepByProtect(config.protect),
1250
+ prevMerged: merged,
1251
+ }),
1252
+ unsupported,
1253
+ unverified,
1254
+ };
1255
+ }