agent-syncer 0.1.0 → 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.
package/lib/record.js ADDED
@@ -0,0 +1,460 @@
1
+ // @ts-check
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { SUPPORT_DIR, parseItem, projectItemPath } from './manifest.js';
5
+ // BOM 处理只有一份实现(见 merge.js 里 stripBom 的注释)。这个文件的依赖方向
6
+ // 又一次「逆」——记「装过什么」的模块去借处理 JSON 的工具。可以接受:真实
7
+ // 依赖是「JSON 文本怎么读」这件小事,而这份知识散成好几份正是踩过的坑。
8
+ import { stripBom } from './merge.js';
9
+ import { CONTENT_ROOT } from './target.js';
10
+
11
+ /**
12
+ * 「装过什么」的记录文件。
13
+ *
14
+ * ## 为什么必须有它
15
+ *
16
+ * 光靠文件系统推不出来。看起来有条捷径:「`.agents/` 里有、内容仓库里没有 ⇒
17
+ * 是用户自己建的,别动」。但它恰恰在最要紧的情况下失效——**内容仓库某次
18
+ * 把某个技能整个删掉了**,那份内容真的源里已经没有它了,这个启发式会把它
19
+ * 误判成用户内容而永久保留。sync 只会说一句「不在本模板里——不会自动删除」,
20
+ * 用户既不知道它是谁装的,也没有任何办法把它清掉。
21
+ *
22
+ * 更要命的是 `scripts/`:`findOrphans` 明确跳过了它,所以内容仓库删掉一个脚本,
23
+ * 项目里那份会**一声不吭**地留着。
24
+ *
25
+ * 所以需要一个记录,回答「这些内容是本工具放进去的吗」。
26
+ *
27
+ * ## 只记名字,不记内容指纹
28
+ *
29
+ * 一开始这里存的是每条内容的哈希,用来判断「用户改过没有」。去掉了,两个原因:
30
+ *
31
+ * 1. **换行符**。`core.autocrlf` 的 Windows 检出会把同一份内容算成不同哈希,
32
+ * 于是记录会在 LF / CRLF 两种值之间来回翻,而每个项目都提交一份这个文件。
33
+ * 误判的方向倒是保守(该删的变成不删),但翻来覆去本身就是冲突源。
34
+ * 2. **它让记录频繁变动**。内容一更新哈希就变,等于每次内容改动都在每个项目的
35
+ * 仓库里多出一份 diff,而那份改动 `.agents/` 里本来就看得见。
36
+ *
37
+ * 代价是「用户改过的残留」不再被自动认出来。补法是在 `agents.json` 里**显式声明**
38
+ * `protect`——把「猜」换成「说」,也是这个项目一贯的口径:拿不准就不动,拿不准
39
+ * 就让用户讲。
40
+ *
41
+ * ## 只记名字,也不记内容仓库地址
42
+ *
43
+ * 这个文件是**要提交进版本库**的(它描述的是 `.agents/` 的内容,与机器无关;
44
+ * 不提交的话队友克隆后清理功能等于失效)。因此里面绝不能出现内部 git 地址——
45
+ * 部门规则第 3 条,也避免每个项目仓库都带上一份内部坐标。
46
+ *
47
+ * ## 还有一个 `merged`:合并产物的归属
48
+ *
49
+ * skills / rules 那几类落在 `.agents/` 里,`installed` 说「这个文件是本工具放的」
50
+ * 就够了。但 hooks / mcp 要**合并进各工具自己的配置文件**(`.mcp.json`、
51
+ * `.claude/settings.json`),那些文件里还有用户自己的东西——于是问题变成了
52
+ * 「配置文件里的**那一条**是不是我们写的」,而 `installed` 回答不了:
53
+ *
54
+ * - 用户 `.mcp.json` 里本来就有一个同名 server,我们报冲突没敢写,但
55
+ * `installed` 照样会记上 `mcp:foo`(`.agents/mcp/foo.json` 确实是装了的),
56
+ * 于是**下一轮就把它当成自己人覆盖掉**——静默丢掉用户的东西
57
+ * - 用户手删了 `.agents/mcp/foo.json`,`planRemovals` 因为存在性检查不成立
58
+ * 而不认它,配置文件里那一条就**永久残留**
59
+ *
60
+ * 所以另记一份 `merged`:**我们到底往哪个文件里写了什么**。判定因此升级成
61
+ * 「记录说是我写的,**且现场确实还是我写的那份**」才动——现场对不上就判为
62
+ * 用户编辑过,保留并警告。
63
+ *
64
+ * 存的是**解析后的结构**,不是原文、也不是哈希。所以上面「去掉哈希」那两条理由
65
+ * 在这里不成立:`core.autocrlf` 影响的是字节,解析出来的结构是一样的。
66
+ * 比较一律走**无序**深度相等——工具重排键序不该被当成「内容变了」。
67
+ */
68
+ export const RECORD_REL = `${CONTENT_ROOT}/.agent-sync.json`;
69
+
70
+ /**
71
+ * 记录文件的格式版本,与内容仓库的 schemaVersion 无关。
72
+ * 2 = 去掉内容指纹,`installed` / `scripts` 改成字符串数组。
73
+ */
74
+ export const RECORD_SCHEMA = 2;
75
+
76
+ /** @param {string} projectRoot */
77
+ export function recordPath(projectRoot) {
78
+ return path.resolve(projectRoot, RECORD_REL);
79
+ }
80
+
81
+ /** @param {string} projectRoot @param {string} rel */
82
+ function scriptPath(projectRoot, rel) {
83
+ return path.resolve(projectRoot, CONTENT_ROOT, SUPPORT_DIR, rel);
84
+ }
85
+
86
+ /** 只留字符串、去重、排序——手改坏了的记录不该让整件事崩掉 */
87
+ function strList(value) {
88
+ if (!Array.isArray(value)) return [];
89
+ /** @type {string[]} */
90
+ const out = [];
91
+ for (const v of value) if (typeof v === 'string' && !out.includes(v)) out.push(v);
92
+ return out.sort();
93
+ }
94
+
95
+ /** JSON 意义上的「一个对象」——null 和数组都不算 */
96
+ function isPlainObject(value) {
97
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
98
+ }
99
+
100
+ /**
101
+ * 换行符归一,**只为比较用**。写出去的一律是 LF。
102
+ *
103
+ * `core.autocrlf=true` 的检出会让盘上的记录变成 CRLF,而我们是按 LF 拼的。
104
+ * 按字节比的话,每次重新检出之后第一次 sync 都会重写这个文件,git 里永远
105
+ * 挂着一份 modified——正是上面「去掉哈希」那段要躲的病,换了个渠道又回来了。
106
+ * 归一之后只比不管:盘上是什么换行就让它留着。
107
+ *
108
+ * @param {string|null} s
109
+ */
110
+ function eol(s) {
111
+ return s === null ? null : s.replace(/\r\n/g, '\n');
112
+ }
113
+
114
+ /**
115
+ * 归一化 `merged`。
116
+ *
117
+ * 结构:`{ <工具>: { mcp: {<id>: server对象}, hooks: {<id>: {<事件名>: [条目]}} } }`
118
+ *
119
+ * 手改坏的部分一律丢掉——一份被改坏的记录不该把 sync 弄崩。丢掉的方向是
120
+ * 保守的:不认的条目就等于「不是本工具写的」,于是不碰。
121
+ *
122
+ * @param {any} value
123
+ * @returns {Record<string, any>}
124
+ */
125
+ function mergedMap(value) {
126
+ if (!isPlainObject(value)) return {};
127
+
128
+ /** @type {Record<string, any>} */
129
+ const out = {};
130
+ for (const [tool, kinds] of Object.entries(value)) {
131
+ if (!isPlainObject(kinds)) continue;
132
+ /** @type {Record<string, any>} */
133
+ const kept = {};
134
+
135
+ if (isPlainObject(kinds.mcp)) {
136
+ /** @type {Record<string, any>} */
137
+ const mcp = {};
138
+ for (const [id, server] of Object.entries(kinds.mcp)) {
139
+ if (isPlainObject(server)) mcp[id] = server;
140
+ }
141
+ // 空的不记。「一条都没写」和「没有这一项」是同一件事,
142
+ // 记成空对象只会让这份要提交的文件里多出一堆 {}",{}。
143
+ if (Object.keys(mcp).length > 0) kept.mcp = mcp;
144
+ }
145
+
146
+ if (isPlainObject(kinds.hooks)) {
147
+ /** @type {Record<string, any>} */
148
+ const hooks = {};
149
+ for (const [id, events] of Object.entries(kinds.hooks)) {
150
+ if (!isPlainObject(events)) continue;
151
+ /** @type {Record<string, any>} */
152
+ const ev = {};
153
+ for (const [name, entries] of Object.entries(events)) {
154
+ if (Array.isArray(entries)) ev[name] = entries;
155
+ }
156
+ if (Object.keys(ev).length > 0) hooks[id] = ev;
157
+ }
158
+ if (Object.keys(hooks).length > 0) kept.hooks = hooks;
159
+ }
160
+
161
+ if (Object.keys(kept).length > 0) out[tool] = kept;
162
+ }
163
+ return out;
164
+ }
165
+
166
+ /**
167
+ * 读记录。文件不存在、坏了、版本不认识,一律当作「没有记录」——
168
+ * 那是保守方向:没有记录时什么都不会删。
169
+ *
170
+ * ⚠️ 「当作没有记录」对**删除**是保守的,对**归属**却是破坏性的:sync 拿到
171
+ * 一份 unusable 的记录,紧接着就会用一份全新的把它覆盖掉,`merged` 里记的归属
172
+ * 全丢(现场还在文件里,但那之后本工具认不出它是自己写的了)。所以调用方
173
+ * 读到 `reason` 时应当先 `backupUnreadableRecord` 留个档再重建。
174
+ *
175
+ * @param {string} projectRoot
176
+ * @returns {{usable: boolean, reason: string|null, installed: string[], scripts: string[], merged: Record<string, any>}}
177
+ */
178
+ export function readRecord(projectRoot) {
179
+ /** @type {{usable: boolean, reason: string|null, installed: string[], scripts: string[], merged: Record<string, any>}} */
180
+ const empty = { usable: false, reason: null, installed: [], scripts: [], merged: {} };
181
+
182
+ const p = recordPath(projectRoot);
183
+ if (!fs.existsSync(p)) return empty;
184
+
185
+ /** @type {any} */
186
+ let raw;
187
+ try {
188
+ // 先去 BOM 再解析:Windows 上记事本、PowerShell 的 `>` 都会写带 BOM 的 UTF-8,
189
+ // 而 JSON.parse 见 BOM 直接抛——一份完全正常的记录会因此被判成「不可用」,
190
+ // 后果是清理静默失效 + 合并归属全丢。config.js 早就防着这一手,这里原先漏了。
191
+ raw = JSON.parse(stripBom(fs.readFileSync(p, 'utf8')));
192
+ } catch (e) {
193
+ return {
194
+ ...empty,
195
+ reason: `${RECORD_REL} 不是合法的 JSON(${/** @type {Error} */ (e).message}),本次按「没有记录」处理`,
196
+ };
197
+ }
198
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
199
+ return { ...empty, reason: `${RECORD_REL} 的顶层必须是一个对象,本次按「没有记录」处理` };
200
+ }
201
+ if (raw.schemaVersion !== RECORD_SCHEMA) {
202
+ return {
203
+ ...empty,
204
+ reason:
205
+ `${RECORD_REL} 的 schemaVersion 是 ${JSON.stringify(raw.schemaVersion)},` +
206
+ `本工具只支持 ${RECORD_SCHEMA},本次按「没有记录」处理`,
207
+ };
208
+ }
209
+
210
+ // merged 缺字段(老记录)→ {}:`.agent-sync.json` 要提交进每个项目的版本库,
211
+ // 不能因为加了个字段就要求所有人先迁移一遍。
212
+ return {
213
+ usable: true,
214
+ reason: null,
215
+ installed: strList(raw.installed),
216
+ scripts: strList(raw.scripts),
217
+ merged: mergedMap(raw.merged),
218
+ };
219
+ }
220
+
221
+ /**
222
+ * 算出「本工具以前装的、现在不需要了」的东西。
223
+ *
224
+ * 判定只需要两条,都来自明面:
225
+ * - 记录里说装过
226
+ * - 本次不再选中,且没被 `protect` 锁住
227
+ *
228
+ * 本地已经不存在的不返回——它本来就没了,新记录里自然也不会再有。
229
+ *
230
+ * @param {string} projectRoot
231
+ * @param {{installed: string[], scripts: string[]}} record
232
+ * @param {{items: string[], scripts: string[], protect?: string[]}} wanted
233
+ * @returns {{key: string, abs: string}[]}
234
+ */
235
+ export function planRemovals(projectRoot, record, wanted) {
236
+ const protectedKeys = new Set(wanted.protect ?? []);
237
+ /** @type {{key: string, abs: string}[]} */
238
+ const out = [];
239
+
240
+ for (const key of record.installed) {
241
+ if (wanted.items.includes(key) || protectedKeys.has(key)) continue;
242
+ // 名字都解析不了的,映射不到任何路径,谈不上删——跳过。
243
+ // **绝不能让它把整条命令崩掉**:记录是手改得坏的东西,`strList` 那边已经
244
+ // 定过口径「手改坏了的记录不该让整件事崩掉」,这里原先漏了——手写一条
245
+ // "oops" 就能让 status 直接抛异常。跳过的方向也是保守的:不删。
246
+ let parsed;
247
+ try {
248
+ parsed = parseItem(key);
249
+ } catch {
250
+ continue;
251
+ }
252
+ const abs = projectItemPath(projectRoot, parsed.kind, parsed.id);
253
+ if (fs.existsSync(abs)) out.push({ key, abs });
254
+ }
255
+
256
+ for (const rel of record.scripts) {
257
+ const key = `script:${rel}`;
258
+ if (wanted.scripts.includes(rel) || protectedKeys.has(key)) continue;
259
+ const abs = scriptPath(projectRoot, rel);
260
+ if (fs.existsSync(abs)) out.push({ key, abs });
261
+ }
262
+
263
+ return out;
264
+ }
265
+
266
+ /**
267
+ * 给本次装好的内容拍一张记录快照。
268
+ *
269
+ * 只记磁盘上确实存在的——记了装不上的,下次会被误当成「需要清理」。
270
+ * `projectRoot` 就是为了这个存在性检查(以及让调用方不必自己拼路径)。
271
+ *
272
+ * `merged` 不是从文件系统推出来的——它记的是「我们往别人文件里写了什么」,
273
+ * 只能由合并器算好后传进来。默认 `{}`:没有合并能力的那条路径不必关心它。
274
+ *
275
+ * @param {string} projectRoot @param {string[]} items @param {string[]} scripts
276
+ * @param {Record<string, any>} [merged]
277
+ */
278
+ export function snapshotRecord(projectRoot, items, scripts, merged = {}) {
279
+ /** @type {string[]} */
280
+ const installed = [];
281
+ for (const spec of items) {
282
+ try {
283
+ const { kind, id } = parseItem(spec);
284
+ if (fs.existsSync(projectItemPath(projectRoot, kind, id))) installed.push(spec);
285
+ } catch {
286
+ // 名字都解析不了就更不该记
287
+ }
288
+ }
289
+
290
+ /** @type {string[]} */
291
+ const s = [];
292
+ for (const rel of scripts) {
293
+ if (fs.existsSync(scriptPath(projectRoot, rel))) s.push(rel);
294
+ }
295
+
296
+ return {
297
+ schemaVersion: RECORD_SCHEMA,
298
+ installed: strList(installed),
299
+ scripts: strList(s),
300
+ merged: mergedMap(merged),
301
+ };
302
+ }
303
+
304
+ /**
305
+ * 写记录。内容没变就不碰文件。
306
+ *
307
+ * ⚠️ 这里是**整份重建**的:任何字段在这个字面量里漏掉,下次 sync 就会被静默丢弃。
308
+ * 加字段时 `readRecord` / `snapshotRecord` / 这里 / `carryOver` 四处都要过一遍。
309
+ *
310
+ * @param {string} projectRoot
311
+ * @param {{installed: string[], scripts: string[], merged?: Record<string, any>}} snapshot
312
+ * @param {{dryRun?: boolean}} [opts]
313
+ */
314
+ export function writeRecord(projectRoot, snapshot, opts = {}) {
315
+ const p = recordPath(projectRoot);
316
+ const text = `${JSON.stringify(
317
+ {
318
+ schemaVersion: RECORD_SCHEMA,
319
+ installed: strList(snapshot.installed),
320
+ scripts: strList(snapshot.scripts),
321
+ merged: mergedMap(snapshot.merged),
322
+ },
323
+ null,
324
+ 2,
325
+ )}\n`;
326
+
327
+ /** @type {string|null} */
328
+ let current = null;
329
+ try {
330
+ current = fs.readFileSync(p, 'utf8');
331
+ } catch {
332
+ // 不存在,下面照常写
333
+ }
334
+
335
+ const changed = eol(current) !== eol(text);
336
+ if (changed && !opts.dryRun) {
337
+ fs.mkdirSync(path.dirname(p), { recursive: true });
338
+ fs.writeFileSync(p, text);
339
+ }
340
+ return { changed, path: p };
341
+ }
342
+
343
+ /**
344
+ * 这次没删成的条目要留在记录里继续跟踪。
345
+ *
346
+ * 不能一删了之:它们既不属于本次选择,也不属于用户从头写的东西,
347
+ * 直接从记录里抹掉就等于失联——下次同步再也说不出「这是本工具装的」,
348
+ * 用户漏看一次就永远找不回来了。
349
+ *
350
+ * `merged` 原样带过,这里不碰它——它是「往别人文件里写了什么」,撤销失败的
351
+ * 那部分该不该留着,只有合并器自己判断得了。调用方把它放进 snapshot 就是了。
352
+ *
353
+ * @param {{installed: string[], scripts: string[], merged?: Record<string, any>}} snapshot
354
+ * @param {string[]} keys 形如 "skill:x" 或 "script:a.sh"
355
+ */
356
+ export function carryOver(snapshot, keys) {
357
+ for (const key of keys) {
358
+ if (key.startsWith('script:')) {
359
+ const rel = key.slice('script:'.length);
360
+ if (!snapshot.scripts.includes(rel)) snapshot.scripts.push(rel);
361
+ } else if (!snapshot.installed.includes(key)) {
362
+ snapshot.installed.push(key);
363
+ }
364
+ }
365
+ snapshot.installed = strList(snapshot.installed);
366
+ snapshot.scripts = strList(snapshot.scripts);
367
+ return snapshot;
368
+ }
369
+
370
+ /**
371
+ * 这条记录跟踪的是不是某个键(`"skill:x"` 或 `"script:a.sh"`)。
372
+ * @param {{installed: string[], scripts: string[]}} record @param {string} key
373
+ */
374
+ export function isTracked(record, key) {
375
+ return key.startsWith('script:')
376
+ ? record.scripts.includes(key.slice('script:'.length))
377
+ : record.installed.includes(key);
378
+ }
379
+
380
+ /**
381
+ * 记录里跟踪的条目,本地还在不在。
382
+ * 给 status 用——它没有内容仓库,判断不了「该不该装」,但报得出「还在不在」。
383
+ *
384
+ * @param {string} projectRoot
385
+ * @param {{installed: string[], scripts: string[]}} record
386
+ */
387
+ export function checkRecord(projectRoot, record) {
388
+ /** @type {{key: string, abs: string, exists: boolean}[]} */
389
+ const out = [];
390
+
391
+ const check = (key, abs) => out.push({ key, abs, exists: fs.existsSync(abs) });
392
+
393
+ for (const key of record.installed) {
394
+ // 同 planRemovals:名字解析不了就跳过,不能为一条手改坏的名字把 status 打崩
395
+ // (调用方拿 unparsableKeys 单独把这件事说出来)
396
+ let parsed;
397
+ try {
398
+ parsed = parseItem(key);
399
+ } catch {
400
+ continue;
401
+ }
402
+ check(key, projectItemPath(projectRoot, parsed.kind, parsed.id));
403
+ }
404
+ for (const rel of record.scripts) check(`script:${rel}`, scriptPath(projectRoot, rel));
405
+
406
+ return out;
407
+ }
408
+
409
+ /**
410
+ * 记录里**名字都解析不了**的条目(`installed` 里那些)。
411
+ *
412
+ * 它们映射不到任何路径,所以处置只能是「跳过」——但跳过不等于没有这回事:
413
+ * 清理功能会因此少管几项,而用户只看到「跟踪 N 项」莫名其妙变少了。
414
+ * 让调用方说一句,比默默少几条强。
415
+ *
416
+ * `scripts` 不在这里:它们记的是相对路径,本来就没有 `类型:名字` 的格式。
417
+ *
418
+ * @param {{installed: string[]}} record
419
+ * @returns {string[]}
420
+ */
421
+ export function unparsableKeys(record) {
422
+ /** @type {string[]} */
423
+ const out = [];
424
+ for (const key of record.installed) {
425
+ try {
426
+ parseItem(key);
427
+ } catch {
428
+ out.push(key);
429
+ }
430
+ }
431
+ return out;
432
+ }
433
+
434
+ /**
435
+ * 记录读不出来时先留个档,**别让新记录把它直接盖掉**。
436
+ *
437
+ * `writeRecord` 是整份重写的。读到一份解析不了的记录(坏 JSON / BOM / 本版本不
438
+ * 认识的 schemaVersion)就往下写的话,盘上那份就永久没了——而它里面记的是
439
+ * 「哪些内容是本工具装的」,**没有第二处能推导出来**(见文件开头那段)。
440
+ * 判据和 config.js 里那条一样:读失败装作不存在、然后接着写,是**进攻方向**。
441
+ *
442
+ * 只在还没有 `.bak` 时做一次:反复跑 sync 不该把最初那份越冲越远。
443
+ * 备份放在记录旁边(`.agents/` 下),托管段只放行 `.agent-sync.json` 这一个
444
+ * 文件名,所以 `.bak` 不会被提交进版本库。
445
+ *
446
+ * @param {string} projectRoot
447
+ * @returns {string|null} 备份文件的相对路径;没做(没有 / 已存在 / 失败)返回 null
448
+ */
449
+ export function backupUnreadableRecord(projectRoot) {
450
+ const p = recordPath(projectRoot);
451
+ const bak = `${p}.bak`;
452
+ if (!fs.existsSync(p) || fs.existsSync(bak)) return null;
453
+ try {
454
+ fs.copyFileSync(p, bak);
455
+ return `${RECORD_REL}.bak`;
456
+ } catch {
457
+ // 备份不成也不能让 sync 整个失败——下面照常写新记录,只是没留档
458
+ return null;
459
+ }
460
+ }