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/config.js CHANGED
@@ -1,170 +1,377 @@
1
1
  // @ts-check
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
- import {
5
- KINDS,
6
- TOOL_NAMES,
7
- allSpecs,
8
- contentDir,
9
- isTool,
10
- parseSpec,
11
- toolsOfSpecs,
12
- } from './target.js';
4
+ import { ITEM_KINDS, ITEM_KIND_NAMES, SUPPORT_DIR } from './manifest.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';
12
+ import { TOOL_NAMES, isTool, sortTools, specsOfTools } from './target.js';
13
13
 
14
14
  export const CONFIG_FILENAME = 'agents.json';
15
15
 
16
+ /**
17
+ * 读 `agents.json` 的原文。三种结果分得很开,**混起来就是上次那个 blocker**:
18
+ *
19
+ * - 文件不存在 → `null`(调用方按「还没配过」处理)
20
+ * - 有东西但**读不到**(权限 / IO)→ 抛
21
+ * - 读到了但**不是合法 JSON** → 抛
22
+ *
23
+ * 后两种一旦被降级成「不存在」,调用方拿到一份空配置、紧接着就是**整份重写**,
24
+ * 用户的 `content` / `bundle` / `include` / `protect` 会一次被抹掉。判据和
25
+ * `merge.js` 里那条一样:读失败装作不存在、然后接着写,是**进攻方向**。
26
+ *
27
+ * @param {string} projectRoot
28
+ * @returns {string|null}
29
+ */
30
+ function readConfigText(projectRoot) {
31
+ const p = path.resolve(projectRoot, CONFIG_FILENAME);
32
+ try {
33
+ return fs.readFileSync(p, 'utf8');
34
+ } catch (e) {
35
+ const err = /** @type {any} */ (e);
36
+ if (isMissingPath(err)) return null;
37
+ throw new Error(`${CONFIG_FILENAME} 读不了(${err.code ?? err.message}):${p}`);
38
+ }
39
+ }
40
+
41
+ /**
42
+ * 解析配置正文。顺带把「顶层必须是一个对象」这条也收在这里——
43
+ * 它和「不是合法 JSON」是同一类事:文件写得不对,而不是配置项写得不对。
44
+ *
45
+ * @param {string} text
46
+ * @returns {Record<string, any>}
47
+ */
48
+ function parseConfigText(text) {
49
+ /** @type {any} */
50
+ let parsed;
51
+ try {
52
+ parsed = JSON.parse(stripBom(text));
53
+ } catch (e) {
54
+ throw new Error(`${CONFIG_FILENAME} 不是合法的 JSON:${/** @type {Error} */ (e).message}`);
55
+ }
56
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
57
+ throw new Error(`${CONFIG_FILENAME} 的顶层必须是一个对象`);
58
+ }
59
+ return parsed;
60
+ }
61
+
16
62
  /**
17
63
  * 读取项目配置 agents.json。
18
64
  *
19
- * 两种写法都支持:
20
- * { "links": ["claude/skills", "trae/skills"] } ← 精确到目录(推荐,交互式勾选写出的就是它)
21
- * { "tools": ["claude", "trae"] } ← 等价于这些工具的全部条目
65
+ * links 里写的是**工具名**,不是某个目录:
66
+ *
67
+ * { "links": ["claude", "trae"] }
22
68
  *
23
- * 文件不存在时按「项目里已有哪些工具目录 + .agents/ 下已有哪些内容」推断,
24
- * 本轮只做推断、不落盘——让交互式命令去问用户。
69
+ * 选用一个工具就是全量适配——想要 Claude Code 就四个目录一起要,
70
+ * 没有「只要 .claude/rules 不要 .claude/skills」这种真实场景。
71
+ * 因此配置里不提供目录级的开关;条目形式("claude/skills")只在内部表示里用。
72
+ *
73
+ * **完全不做推断。** 没写 links(或者压根没有配置文件)时 `tools` 就是空的,
74
+ * `toolsDeclared` 为 false,由调用方决定怎么办——`link` 的做法是当场问用户,
75
+ * 问不了就把可选项和写法列出来。读配置和决定用哪些工具是两件事,不混在一起。
25
76
  *
26
77
  * @param {string} projectRoot
27
- * @returns {{links: string[], tools: string[], path: string, exists: boolean, inferred: boolean, warnings: string[]}}
78
+ * @returns {{tools: string[], specs: string[], content: string|null, ref: string|null, bundles: string[], include: string[], exclude: string[], protect: string[], path: string, exists: boolean, toolsDeclared: boolean, warnings: string[]}}
28
79
  */
