agent-syncer 0.1.0 → 0.1.1

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