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.
@@ -0,0 +1,442 @@
1
+ // @ts-check
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { CONTENT_ROOT } from './target.js';
5
+
6
+ /** 当前支持的内容仓库 schema 版本 */
7
+ export const SCHEMA_VERSION = 1;
8
+
9
+ /**
10
+ * 条目的六种类型。
11
+ *
12
+ * 注意这里的 `kind` 是**单数**(`skill:code-style`),而内容仓库里的目录名是复数。
13
+ * 两套词汇故意分开:仓库目录结构可以变,条目 id 是稳定的对外契约。
14
+ *
15
+ * @type {Record<string, {dir: string, ext: string|null, label: string}>}
16
+ */
17
+ export const ITEM_KINDS = {
18
+ skill: { dir: 'skills', ext: null, label: '技能' },
19
+ rule: { dir: 'rules', ext: '.md', label: '规则' },
20
+ command: { dir: 'commands', ext: '.md', label: '命令' },
21
+ agent: { dir: 'agents', ext: '.md', label: '子代理' },
22
+ hook: { dir: 'hooks', ext: '.json', label: 'Hook' },
23
+ mcp: { dir: 'mcp', ext: '.json', label: 'MCP' },
24
+ };
25
+
26
+ export const ITEM_KIND_NAMES = Object.keys(ITEM_KINDS);
27
+
28
+ /** hooks / mcp 靠路径引用脚本,所以 scripts/ 作为支撑目录整体同步,不参与条目挑选 */
29
+ export const SUPPORT_DIR = 'scripts';
30
+
31
+ /**
32
+ * 条目在内容仓库里的绝对路径。
33
+ * skill 是目录,其余是「<id><ext>」单文件。
34
+ * @param {string} repoRoot @param {string} kind @param {string} id
35
+ */
36
+ export function itemPath(repoRoot, kind, id) {
37
+ const cfg = ITEM_KINDS[kind];
38
+ if (!cfg) throw new Error(`未知条目类型 "${kind}"(可用:${ITEM_KIND_NAMES.join('、')})`);
39
+ return path.resolve(repoRoot, cfg.dir, cfg.ext === null ? id : `${id}${cfg.ext}`);
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
+
63
+ /**
64
+ * 解析 `kind:id` 形式的条目引用。
65
+ * @param {string} entry
66
+ * @returns {{kind: string, id: string}}
67
+ */
68
+ export function parseItem(entry) {
69
+ if (typeof entry !== 'string') {
70
+ throw new Error(`条目必须是字符串,收到 ${JSON.stringify(entry)}`);
71
+ }
72
+ const colon = entry.indexOf(':');
73
+ if (colon === -1) {
74
+ throw new Error(`条目 ${JSON.stringify(entry)} 格式不对,应为 "类型:名字",例如 "skill:code-style"`);
75
+ }
76
+ const kind = entry.slice(0, colon).trim();
77
+ const id = entry.slice(colon + 1).trim();
78
+ if (!Object.hasOwn(ITEM_KINDS, kind)) {
79
+ throw new Error(`未知条目类型 "${kind}"(可用:${ITEM_KIND_NAMES.join('、')})`);
80
+ }
81
+ if (id === '') throw new Error(`条目 ${JSON.stringify(entry)} 缺少名字`);
82
+ return { kind, id };
83
+ }
84
+
85
+ /**
86
+ * 加载并校验内容仓库的根清单。
87
+ * @param {string} repoRoot
88
+ */
89
+ export function loadRepo(repoRoot) {
90
+ const p = path.resolve(repoRoot, 'dept.json');
91
+ if (!fs.existsSync(p)) {
92
+ throw new Error(`${repoRoot} 不是内容仓库:找不到 dept.json`);
93
+ }
94
+
95
+ /** @type {any} */
96
+ let raw;
97
+ try {
98
+ raw = JSON.parse(fs.readFileSync(p, 'utf8'));
99
+ } catch (e) {
100
+ throw new Error(`dept.json 不是合法的 JSON:${/** @type {Error} */ (e).message}`);
101
+ }
102
+
103
+ if (raw?.schemaVersion !== SCHEMA_VERSION) {
104
+ throw new Error(
105
+ `内容仓库的 schemaVersion 是 ${JSON.stringify(raw?.schemaVersion)},` +
106
+ `本工具只支持 ${SCHEMA_VERSION}。请升级 agent-syncer。`,
107
+ );
108
+ }
109
+ return { name: raw.name, version: raw.version, description: raw.description, root: path.resolve(repoRoot) };
110
+ }
111
+
112
+ /**
113
+ * 扫描内容仓库,列出每类条目下有哪些 id。
114
+ * @param {string} repoRoot
115
+ * @returns {Record<string, string[]>}
116
+ */
117
+ export function listItems(repoRoot) {
118
+ /** @type {Record<string, string[]>} */
119
+ const out = {};
120
+
121
+ for (const [kind, cfg] of Object.entries(ITEM_KINDS)) {
122
+ const dir = path.resolve(repoRoot, cfg.dir);
123
+ /** @type {fs.Dirent[]} */
124
+ let entries;
125
+ try {
126
+ entries = fs.readdirSync(dir, { withFileTypes: true });
127
+ } catch {
128
+ out[kind] = [];
129
+ continue;
130
+ }
131
+
132
+ out[kind] = entries
133
+ .filter((e) => (cfg.ext === null ? e.isDirectory() : e.isFile() && e.name.endsWith(cfg.ext)))
134
+ .map((e) => (cfg.ext === null ? e.name : e.name.slice(0, -cfg.ext.length)))
135
+ .sort();
136
+ }
137
+
138
+ return out;
139
+ }
140
+
141
+ /**
142
+ * 列出所有可选的 bundle。
143
+ * @param {string} repoRoot
144
+ */
145
+ export function listBundles(repoRoot) {
146
+ const dir = path.resolve(repoRoot, 'bundles');
147
+ /** @type {{name: string, title?: string, description?: string}[]} */
148
+ const out = [];
149
+
150
+ /** @type {string[]} */
151
+ let files;
152
+ try {
153
+ files = fs.readdirSync(dir);
154
+ } catch {
155
+ return out;
156
+ }
157
+
158
+ for (const f of files) {
159
+ if (!f.endsWith('.json')) continue;
160
+ const name = f.slice(0, -'.json'.length);
161
+ /** @type {any} */
162
+ let raw;
163
+ try {
164
+ raw = JSON.parse(fs.readFileSync(path.resolve(dir, f), 'utf8'));
165
+ } catch (e) {
166
+ out.push({ name, description: `(文件损坏:${/** @type {Error} */ (e).message})` });
167
+ continue;
168
+ }
169
+ out.push({ name, title: raw?.title, description: raw?.description });
170
+ }
171
+
172
+ return out.sort((a, b) => a.name.localeCompare(b.name));
173
+ }
174
+
175
+ /**
176
+ * 规整顶层的 `bundle` 字段:单个字符串和字符串数组都收,统一成数组。
177
+ *
178
+ * 单个字符串也接受,理由和项目根一样——没道理为了加第二个模板先加方括号。
179
+ * 语义与项目根 `agents.json` 的 `bundle` 字段**完全一致**:被引用的每个模板
180
+ * 各自完整解析,然后取并集。
181
+ *
182
+ * @param {string} file 出错时用来指明是哪个文件,光说"bundle 格式不对"没法照着修
183
+ * @param {any} value
184
+ * @returns {string[]}
185
+ */
186
+ function normalizeBundleField(file, value) {
187
+ if (value === undefined || value === null) return [];
188
+ if (typeof value !== 'string' && !Array.isArray(value)) {
189
+ throw new Error(`${file} 的 bundle 必须是字符串或字符串数组,收到 ${JSON.stringify(value)}`);
190
+ }
191
+
192
+ const arr = Array.isArray(value) ? value : [value];
193
+ /** @type {string[]} */
194
+ const out = [];
195
+ for (const [i, n] of arr.entries()) {
196
+ if (typeof n !== 'string') {
197
+ throw new Error(`${file} 的 bundle[${i}] 必须是字符串,收到 ${JSON.stringify(n)}`);
198
+ }
199
+ // 去空白、丢空串、去重、保序——**和项目根那份(config.js 的 normalizeList)
200
+ // 规则必须一模一样**。文档里说两层「字段名和含义一字不差」,那 `"common "`
201
+ // 和 `["common"]` 在两边就得是同一件事;不一致的后果很隐蔽:同一份配置写进
202
+ // 模板文件能用、写进 agents.json 就报「找不到模板 "common "」。
203
+ const s = n.trim();
204
+ if (s !== '' && !out.includes(s)) out.push(s);
205
+ }
206
+ return out;
207
+ }
208
+
209
+ /**
210
+ * 模板名只能是**一个文件名**,不能是路径。
211
+ *
212
+ * `readBundle` 拿名字去拼 `bundles/<名字>.json`。不拦的话,内容仓库里一句
213
+ * `"bundle": ["../../../../某处的配置"]` 就能读到仓库外的任意 JSON——而且
214
+ * 读出来的片段还会进报错信息,等于把那个文件的内容写进日志。
215
+ *
216
+ * 影响不算大(内容仓库通常是团队自有的,能读到的多半也只是 JSON),但
217
+ * 「内容仓库可以指定读仓库外的文件」这一条本身就不该成立,拦下来只要几行。
218
+ *
219
+ * @param {string} name
220
+ */
221
+ function assertBundleName(name) {
222
+ if (name === '' || name === '.' || name === '..' || /[\\/]/.test(name) || name.includes('\0')) {
223
+ throw new Error(
224
+ `模板名必须是一个简单的名字,不能带路径:收到 ${JSON.stringify(name)}(例如 "common")`,
225
+ );
226
+ }
227
+ }
228
+
229
+ /** 读一个 bundle 文件 */
230
+ function readBundle(repoRoot, name) {
231
+ assertBundleName(name);
232
+ const p = path.resolve(repoRoot, 'bundles', `${name}.json`);
233
+ if (!fs.existsSync(p)) {
234
+ const available = listBundles(repoRoot).map((b) => b.name);
235
+ throw new Error(
236
+ `找不到模板 "${name}"。可用:${available.length > 0 ? available.join('、') : '(仓库里没有任何 bundle)'}`,
237
+ );
238
+ }
239
+ /** @type {any} */
240
+ let raw;
241
+ try {
242
+ raw = JSON.parse(fs.readFileSync(p, 'utf8'));
243
+ } catch (e) {
244
+ throw new Error(`bundles/${name}.json 不是合法的 JSON:${/** @type {Error} */ (e).message}`);
245
+ }
246
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
247
+ throw new Error(`bundles/${name}.json 的顶层必须是一个对象`);
248
+ }
249
+ if (raw.include !== undefined && !Array.isArray(raw.include)) {
250
+ throw new Error(`bundles/${name}.json 的 include 必须是数组`);
251
+ }
252
+ if (raw.exclude !== undefined && !Array.isArray(raw.exclude)) {
253
+ throw new Error(`bundles/${name}.json 的 exclude 必须是数组`);
254
+ }
255
+ // 顺手把 bundle 字段规整成数组,后面按同一套流水线走,不必再关心它是怎么写进来的
256
+ raw.bundle = normalizeBundleField(`bundles/${name}.json`, raw.bundle);
257
+ return raw;
258
+ }
259
+
260
+ /**
261
+ * 把一条 include / exclude 展开成条目列表。
262
+ *
263
+ * 支持两种写法:
264
+ * - `类型:*` —— 该类型下的全部条目
265
+ * - `类型:名字` —— 单个条目
266
+ *
267
+ * **模板之间的组合不走这里**,走顶层的 `bundle` 字段(`"bundle": ["common"]`)。
268
+ * 老写法 `@common` 已经移除,见到就直接抛错——静默忽略会让「引用没生效」
269
+ * 变成一个没人查得出来的哑谜,而错误信息里的新写法能让人当场改对。
270
+ *
271
+ * `lenient` 只放宽一件事:**条目在仓库里不存在时返回空、改成告警,而不是抛错**。
272
+ * 这专给 exclude 用——"排除一个当前不存在的东西"是合法的空操作,
273
+ * 而项目级的 exclude 是叠在模板之上的最后一层,模板内容一变动就把所有人的
274
+ * 配置搞崩,代价太大。类型写错、语法写错、`@` 老写法,这些一律照旧抛错,绝不放过。
275
+ *
276
+ * @param {string} repoRoot @param {string} entry
277
+ * @param {{lenient?: boolean, warnings?: string[]}} [opts]
278
+ * @returns {string[]}
279
+ */
280
+ function expandEntry(repoRoot, entry, opts = {}) {
281
+ const { lenient = false, warnings = [] } = opts;
282
+
283
+ if (typeof entry === 'string' && entry.startsWith('@')) {
284
+ throw new Error(
285
+ `${JSON.stringify(entry)} 这种写法已经移除。模板之间的组合改用顶层字段:\n` +
286
+ ' "bundle": ["common"]\n' +
287
+ '(bundles/*.json 和 agents.json 里用的是同一个字段,include / exclude 不再接受 "@名字")',
288
+ );
289
+ }
290
+
291
+ const { kind, id } = parseItem(entry);
292
+
293
+ if (id === '*') {
294
+ return listItems(repoRoot)[kind].map((x) => `${kind}:${x}`);
295
+ }
296
+
297
+ const p = itemPath(repoRoot, kind, id);
298
+ if (!fs.existsSync(p)) {
299
+ const available = listItems(repoRoot)[kind];
300
+ const detail =
301
+ `条目 ${JSON.stringify(entry)} 在内容仓库里不存在(找的是 ${path.relative(repoRoot, p)})。` +
302
+ `该类型下现有:${available.length > 0 ? available.join('、') : '(空)'}`;
303
+ if (lenient) {
304
+ warnings.push(`${detail}(作为 exclude 跳过,不影响其它条目)`);
305
+ return [];
306
+ }
307
+ throw new Error(detail);
308
+ }
309
+ return [`${kind}:${id}`];
310
+ }
311
+
312
+ /**
313
+ * 展开一组条目引用(保序、去重)。
314
+ * @param {string} repoRoot @param {string[]} entries
315
+ * @param {{lenient?: boolean, warnings?: string[]}} [opts]
316
+ * @returns {string[]}
317
+ */
318
+ function expandEntries(repoRoot, entries, opts = {}) {
319
+ /** @type {string[]} */
320
+ const out = [];
321
+ for (const entry of entries) {
322
+ for (const item of expandEntry(repoRoot, entry, opts)) {
323
+ if (!out.includes(item)) out.push(item);
324
+ }
325
+ }
326
+ return out;
327
+ }
328
+
329
+ /**
330
+ * 「模板并集 → 叠加 include → 最后过 exclude」这套三步流水线的共用实现。
331
+ *
332
+ * 模板内部(`resolveBundle`)和项目级(`resolveSelection`)走的是**同一套**:
333
+ * 两处各写一份的话,迟早会长出两套规则,而使用者根本分不清「模板里的 bundle」
334
+ * 和「agents.json 里的 bundle」能有什么区别——它们本来就该是同一个东西。
335
+ *
336
+ * 第 1 步刻意是"各自解析再取并集",而不是把多个模板的 include 倒进一个池子
337
+ * 再统一过 exclude:模板应当是自洽的,A 模板的 exclude 不该悄悄砍掉 B 模板的内容,
338
+ * 否则 `bundle: ["a", "b"]` 的结果会依赖组合顺序和谁的 exclude 更宽,没法推理。
339
+ *
340
+ * @param {string} repoRoot
341
+ * @param {{bundle?: string[], include?: string[], exclude?: string[]}} spec
342
+ * @param {string[]} stack 循环引用的引用链,由 resolveBundle 维护
343
+ * @param {{lenient?: boolean, warnings?: string[]}} [opts] 只作用于本层的 exclude
344
+ * @returns {{items: string[], dropped: string[]}}
345
+ */
346
+ function resolvePipeline(repoRoot, spec, stack, opts = {}) {
347
+ const { bundle = [], include = [], exclude = [] } = spec;
348
+ const { lenient = false, warnings = [] } = opts;
349
+
350
+ /** @type {string[]} */
351
+ const items = [];
352
+ const add = (list) => {
353
+ for (const item of list) if (!items.includes(item)) items.push(item);
354
+ };
355
+
356
+ // 1. 每个被引用的模板各自完整解析(递归地含它自己的 bundle / include / exclude),取并集
357
+ for (const name of bundle) add(resolveBundle(repoRoot, name, stack));
358
+
359
+ // 2. 本层的 include 叠在并集之上
360
+ add(expandEntries(repoRoot, include));
361
+
362
+ // 3. 本层的 exclude 最后过一遍 —— 它能砍掉上面任何一步加进来的东西
363
+ const excluded = new Set(expandEntries(repoRoot, exclude, { lenient, warnings }));
364
+ const dropped = items.filter((item) => excluded.has(item));
365
+
366
+ return { items: items.filter((item) => !excluded.has(item)), dropped };
367
+ }
368
+
369
+ /**
370
+ * 解析一个 bundle,得到最终要安装的条目清单(保序、去重)。
371
+ *
372
+ * 流水线见 resolvePipeline,和项目级完全一致:
373
+ * 1. 本文件 `bundle` 引用的每个模板各自完整解析 → 取并集
374
+ * 2. 叠加本文件的 `include`
375
+ * 3. 最后过本文件的 `exclude`
376
+ *
377
+ * 也就是说**模板自己就是个小号的 agents.json**——`bundle` / `include` / `exclude`
378
+ * 三个字段的名字和含义都和项目根一模一样,不用记两套。
379
+ *
380
+ * @param {string} repoRoot @param {string} name @param {string[]} [stack]
381
+ * @returns {string[]}
382
+ */
383
+ export function resolveBundle(repoRoot, name, stack = []) {
384
+ if (stack.includes(name)) {
385
+ throw new Error(`bundle 循环引用:${[...stack, name].join(' → ')}`);
386
+ }
387
+
388
+ const bundle = readBundle(repoRoot, name);
389
+ stack.push(name);
390
+
391
+ try {
392
+ return resolvePipeline(repoRoot, bundle, stack).items;
393
+ } finally {
394
+ stack.pop();
395
+ }
396
+ }
397
+
398
+ /**
399
+ * 解析一次完整的安装选择,得到最终条目清单。
400
+ *
401
+ * 流水线(每一步都在上一步的结果上做增删,实现在 resolvePipeline):
402
+ *
403
+ * 1. 每个 bundle 各自解析(递归地含它自己的 bundle → include → exclude),然后取**并集**
404
+ * 2. 加上项目级 include
405
+ * 3. 减掉项目级 exclude —— 最后过一遍,它能砍掉上面任何一步加进来的东西
406
+ *
407
+ * 与模板唯一的差别是 exclude 更宽容:项目级的 exclude 指着一条已经不在的条目,
408
+ * 只告警不报错(理由见 expandEntry)。
409
+ *
410
+ * @param {string} repoRoot
411
+ * @param {{bundles?: string[], include?: string[], exclude?: string[]}} selection
412
+ * @returns {{items: string[], dropped: string[], warnings: string[]}}
413
+ */
414
+ export function resolveSelection(repoRoot, selection = {}) {
415
+ const { bundles = [], include = [], exclude = [] } = selection;
416
+ /** @type {string[]} */
417
+ const warnings = [];
418
+
419
+ const { items, dropped } = resolvePipeline(
420
+ repoRoot,
421
+ { bundle: bundles, include, exclude },
422
+ [],
423
+ { lenient: true, warnings },
424
+ );
425
+
426
+ return { items, dropped, warnings };
427
+ }
428
+
429
+ /**
430
+ * 按类型把条目分组,方便安装时逐类处理。
431
+ * @param {string[]} items
432
+ * @returns {Record<string, string[]>}
433
+ */
434
+ export function groupByKind(items) {
435
+ /** @type {Record<string, string[]>} */
436
+ const out = {};
437
+ for (const item of items) {
438
+ const { kind, id } = parseItem(item);
439
+ (out[kind] ??= []).push(id);
440
+ }
441
+ return out;
442
+ }