29
80
  export function loadConfig(projectRoot) {
30
81
  const cfgPath = path.resolve(projectRoot, CONFIG_FILENAME);
31
82
  /** @type {string[]} */
32
83
  const warnings = [];
33
84
 
34
- if (!fs.existsSync(cfgPath)) {
35
- const links = inferLinks(projectRoot);
85
+ const text = readConfigText(projectRoot);
86
+
87
+ // 「文件不存在」才走这一条。**读不了是抛错,不是「没有」**——两者混起来,
88
+ // 调用方会拿一份空配置去整份重写,把用户写的东西抹掉。
89
+ if (text === null) {
36
90
  return {
37
- links,
38
- tools: toolsOfSpecs(links),
91
+ tools: [],
92
+ specs: [],
93
+ content: null,
94
+ ref: null,
95
+ bundles: [],
96
+ include: [],
97
+ exclude: [],
98
+ protect: [],
39
99
  path: cfgPath,
40
100
  exists: false,
41
- inferred: true,
101
+ toolsDeclared: false,
42
102
  warnings,
43
103
  };
44
104
  }
45
105
 
46
- /** @type {any} */
47
- let raw;
48
- try {
49
- raw = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
50
- } catch (e) {
51
- throw new Error(`${CONFIG_FILENAME} 不是合法的 JSON:${/** @type {Error} */ (e).message}`);
52
- }
53
-
54
- if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
55
- throw new Error(`${CONFIG_FILENAME} 的顶层必须是一个对象`);
56
- }
106
+ const raw = parseConfigText(text);
57
107
 
58
- /** @type {string[]} */
59
- let links;
60
- if (raw.links !== undefined) {
61
- links = normalizeLinks(raw.links, warnings);
62
- } else if (raw.tools !== undefined) {
63
- // links 分支保持一致的排序,否则两种写法会产出不同顺序的 diff
64
- links = sortSpecs(normalizeTools(raw.tools, warnings).flatMap(allSpecs));
65
- } else {
66
- warnings.push(`${CONFIG_FILENAME} 里既没有 links 也没有 tools`);
67
- links = [];
68
- }
108
+ // 没有 links 就**留空**,不按已有目录猜。「这个项目用 Trae」是个持久事实,
109
+ // 从「目录恰好存在」猜出来的东西用户既没同意过、也看不见,
110
+ // 而且猜错的方向很糟:给一个其实不用的工具建了链接。
111
+ //
112
+ // 调用方(link)拿到 toolsDeclared === false 时该去问用户,或者告诉他怎么写。
113
+ const toolsDeclared = raw.links !== undefined;
114
+ const tools = toolsDeclared ? normalizeLinks(raw.links, warnings) : [];
69
115
 
70
116
  return {
71
- links,
72
- tools: toolsOfSpecs(links),
117
+ tools,
118
+ specs: specsOfTools(tools),
119
+ // sync 用:内容来源、版本与选中的模板。
120
+ // content 既可以是本地路径,也可以是 git 地址,由 source.js 自动判别。
121
+ content: typeof raw.content === 'string' ? raw.content.trim() : null,
122
+ ref: typeof raw.ref === 'string' ? raw.ref.trim() : null,
123
+ // bundle 允许写一个,也允许写数组(多个模板取并集)
124
+ bundles: normalizeList(raw.bundle, 'bundle'),
125
+ // 项目级的增删,叠在模板之上;exclude 最后过一遍
126
+ include: normalizeListArray(raw.include, 'include'),
127
+ exclude: normalizeListArray(raw.exclude, 'exclude'),
128
+ // 锁定的条目:sync 不覆盖也不删。写错了必须立刻报错——
129
+ // 这是用户用来保护自己劳动成果的声明,静默忽略比报错危险得多
130
+ protect: normalizeProtect(raw.protect),
73
131
  path: cfgPath,
74
132
  exists: true,
75
- inferred: false,
133
+ toolsDeclared,
76
134
  warnings,
77
135
  };
78
136
  }
79
137
 
80
138
  /**
81
- * 写入配置。按固定顺序(工具顺序 → 内容类型顺序)排列,
139
+ * 读出配置文件里的**原始对象**,一个字段都不校验、不归一化。
140
+ *
141
+ * 两个用处:
142
+ * - `saveConfig` 靠它保住用户自己写的字段(include / exclude / protect,
143
+ * 以及本工具还不认识的将来字段)
144
+ * - `init` 靠它拼出「将要写入的整份配置」给用户过目——那份预览必须和真写下去的
145
+ * 东西长得一模一样,所以走的是同一份原始数据
146
+ *
147
+ * **只有文件真的不存在才返回空对象**,读不了和不是合法 JSON 都**抛**。
148
+ *
149
+ * 第一版这里是个 `catch {}`,把三种情况一起降成 `{}`——那是**进攻方向**:
150
+ * 调用方(`saveConfig`)拿到空对象后紧接着就是整份重写,用户的
151
+ * `content` / `bundle` / `include` / `protect` 会一次没掉。当时靠「调用方都先
152
+ * loadConfig 过」来兜底,但那是一条**约定**,不是一道**保证**——多一个调用方
153
+ * 就多一个漏的机会。判据和 `merge.js` 那条 blocker 完全一样。
154
+ *
155
+ * @param {string} projectRoot
156
+ * @returns {Record<string, any>}
157
+ */
158
+ export function readConfigRaw(projectRoot) {
159
+ const text = readConfigText(projectRoot);
160
+ return text === null ? {} : parseConfigText(text);
161
+ }
162
+
163
+ /**
164
+ * 把一份改动叠到已有配置上,返回**要写进文件的完整对象**。纯函数。
165
+ *
166
+ * patch 里每个字段都是三态的,这个区别很要紧:
167
+ *
168
+ * - `undefined` —— 没问过这一项,**保持文件里的原样**(比如内容仓库不是 git 仓库,
169
+ * ref 那一步跳过了,就不能把人家写好的 ref 抹掉)
170
+ * - `null` —— 明确要清掉这个字段(用户在下拉里选了「不指定」)
171
+ * - 其它值 —— 覆盖
172
+ *
173
+ * `bundle` 清空时**删掉键**而不是写 `[]`:两句话意思一样,但空数组会让
174
+ * 文件里多一行解释不清的东西。工具名按 TOOL_NAMES 的固定顺序排列,
82
175
  * 这样每次勾选后产生的 diff 是稳定的,不会因为点击顺序变来变去。
83
- * @param {string} projectRoot @param {string[]} links
176
+ *
177
+ * @param {Record<string, any>} existing @param {Record<string, any>} patch
178
+ * @returns {Record<string, any>}
84
179
  */
85
- export function saveConfig(projectRoot, links) {
86
- const p = path.resolve(projectRoot, CONFIG_FILENAME);
87
- const sorted = sortSpecs(links);
88
- fs.writeFileSync(p, `${JSON.stringify({ links: sorted }, null, 2)}\n`);
89
- return p;
180
+ export function mergeConfig(existing, patch) {
181
+ /** @type {Record<string, any>} */
182
+ const next = { ...existing };
183
+
184
+ /** @param {string} key @param {any} value */
185
+ const set = (key, value) => {
186
+ if (value === undefined) return;
187
+ if (value === null || value === '') delete next[key];
188
+ else next[key] = value;
189
+ };
190
+
191
+ set('content', patch.content);
192
+ set('ref', patch.ref);
193
+
194
+ if (patch.bundle !== undefined) {
195
+ const list = patch.bundle === null ? [] : normalizeList(patch.bundle, 'bundle');
196
+ if (list.length === 0) delete next.bundle;
197
+ // 只选了一个就写成裸字符串:和文档里的例子一致,也省得用户为了加第二个
198
+ // 模板再去找方括号的位置(loadConfig 两种都认)
199
+ else next.bundle = list.length === 1 ? list[0] : list;
200
+ }
201
+
202
+ if (patch.links !== undefined) next.links = sortTools(patch.links ?? []);
203
+
204
+ return next;
90
205
  }
91
206
 
92
- /** 按 工具顺序 → 内容类型顺序 排序并去重 */
93
- export function sortSpecs(specs) {
94
- const parsed = specs.map((s) => ({ spec: s, ...parseSpec(s) }));
95
- parsed.sort((a, b) => {
96
- const t = TOOL_NAMES.indexOf(a.tool) - TOOL_NAMES.indexOf(b.tool);
97
- return t !== 0 ? t : KINDS.indexOf(a.kind) - KINDS.indexOf(b.kind);
98
- });
99
- return [...new Set(parsed.map((p) => p.spec))];
207
+ /**
208
+ * 写入配置。会保留文件里已有的其它字段——覆盖式写入是个一旦踩到就很难查的坑
209
+ * (用户的 include / exclude / protect 会被悄悄抹掉)。
210
+ *
211
+ * 两种调用方式都认:
212
+ *
213
+ * saveConfig(root, ['claude']) // 老写法:只改 links
214
+ * saveConfig(root, { content, ref, bundle, links }) // init 用的完整写法
215
+ *
216
+ * 数组是 `{ links }` 的简写,老调用方和测试不必跟着改。
217
+ *
218
+ * @param {string} projectRoot
219
+ * @param {string[]|{content?: string|null, ref?: string|null, bundle?: string[]|string|null, links?: string[]}} config
220
+ */
221
+ export function saveConfig(projectRoot, config) {
222
+ const patch = Array.isArray(config) ? { links: config } : config;
223
+ return writeConfig(projectRoot, mergeConfig(readConfigRaw(projectRoot), patch));
100
224
  }
101
225
 
102
226
  /**
103
- * 校验并归一化 links 字段
104
- * @param {unknown} value @param {string[]} warnings
227
+ * 把一份**已经算好的**配置写下去。原子写(同目录 tmp + rename)。
228
+ *
229
+ * 为什么不像 `record.js` / `gitignore.js` 那样直接 `writeFileSync`——那里的分界
230
+ * 不是「谁高谁低」,而是**这份文件丢了能不能自己重生**:
231
+ *
232
+ * | 文件 | 丢了会怎样 |
233
+ * | --- | --- |
234
+ * | 链接、合并产物 | 重跑 `sync` / `link` 就回来了 |
235
+ * | `.agents/.agent-sync.json` | 退化成「不再自动清理」,内容还在 |
236
+ * | **`agents.json`** | `content` / `ref` / `include` / `exclude` / `protect` **没有第二处能推导出来** |
237
+ *
238
+ * 而且 `writeFileSync` 是先截断再写:写到一半崩掉,旧内容**一并没了**,
239
+ * 此后 `loadConfig` 对**所有**命令都抛「不是合法的 JSON」,整个项目卡死到有人
240
+ * 手工修好为止。方向是反的,所以这个文件按 `merge.js` 的标准来。
241
+ *
242
+ * 单独留一个「写算好的对象」的入口,还为了让 init 的**预览**和**落盘**用的是
243
+ * 同一个对象,而不是各读一次文件(两次读之间文件可能被别人改)。
244
+ *
245
+ * @param {string} projectRoot @param {Record<string, any>} next
246
+ */
247
+ export function writeConfig(projectRoot, next) {
248
+ const p = path.resolve(projectRoot, CONFIG_FILENAME);
249
+ writeJsonFile(p, next);
250
+ return p;
251
+ }
252
+
253
+ /**
254
+ * 字符串列表归一化:去空白、丢空串、去重、保序。
255
+ *
256
+ * 字段缺失返回空数组。单个字符串也接受——`"bundle": "common"` 和
257
+ * `"bundle": ["common"]` 都好写,没有理由要求用户为了加第二个模板先加方括号。
258
+ *
259
+ * @param {unknown} value @param {string} field
105
260
  * @returns {string[]}
106
261
  */
107
- export function normalizeLinks(value, warnings = []) {
108
- if (!Array.isArray(value)) {
109
- throw new Error('links 必须是字符串数组,例如 ["claude/skills", "trae/skills"]');
110
- }
262
+ function normalizeList(value, field) {
263
+ if (value === undefined || value === null) return [];
264
+ const arr = Array.isArray(value) ? value : [value];
111
265
  /** @type {string[]} */
112
266
  const out = [];
113
- for (const item of value) {
267
+ for (const item of arr) {
114
268
  if (typeof item !== 'string') {
115
- throw new Error(`links 里出现了非字符串项:${JSON.stringify(item)}`);
269
+ throw new Error(`${field} 里出现了非字符串项:${JSON.stringify(item)}`);
116
270
  }
117
- const spec = item.trim();
118
- try {
119
- parseSpec(spec);
120
- } catch (e) {
121
- warnings.push(`忽略无效条目 "${item}":${/** @type {Error} */ (e).message}`);
271
+ const s = item.trim();
272
+ if (s !== '' && !out.includes(s)) out.push(s);
273
+ }
274
+ return out;
275
+ }
276
+
277
+ /**
278
+ * include / exclude 归一化。
279
+ *
280
+ * 与 bundle 不同,这里**要求写成数组**——它是给人手写的精细控制,
281
+ * 写成裸字符串多半是笔误(`"include": "skill:x"`),报错比默默接受好。
282
+ *
283
+ * @param {unknown} value @param {string} field
284
+ * @returns {string[]}
285
+ */
286
+ function normalizeListArray(value, field) {
287
+ if (value === undefined || value === null) return [];
288
+ if (!Array.isArray(value)) {
289
+ throw new Error(`${field} 必须是字符串数组,例如 ["skill:api-conventions"]`);
290
+ }
291
+ return normalizeList(value, field);
292
+ }
293
+
294
+ /**
295
+ * 校验并归一化 protect 字段。
296
+ *
297
+ * 条目写成 `"kind:id"`:`"skill:my-customized"`;脚本写成 `"script:相对路径"`,
298
+ * 和 `sync --prune` 输出里用的是同一套词。
299
+ *
300
+ * **不支持 `*` 通配**(对 include / exclude 是支持的)。`protect` 是「这个我要
301
+ * 自己留着」的声明,`skill:*` 那种整体锁定实际含义是「这个项目不要用 sync」,
302
+ * 说成通配只会让人以为只是顺手省几个字。要那样就别跑 sync。
303
+ *
304
+ * @param {unknown} value
305
+ * @returns {string[]}
306
+ */
307
+ function normalizeProtect(value) {
308
+ const list = normalizeListArray(value, 'protect');
309
+ for (const entry of list) {
310
+ const colon = entry.indexOf(':');
311
+ const kind = colon === -1 ? '' : entry.slice(0, colon).trim();
312
+ const id = colon === -1 ? '' : entry.slice(colon + 1).trim();
313
+
314
+ if (kind === 'script') {
315
+ if (id === '') throw new Error(`protect 里的 ${JSON.stringify(entry)} 少了脚本路径`);
122
316
  continue;
123
317
  }
124
- if (!out.includes(spec)) out.push(spec);
318
+ const known = /** @type {Record<string, unknown>} */ (ITEM_KINDS)[kind];
319
+ if (!known) {
320
+ throw new Error(
321
+ `protect 里的 ${JSON.stringify(entry)} 类型不对:应为 "类型:名字",` +
322
+ `类型可用 ${ITEM_KIND_NAMES.join('、')},脚本写 "script:${SUPPORT_DIR}/ 下的相对路径"`,
323
+ );
324
+ }
325
+ if (id === '') throw new Error(`protect 里的 ${JSON.stringify(entry)} 少了名字`);
326
+ if (id === '*') {
327
+ throw new Error(
328
+ `protect 不支持通配:把 ${JSON.stringify(entry)} 拆成具体条目。` +
329
+ '想整个类型都不让 sync 管,那这个项目就不该跑 sync。',
330
+ );
331
+ }
125
332
  }
126
- return sortSpecs(out);
333
+ return list;
127
334
  }
128
335
 
129
336
  /**
130
- * 校验并归一化 tools 字段
337
+ * 校验并归一化 links 字段。
338
+ *
339
+ * 值必须是工具名。写明目录的旧写法("claude/skills")直接抛错而不是静默接受:
340
+ * 用户写下的是一个**比实际行为更窄**的东西,若默默按整个工具处理,
341
+ * 他事后会以为自己只启用了 skills。这种误解必须当场说清楚。
342
+ *
131
343
  * @param {unknown} value @param {string[]} warnings
132
344
  * @returns {string[]}
133
345
  */
134
- export function normalizeTools(value, warnings = []) {
346
+ export function normalizeLinks(value, warnings = []) {
135
347
  if (!Array.isArray(value)) {
136
- throw new Error('tools 必须是字符串数组,例如 ["claude", "trae", "codex"]');
348
+ throw new Error('links 必须是字符串数组,例如 ["claude", "trae"]');
137
349
  }
138
350
  /** @type {string[]} */
139
351
  const out = [];
140
352
  for (const item of value) {
141
353
  if (typeof item !== 'string') {
142
- throw new Error(`tools 里出现了非字符串项:${JSON.stringify(item)}`);
354
+ throw new Error(`links 里出现了非字符串项:${JSON.stringify(item)}`);
143
355
  }
144
356
  const name = item.trim().toLowerCase();
357
+ if (name.includes('/')) {
358
+ throw new Error(
359
+ `links 里只写工具名:把 "${item.trim()}" 改成 "${name.split('/')[0]}"。\n` +
360
+ ' 选用一个工具就是该工具全部类型的适配,配置里不再细化到单个目录。',
361
+ );
362
+ }
145
363
  if (!isTool(name)) {
146
364
  warnings.push(`忽略未知工具 "${item}"(可用值:${TOOL_NAMES.join(', ')})`);
147
365
  continue;
148
366
  }
149
367
  if (!out.includes(name)) out.push(name);
150
368
  }
151
- return out;
369
+ return sortTools(out);
152
370
  }
153
371
 
154
- /**
155
- * 没有配置时的推断:只推断「工具目录已存在」且「内容目录已存在」的组合。
156
- * 两者缺一都不猜——多猜一个就会凭空建出一个没人要的目录。
157
- * @param {string} projectRoot
372
+ /*
373
+ * 这里本来有一个 inferTools():项目里存在哪些工具目录,就推断出哪些工具。
374
+ * 已经删掉了——理由见 loadConfig 里那段注释。别再把它加回来:
375
+ * 它猜错的代价是**给一个其实不用的工具建了链接**,而用户看不见这件事发生过。
376
+ * 现在由 link 命令负责问,或者告诉用户怎么写。
158
377
  */
159
- function inferLinks(projectRoot) {
160
- /** @type {string[]} */
161
- const links = [];
162
- for (const tool of TOOL_NAMES) {
163
- if (!fs.existsSync(path.resolve(projectRoot, `.${tool}`))) continue;
164
- for (const spec of allSpecs(tool)) {
165
- const { kind } = parseSpec(spec);
166
- if (fs.existsSync(contentDir(projectRoot, kind))) links.push(spec);
167
- }
168
- }
169
- return links;
170
- }
package/lib/gitignore.js CHANGED
@@ -1,7 +1,9 @@
1
1
  // @ts-check
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
- import { CONTENT_ROOT, KINDS, TOOLS, parseSpec } from './target.js';
4
+ import { isSymlink } from './link.js';
5
+ import { RECORD_REL } from './record.js';
6
+ import { CONTENT_DIRS, CONTENT_ROOT, TOOLS, kindsOf } from './target.js';
5
7
 
6
8
  export const BEGIN_MARK = '# >>> agent-sync >>>';
7
9
  export const END_MARK = '# <<< agent-sync <<<';
@@ -13,15 +15,19 @@ export const END_MARK = '# <<< agent-sync <<<';
13
15
  * 提交上去在别人的机器上全是错的;更危险的是 git 会**穿透链接**,
14
16
  * 把 .agents/ 里的内容原样再提交一份,仓库里出现两套内容。
15
17
  *
16
- * 只忽略**确实建了链接**的目录:如果某个目录是用户自己的实体目录,
17
- * 忽略它会导致内容进不了版本库,那是帮倒忙。
18
+ * 只忽略**确实建了链接**的目录,两条判断缺一不可:
19
+ *
20
+ * - **内容不存在 → 不忽略。** link 会跳过没有内容的类型,也就不会建链接。
21
+ * 配置里写的是工具名(全量适配),四个目录里可能只有一个有内容。
22
+ * - **目标已是实体目录 → 不忽略。** link 拒绝覆盖实体目录,那就是用户自己的东西;
23
+ * 忽略它会导致人家写的东西进不了版本库,是帮倒忙。
18
24
  *
19
25
  * 注意:.claude/settings.json 与 .claude/hooks/ **不能**忽略——
20
26
  * 那些是项目真实配置,需要进版本库。
21
27
  *
22
- * @param {string[]} specs 形如 "claude/skills"
28
+ * @param {string} projectRoot @param {string[]} tools 工具名列表
23
29
  */
24
- export function buildBlock(specs) {
30
+ export function buildBlock(projectRoot, tools) {
25
31
  /** @type {string[]} */
26
32
  const lines = [
27
33
  BEGIN_MARK,
@@ -29,18 +35,29 @@ export function buildBlock(specs) {
29
35
  `${CONTENT_ROOT}/*`,
30
36
  ];
31
37
 
32
- // 白名单:只放行真正的内容目录,其余(临时文件、缓存)一律忽略
33
- for (const kind of KINDS) {
38
+ // 白名单:放行所有真实内容目录,其余(临时文件、缓存)一律忽略。
39
+ // 必须用 CONTENT_DIRS 而非 KINDS —— hooks / mcp / scripts 不参与链接,
40
+ // 但同样是必须提交的真实文件,漏掉它们会让别人克隆下来只剩空壳。
41
+ for (const kind of CONTENT_DIRS) {
34
42
  lines.push(`!${CONTENT_ROOT}/${kind}/`);
35
43
  lines.push(`!${CONTENT_ROOT}/${kind}/*`);
36
44
  }
37
45
 
46
+ // 「装过什么」的记录也要进版本库:它描述的是 .agents/ 的内容,与机器无关。
47
+ // 不提交的话队友克隆下来,sync 就认不出哪些是本工具装的了,清理功能等于失效。
48
+ // 它被上面的 `.agents/*` 忽略,所以必须单独放行。
49
+ lines.push(`!${RECORD_REL}`);
50
+
38
51
  /** @type {string[]} */
39
52
  const targets = [];
40
- for (const spec of specs) {
41
- const { tool, kind } = parseSpec(spec);
42
- const rel = TOOLS[tool].links[kind];
43
- if (!targets.includes(rel)) targets.push(rel);
53
+ for (const tool of tools) {
54
+ for (const kind of kindsOf(tool)) {
55
+ if (!fs.existsSync(path.resolve(projectRoot, CONTENT_ROOT, kind))) continue;
56
+ const rel = TOOLS[tool].links[kind];
57
+ const abs = path.resolve(projectRoot, rel);
58
+ if (fs.existsSync(abs) && !isSymlink(abs)) continue;
59
+ if (!targets.includes(rel)) targets.push(rel);
60
+ }
44
61
  }
45
62
  lines.push(...targets);
46
63
 
@@ -60,9 +77,9 @@ export function readGitignore(projectRoot) {
60
77
 
61
78
  /**
62
79
  * 只读检查:托管段是否存在、缺哪些条目。
63
- * @param {string} projectRoot @param {string[]} specs
80
+ * @param {string} projectRoot @param {string[]} tools
64
81
  */
65
- export function checkBlock(projectRoot, specs) {
82
+ export function checkBlock(projectRoot, tools) {
66
83
  const content = readGitignore(projectRoot);
67
84
  const start = content.indexOf(BEGIN_MARK);
68
85
  const end = content.indexOf(END_MARK);
@@ -71,24 +88,35 @@ export function checkBlock(projectRoot, specs) {
71
88
  return { present: false, missing: [], extra: [] };
72
89
  }
73
90
 
74
- const block = content.slice(start, end);
75
- const expected = buildBlock(specs)
76
- .split('\n')
77
- .filter((l) => l && !l.startsWith('#'));
78
- 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('#'));
97
+
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));
79
107
 
80
- return { present: true, missing, extra: [] };
108
+ return { present: true, missing, extra };
81
109
  }
82
110
 
83
111
  /**
84
112
  * 写入 / 更新托管段。幂等。
85
- * @param {string} projectRoot @param {string[]} specs
113
+ * @param {string} projectRoot @param {string[]} tools
86
114
  * @param {{dryRun?: boolean}} [opts]
87
115
  */
88
- export function writeBlock(projectRoot, specs, opts = {}) {
116
+ export function writeBlock(projectRoot, tools, opts = {}) {
89
117
  const p = path.resolve(projectRoot, '.gitignore');
90
118
  const current = readGitignore(projectRoot);
91
- const block = buildBlock(specs);
119
+ const block = buildBlock(projectRoot, tools);
92
120
 
93
121
  const start = current.indexOf(BEGIN_MARK);
94
122
  const end = current.indexOf(END_MARK);