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/source.js ADDED
@@ -0,0 +1,312 @@
1
+ // @ts-check
2
+ import { spawnSync } from 'node:child_process';
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import process from 'node:process';
7
+
8
+ /**
9
+ * 判断 `content` 给的是 git 地址还是本地路径。
10
+ *
11
+ * 先排除本地路径的写法(Windows 盘符、绝对路径、相对路径),再看协议前缀。
12
+ * 顺序很重要:`C:/x/foo.git` 以 .git 结尾,但它显然是本地路径。
13
+ *
14
+ * @param {string} value
15
+ */
16
+ export function isGitUrl(value) {
17
+ const s = value.trim();
18
+ if (s === '') return false;
19
+
20
+ // 本地路径的三种写法:盘符、POSIX 绝对路径、相对路径
21
+ if (/^[a-zA-Z]:[\\/]/.test(s)) return false;
22
+ if (s.startsWith('/') || s.startsWith('\\')) return false;
23
+ if (s.startsWith('.')) return false;
24
+
25
+ return (
26
+ /^(https?|git|ssh|file):\/\//i.test(s) ||
27
+ /^[^\s/@]+@[^\s/]+:/.test(s) || // scp 风格:git@host:path
28
+ s.endsWith('.git')
29
+ );
30
+ }
31
+
32
+ /** git 是否可用 */
33
+ export function hasGit() {
34
+ const res = spawnSync('git', ['--version'], { encoding: 'utf8' });
35
+ return res.status === 0;
36
+ }
37
+
38
+ /**
39
+ * `git clone` 的超时(毫秒)。
40
+ *
41
+ * `GIT_TERMINAL_PROMPT=0` 堵的是「停下来等人输密码」,堵不住**网络黑洞**——
42
+ * 连得上、但不回话(代理 / VPN 不通、对面的 git server 挂着、被墙的 host)。
43
+ * 那种情况下 clone 会一直等下去,在 CI 或 postinstall 里就是永久挂起。
44
+ *
45
+ * 比 `ls-remote` 那个 15 秒宽松得多:那一步是「问了才用」的下拉列表,早点放弃没损失;
46
+ * 这一步**失败就是整条 sync 失败**,而内容仓库可能不小、浅克隆也不一定快。
47
+ * 60 秒远大于实测(内容仓库不到 1 秒),等于「只有真的连不上才会触发」。
48
+ *
49
+ * 超时之后**不重试、也不退回上次的内容**:后者要把缓存引回来,而缓存正是
50
+ * `fetchRepo` 刻意不要的东西。直接报错,让用户知道是网络而不是他的 URL 写错了。
51
+ */
52
+ const CLONE_TIMEOUT_MS = 60_000;
53
+
54
+ /** 这个 spawnSync 结果是不是「超时被杀」。超时时 `status` 是 null、`error.code` 是 ETIMEDOUT */
55
+ function isTimeout(res) {
56
+ return /** @type {any} */ (res.error)?.code === 'ETIMEDOUT';
57
+ }
58
+
59
+ /**
60
+ * 第一次 clone 失败之后,要不要退回**完整克隆**再试一次。
61
+ *
62
+ * 退回的理由只有一个:`--branch` 不认裸 SHA,那一次失败得快且无辜,换完整克隆是正解。
63
+ * 所以**超时不在此列**——超时说明网络已经不行了,而完整克隆(没有 `--depth 1`)
64
+ * 比刚才那次更慢,再发起一次等于把一次黑洞拖成两次。
65
+ *
66
+ * 判据单独拿出来,是因为它值得被钉死,而端到端**造不出**这个场景:要造「连得上、
67
+ * 不回话」,得有一个只接受连接、永不回话的本地 server,可本机实测下来 git 对
68
+ * loopback 的连接根本不完成(对**正在监听**的 server 也一样),代理 / 防火墙各家
69
+ * 又不一样——那种测试会变成环境的函数。
70
+ *
71
+ * @param {any} res @param {string|null|undefined} ref
72
+ */
73
+ export function shouldRetryFullClone(res, ref) {
74
+ if (!ref) return false; // 没有 ref 就没有「--branch 不认 SHA」这回事
75
+ if (res.error) return false; // 超时、被信号杀掉、git 起不来——都不是 ref 的锅
76
+ return res.status !== 0;
77
+ }
78
+
79
+ /**
80
+ * 按 ref 取一份克隆;必要时退回完整克隆再检出。
81
+ *
82
+ * 把执行方式 (`run`) 从参数传进来,**只为能测**:这段的分支(`--branch` 不认裸 SHA、
83
+ * 超时不重试)只有「克隆真的失败了」才走得到,而真造一次失败要么靠网络、要么靠一个
84
+ * 挂住的假 git,两头都不稳。第一版就是因为它内联在 `fetchRepo` 里测不着,于是写了条
85
+ * 「耗时小于 1 秒」的假测试——**还原掉整个防重试逻辑,它照样是绿的**。
86
+ *
87
+ * 导出也是为了测。`fetchRepo` 用的是同一个函数,不是复制一份。
88
+ *
89
+ * @param {{
90
+ * run: (args: string[], timeoutMs?: number) => any,
91
+ * url: string, dest: string, ref?: string|null, timeoutMs: number,
92
+ * }} p
93
+ * @returns {any} 最后一次 `run` 的结果
94
+ */
95
+ export function cloneWithFallback({ run, url, dest, ref, timeoutMs }) {
96
+ /** @type {string[]} */
97
+ const args = ['clone', '--quiet', '--depth', '1'];
98
+ if (ref) args.push('--branch', ref);
99
+ args.push('--', url, dest);
100
+
101
+ const first = run(args, timeoutMs);
102
+ if (!shouldRetryFullClone(first, ref)) return first;
103
+
104
+ // `--branch` 只认分支名和标签名,不认裸 SHA。这时退回完整克隆再检出。
105
+ // 失败时可能留下半个目标目录,重试前必须先清掉,否则克隆会因「目录非空」再失败一次。
106
+ fs.rmSync(dest, { recursive: true, force: true });
107
+ const full = run(['clone', '--quiet', '--', url, dest], timeoutMs);
108
+ return full.status === 0 ? run(['-C', dest, 'checkout', '--quiet', '--detach', ref]) : full;
109
+ }
110
+
111
+ /**
112
+ * 把一个 git 仓库的某个 ref 取到临时目录。
113
+ *
114
+ * 取完即用、用完即删——不做本地缓存。多花的是一次 clone 的时间(内容仓库通常很小),
115
+ * 换来的是没有「缓存过期」这类说不清的状态。
116
+ *
117
+ * **`GIT_TERMINAL_PROMPT=0` 是必须的**:否则遇到需要凭据的仓库时 git 会停下来等输入,
118
+ * 在 CI 或 postinstall 里就是永久挂起——和交互式提示一样危险。
119
+ *
120
+ * @param {{url: string, ref?: string|null, timeoutMs?: number}} opts
121
+ * `timeoutMs` 只为测试而开:正常调用别传,用默认值(见 `CLONE_TIMEOUT_MS`)。
122
+ * @returns {{root: string, cleanup: () => void}}
123
+ */
124
+ export function fetchRepo({ url, ref, timeoutMs = CLONE_TIMEOUT_MS }) {
125
+ if (!hasGit()) {
126
+ throw new Error('找不到 git 命令。请先安装 git,或把 content 指向一个本地已克隆的目录。');
127
+ }
128
+
129
+ const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-syncer-src-'));
130
+ const dest = path.join(tmpRoot, 'repo');
131
+ const cleanup = () => fs.rmSync(tmpRoot, { recursive: true, force: true });
132
+
133
+ const baseEnv = {
134
+ ...process.env,
135
+ GIT_TERMINAL_PROMPT: '0', // 绝不等待输入
136
+ GIT_ASKPASS: 'echo',
137
+ };
138
+
139
+ /**
140
+ * @param {string[]} args @param {number} [timeoutMs]
141
+ * `timeoutMs` 只在 clone 那条路上给。`checkout` 是纯本地操作,套上超时没有意义。
142
+ */
143
+ const run = (args, timeoutMs) =>
144
+ spawnSync('git', args, {
145
+ encoding: 'utf8',
146
+ env: baseEnv,
147
+ // 不用 inherit:clone 的进度输出对使用者没有价值,失败时我们再完整打印 stderr
148
+ stdio: ['ignore', 'pipe', 'pipe'],
149
+ ...(timeoutMs ? { timeout: timeoutMs } : {}),
150
+ });
151
+
152
+ const res = cloneWithFallback({ run, url, dest, ref, timeoutMs });
153
+
154
+ if (res.status !== 0) {
155
+ cleanup();
156
+ // 超时说成「拉取失败」,用户第一反应是去查自己的 URL 写错了没有。
157
+ // 这是两回事:地址是对的,是网络没回话。
158
+ if (isTimeout(res)) {
159
+ throw new Error(
160
+ `拉取超时:${url}${ref ? ` @ ${ref}` : ''}\n` +
161
+ ` git 连上了,但 ${Math.round(timeoutMs / 1000)} 秒没有回话——` +
162
+ '多半是代理 / VPN 不通,或者对面的 git server 挂了。\n' +
163
+ ' 也可以把 content 指向一个本地已克隆的目录:不联网、更快,改完内容立刻能试。',
164
+ );
165
+ }
166
+ const detail = (res.stderr || res.stdout || '').trim();
167
+ throw new Error(
168
+ `拉取失败:${url}${ref ? ` @ ${ref}` : ''}\n` +
169
+ (detail ? ` git 说:${detail}` : ' (git 没有输出更多信息)'),
170
+ );
171
+ }
172
+
173
+ return { root: dest, cleanup };
174
+ }
175
+
176
+ /**
177
+ * 列表类 git 调用的超时(毫秒)。
178
+ *
179
+ * 取 refs 是**问了才用**的一步:内容仓库连不上时,用户宁可看到「跳过这一步」
180
+ * 也不愿意对着一个不动的光标等下去。凭据那条路已经由 GIT_TERMINAL_PROMPT=0 堵死,
181
+ * 这里堵的是「连得上但不回话」——网络黑洞、被墙的 host、挂掉的 git server。
182
+ */
183
+ const REF_LIST_TIMEOUT_MS = 15_000;
184
+
185
+ /**
186
+ * 解析 `git` 列出来的 ref 行,分成分支和标签。
187
+ *
188
+ * 两种来源共用:`ls-remote` 给的是 `<sha>\t<ref>`,`for-each-ref` 给的是裸的
189
+ * `<ref>`。所以只认「最后一个字段」,前缀对不上的一律丢掉。
190
+ *
191
+ * 标签要去掉 `^{}` 后缀:注解标签在 `ls-remote` 里会出现两行(标签本身和它
192
+ * 指向的提交),不处理的话列表里会多出一个 `v1^{}`。
193
+ *
194
+ * @param {string} text
195
+ * @returns {{branches: string[], tags: string[]}}
196
+ */
197
+ function parseRefs(text) {
198
+ /** @type {string[]} */
199
+ const branches = [];
200
+ /** @type {string[]} */
201
+ const tags = [];
202
+
203
+ for (const line of text.split('\n')) {
204
+ const trimmed = line.trim();
205
+ if (trimmed === '') continue;
206
+ const parts = trimmed.split(/\s+/);
207
+ const ref = parts[parts.length - 1];
208
+ if (ref.startsWith('refs/heads/')) {
209
+ branches.push(ref.slice('refs/heads/'.length));
210
+ } else if (ref.startsWith('refs/tags/')) {
211
+ const name = ref.slice('refs/tags/'.length).replace(/\^\{\}$/, '');
212
+ if (name !== '' && !tags.includes(name)) tags.push(name);
213
+ }
214
+ }
215
+
216
+ return { branches, tags };
217
+ }
218
+
219
+ /**
220
+ * 本地路径配了 `ref` 时该说的那句话。
221
+ *
222
+ * `sync` 对本地路径读的是那个目录的**工作树**(这正是本地路径「改完内容立刻能试」
223
+ * 的来由),`ref` 一个字都用不上。写进配置却不生效,比不写更糟——用户会以为版本
224
+ * 被钉住了,实际拿到的还是他当前检出的那份,而且**全程没有任何提示**。
225
+ *
226
+ * 想钉版本就把 `content` 写成 git 地址(`file:///…` 也算,那会走 clone)。
227
+ *
228
+ * @param {string} source @param {string|null|undefined} ref
229
+ * @returns {string|null} 要说的话;没什么要说的返回 null
230
+ */
231
+ export function localRefWarning(source, ref) {
232
+ if (!ref || isGitUrl(source)) return null;
233
+ return (
234
+ `"ref": "${ref}" 对本地路径不生效——读的是那个目录的工作树,不是某个提交。` +
235
+ '要钉版本请把 content 写成 git 地址'
236
+ );
237
+ }
238
+
239
+ /**
240
+ * 列出内容仓库有哪些分支和标签。
241
+ *
242
+ * 远端走 `git ls-remote`(**不克隆**,只问一句),本地路径走 `git for-each-ref`。
243
+ * 本地路径不是 git 仓库、git 没装、离线、要凭据、超时——**一律返回 null,
244
+ * 不抛错**。这一条是刻意的:调用方(init)问这个问题只是为了给用户一个下拉列表,
245
+ * 取不到就该安静跳过,绝不能让「列不出 refs」把整个流程拦下来。
246
+ *
247
+ * `GIT_TERMINAL_PROMPT=0` 与 fetchRepo 同一个理由:要密码的仓库必须当场失败,
248
+ * 不能停下来等人输入——CI 里那就是永久挂起。
249
+ *
250
+ * @param {{source: string, cwd?: string}} opts `source` 是本地路径或 git 地址
251
+ * @returns {{branches: string[], tags: string[]}|null}
252
+ */
253
+ export function listRefs({ source, cwd = process.cwd() }) {
254
+ if (typeof source !== 'string' || source.trim() === '') return null;
255
+ if (!hasGit()) return null;
256
+
257
+ const env = {
258
+ ...process.env,
259
+ GIT_TERMINAL_PROMPT: '0',
260
+ GIT_ASKPASS: 'echo',
261
+ };
262
+ const opts = {
263
+ encoding: /** @type {const} */ ('utf8'),
264
+ env,
265
+ stdio: /** @type {const} */ (['ignore', 'pipe', 'pipe']),
266
+ timeout: REF_LIST_TIMEOUT_MS,
267
+ };
268
+
269
+ const res = isGitUrl(source)
270
+ ? spawnSync('git', ['ls-remote', '--heads', '--tags', '--', source], opts)
271
+ : spawnSync('git', ['-C', path.resolve(cwd, source), 'for-each-ref', '--format=%(refname)', 'refs/heads', 'refs/tags'], opts);
272
+
273
+ // timeout 时 spawnSync 会把 status 置成 null 并给出 error,一并当作「取不到」
274
+ if (res.error || res.status !== 0) return null;
275
+ return parseRefs(res.stdout ?? '');
276
+ }
277
+
278
+ /**
279
+ * 把分支和标签排成下拉列表里的顺序。纯函数,单独测。
280
+ *
281
+ * 顺序是有讲究的,不是随便排的:
282
+ *
283
+ * 1. `main`、`master` —— 用户十有八九要选的就是它,而且 main 在 master 前面
284
+ * (新仓库用 main,老仓库才是 master)
285
+ * 2. **标签** —— 想钉版本的人一眼能看到;这类名字(v1.2.3)和分支名不会混
286
+ * 3. 其它分支 —— 最不常选,垫底
287
+ *
288
+ * 组内按 `numeric` 的自然序排(v1.2 < v1.10,而不是字典序的 v1.10 < v1.2),
289
+ * 并且**排一次序**:输入顺序取决于 git 的返回顺序,那东西不稳定,而
290
+ * 下拉列表每次长得不一样会让人怀疑自己是不是点错了。
291
+ *
292
+ * 同名去重,保留先出现的那个——分支 `v1` 和标签 `v1` 撞名时按上面的优先级走。
293
+ *
294
+ * @param {{branches?: string[], tags?: string[]}} refs
295
+ * @returns {string[]}
296
+ */
297
+ export function orderRefs({ branches = [], tags = [] } = {}) {
298
+ // 自然序:不带 numeric 的话 v1.10 会排在 v1.2 前面,而标签里这种名字最常见
299
+ const cmp = (/** @type {string} */ a, /** @type {string} */ b) =>
300
+ a.localeCompare(b, 'en', { numeric: true });
301
+
302
+ const rank = (/** @type {string} */ name) => (name === 'main' ? 0 : name === 'master' ? 1 : 2);
303
+ const head = branches.filter((b) => rank(b) < 2).sort((a, b) => rank(a) - rank(b) || cmp(a, b));
304
+ const rest = branches.filter((b) => rank(b) === 2).sort(cmp);
305
+
306
+ /** @type {string[]} */
307
+ const out = [];
308
+ for (const name of [...head, ...[...tags].sort(cmp), ...rest]) {
309
+ if (name !== '' && !out.includes(name)) out.push(name);
310
+ }
311
+ return out;
312
+ }
package/lib/stale.js ADDED
@@ -0,0 +1,130 @@
1
+ // @ts-check
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { STATUS, inspect, samePath } from './link.js';
5
+ import { deepEqualJSON, readJsonFile } from './merge.js';
6
+ import { MERGE_KINDS, TOOLS, TOOL_NAMES, contentDir, kindsOf, mergeTarget } from './target.js';
7
+
8
+ /**
9
+ * 找出「已经建好、但当前配置里不再需要」的链接。
10
+ *
11
+ * 为什么必须有这一步:**link 只加不减**。把某个工具从 agents.json 的 links 里
12
+ * 去掉,已经建好的 junction 会原地留下,而 .gitignore 托管段却已经不忽略它们了。
13
+ * 于是 git 会穿透这些链接,把 .agents/ 的内容**再提交一份**——托管段存在的
14
+ * 唯一目的就此被绕过,全程没有任何提示。
15
+ *
16
+ * 只认**指向本项目 .agents/<kind>/ 的链接**:指向别处的链接不是本工具建的,
17
+ * 该不该删与本次配置无关,交给用户自己判断。
18
+ *
19
+ * 断链也算——链接指着 .agents/rules、而 .agents/rules 已经被删掉,
20
+ * 同样是该清理的残留。
21
+ *
22
+ * 纯只读,不修改任何东西。
23
+ *
24
+ * @param {string} projectRoot
25
+ * @param {string[]} tools 当前配置里要保留的工具
26
+ * @returns {{tool: string, kind: string, rel: string, target: string}[]}
27
+ */
28
+ export function findStaleLinks(projectRoot, tools) {
29
+ /** @type {Set<string>} */
30
+ const wanted = new Set();
31
+ for (const tool of tools) {
32
+ for (const kind of kindsOf(tool)) wanted.add(TOOLS[tool].links[kind]);
33
+ }
34
+
35
+ /** @type {{tool: string, kind: string, rel: string, target: string}[]} */
36
+ const out = [];
37
+
38
+ for (const tool of TOOL_NAMES) {
39
+ for (const kind of kindsOf(tool)) {
40
+ const rel = TOOLS[tool].links[kind];
41
+ if (wanted.has(rel)) continue;
42
+
43
+ const target = path.resolve(projectRoot, rel);
44
+ const expected = contentDir(projectRoot, kind);
45
+ const st = inspect(target, expected);
46
+
47
+ const ours =
48
+ st.status === STATUS.HEALTHY ||
49
+ (st.status === STATUS.BROKEN && st.actual !== undefined && samePath(st.actual, expected));
50
+ if (!ours) continue;
51
+
52
+ out.push({ tool, kind, rel, target });
53
+ }
54
+ }
55
+
56
+ return out;
57
+ }
58
+
59
+ /**
60
+ * 文件里还找得到这一条吗。找不到就别报,免得虚惊一场。
61
+ *
62
+ * mcp 按 id 查键;hook 没有 id 这个概念(条目按事件名排),只能在对应事件
63
+ * 里按深度相等找我们记下的那些条目。
64
+ *
65
+ * @param {Record<string, any>} cfg @param {string} kind @param {string} id @param {any} value
66
+ */
67
+ function stillPresent(cfg, kind, id, value) {
68
+ if (kind === 'mcp') {
69
+ const servers = cfg.mcpServers;
70
+ return (
71
+ servers !== null && typeof servers === 'object' && !Array.isArray(servers) && Object.hasOwn(servers, id)
72
+ );
73
+ }
74
+
75
+ const hooks = cfg.hooks;
76
+ if (hooks === null || typeof hooks !== 'object' || Array.isArray(hooks)) return false;
77
+ for (const [event, entries] of Object.entries(value ?? {})) {
78
+ const list = Array.isArray(hooks[event]) ? hooks[event] : [];
79
+ for (const entry of entries ?? []) {
80
+ if (list.some((x) => deepEqualJSON(x, entry))) return true;
81
+ }
82
+ }
83
+ return false;
84
+ }
85
+
86
+ /**
87
+ * 找出「本工具合进过、但当前配置里已经不要那个工具了」的合并产物。
88
+ *
89
+ * 和 `findStaleLinks` 完全同构,后果也一样:`.mcp.json` 是我们写的、已经提交
90
+ * 进版本库,而那个工具从 `links` 里去掉之后**没有任何东西会去清它**——文件里
91
+ * 留着一条谁也不认识的 server 定义,一句提示都没有。
92
+ *
93
+ * 判定只用记录里的 `merged`(「我们往哪个文件里写了什么」),不看文件里
94
+ * 「有没有像我们的东西」——后者是猜,而这个项目一贯的立场是拿不准就不动。
95
+ * 另外要求那条**现在还在文件里**,否则用户自己删过了还报「有残留」就是虚惊。
96
+ *
97
+ * 纯只读。
98
+ *
99
+ * @param {string} projectRoot
100
+ * @param {string[]} tools 当前配置里要保留的工具
101
+ * @param {Record<string, any>} merged 记录里的 merged
102
+ * @returns {{tool: string, kind: string, rel: string, ids: string[]}[]}
103
+ */
104
+ export function findStaleMerges(projectRoot, tools, merged) {
105
+ const keep = new Set(tools);
106
+ /** @type {{tool: string, kind: string, rel: string, ids: string[]}[]} */
107
+ const out = [];
108
+
109
+ for (const tool of TOOL_NAMES) {
110
+ if (keep.has(tool)) continue;
111
+
112
+ for (const kind of MERGE_KINDS) {
113
+ const target = mergeTarget(tool, kind);
114
+ if (!target) continue;
115
+
116
+ const recorded = merged?.[tool]?.[kind] ?? {};
117
+ if (Object.keys(recorded).length === 0) continue;
118
+
119
+ const abs = path.resolve(projectRoot, target.rel);
120
+ if (!fs.existsSync(abs)) continue; // 文件都删了,没有残留可言
121
+ const read = readJsonFile(abs);
122
+ if (read.error) continue; // 读不了就别乱说
123
+
124
+ const ids = Object.keys(recorded).filter((id) => stillPresent(read.value, kind, id, recorded[id]));
125
+ if (ids.length > 0) out.push({ tool, kind, rel: target.rel, ids });
126
+ }
127
+ }
128
+
129
+ return out;
130
+ }
package/lib/target.js CHANGED
@@ -7,14 +7,69 @@ import path from 'node:path';
7
7
  */
8
8
  export const CONTENT_ROOT = '.agents';
9
9
 
10
- /** 内容类型。与 .agents/ 下的子目录名一一对应。 */
10
+ /** 可链接的内容类型。与 .agents/ 下的子目录名一一对应。 */
11
11
  export const KINDS = ['skills', 'rules', 'commands', 'agents'];
12
12
 
13
+ /**
14
+ * 工具的配置目录名(相对项目根):claude → `.claude`。
15
+ *
16
+ * 「工具名就是目录名去掉点」这条约定原先在 link.js(拼表格)、doctor.js
17
+ * (PACKAGE_TOOL_HINTS)各写了一遍,现在收到这里单点维护——它和下面的 TOOLS
18
+ * 属于同一类知识:哪个工具把东西放哪。
19
+ *
20
+ * @param {string} tool
21
+ */
22
+ export function toolDir(tool) {
23
+ return `.${tool}`;
24
+ }
25
+
26
+ /**
27
+ * 合并层认的内容类型。**用的是目录名(复数)**,不是条目类型名。
28
+ *
29
+ * 这个项目里有两套词汇,是故意的:
30
+ *
31
+ * - **选择层**用单数条目名(`skill` / `hook` / `mcp`)——那是 `include` 里要写的词,
32
+ * 见 `manifest.js` 的 `ITEM_KINDS`
33
+ * - **文件层**用复数目录名(`skills` / `hooks` / `mcp`)——那是 `.agents/` 下的路径
34
+ *
35
+ * 合并器在**文件层**干活(读 `.agents/mcp/*.json`、写工具自己的配置文件),
36
+ * 所以这里和记录里的 `merged` 一律用复数。**这个坑真踩过**:`list` 初版按目录名
37
+ * 过滤,而数据是单数类型名,那句「装了不生效」的警告一声不吭。
38
+ */
39
+ export const MERGE_KINDS = ['mcp', 'hooks'];
40
+
41
+ /**
42
+ * 项目根在内容里的写法。**不引入新的中性占位符**,改成一张别名表:
43
+ * 内容里写哪个都认,合并时统一翻译成目标工具自己的写法。
44
+ *
45
+ * 理由是这个项目的铁律——内容只描述「这是什么」,不描述「写到哪」。而
46
+ * `args` / `env` / `headers` 里引用项目内的脚本时又确实需要一个「项目根」的
47
+ * 写法,各工具叫法还不同。别名表让两边都成立:已经写下的
48
+ * `${CLAUDE_PROJECT_DIR}` 不会作废,写 `${workspaceFolder}` 的内容也能在
49
+ * Claude 上跑。
50
+ */
51
+ export const PROJECT_DIR_ALIASES = ['CLAUDE_PROJECT_DIR', 'workspaceFolder'];
52
+
53
+ /**
54
+ * .agents/ 下**需要提交到版本库**的全部目录。
55
+ *
56
+ * 前四类是可链接的(见 KINDS);hooks / mcp / scripts 不链接到任何工具目录,
57
+ * 它们由合并器读取、或被 hook 命令直接引用,但同样是真实文件而非链接。
58
+ *
59
+ * .gitignore 的白名单漏掉这三个,会导致 hook 脚本和 MCP 配置根本提交不上去——
60
+ * 别人克隆下来只有一个空壳。
61
+ */
62
+ export const CONTENT_DIRS = [...KINDS, 'hooks', 'mcp', 'scripts'];
63
+
13
64
  /**
14
65
  * 工具 → 目标路径映射表。
15
66
  *
16
67
  * 这是全项目唯一需要关心「哪个工具把内容放哪」的地方——
17
- * link.js / status.js / doctor.js 都不得出现 if-tool 分支。
68
+ * link.js / status.js / doctor.js / merge.js 都不得出现 if-tool 分支。
69
+ *
70
+ * `links` 管**目录链接**(skills / rules / commands / agents),
71
+ * `merges` 管**合并进工具自己的配置文件**(hooks / mcp)——两套机制,
72
+ * 两套键,别互相污染。`merges` **缺键即不支持**,见 `mergeTarget()`。
18
73
  *
19
74
  * - claude:skills / rules / commands / agents 四类都是目录
20
75
  * (rules 目录的有效性已在本机 Claude Code 2.1.267 上实测确认,含嵌套子目录)
@@ -37,6 +92,14 @@ export const TOOLS = {
37
92
  commands: '.claude/commands',
38
93
  agents: '.claude/agents',
39
94
  },
95
+ // 本机 Claude Code 2.1.267 实测:`claude mcp add --scope project` 写的就是
96
+ // 项目根的 `.mcp.json`,顶层是 `mcpServers` 对象。插件根目录下的 `.mcp.json`
97
+ // 用的是**扁平形式**(没有包装),那是插件特有的,别混。
98
+ merges: {
99
+ mcp: { rel: '.mcp.json' },
100
+ hooks: { rel: '.claude/settings.json' },
101
+ },
102
+ projectDir: { var: 'CLAUDE_PROJECT_DIR' },
40
103
  },
41
104
  trae: {
42
105
  label: 'Trae',
@@ -45,12 +108,23 @@ export const TOOLS = {
45
108
  rules: '.trae/rules',
46
109
  commands: '.trae/commands',
47
110
  },
111
+ // ⚠️ 本机没装 Trae,下面这两项**都没有实证**,来自网络资料。写错了的后果是
112
+ // 这个文件不生效(不会破坏别的东西),但 doctor 不该把它报成「一切正常」——
113
+ // 见 mergeTarget() 的 verified。
114
+ merges: {
115
+ mcp: { rel: '.trae/mcp.json', verified: false },
116
+ },
117
+ projectDir: { var: 'workspaceFolder', verified: false },
48
118
  },
49
119
  codex: {
50
120
  label: 'Codex CLI',
51
121
  links: {
52
122
  skills: '.codex/skills',
53
123
  },
124
+ // **没有 merges,是有意的**:Codex 的 MCP 配置是 `~/.codex/config.toml` 那样的
125
+ // TOML,项目级 `.codex/config.toml` 又只在**受信任项目**里加载,而且
126
+ // `codex mcp add` 只能写用户级(上游 issue #23487 还开着)。
127
+ // 缺键 = 不支持,调用方据此明确报警,而不是静默跳过。
54
128
  },
55
129
  };
56
130
 
@@ -66,27 +140,100 @@ export function contentDir(projectRoot, kind) {
66
140
  return path.resolve(projectRoot, CONTENT_ROOT, kind);
67
141
  }
68
142
 
143
+ /** 取工具配置。未知工具一律抛错——静默忽略一份写错的配置太危险 */
144
+ function toolCfg(tool) {
145
+ const cfg = TOOLS[tool];
146
+ if (!cfg) throw new Error(`未知工具:${tool}。可用值:${TOOL_NAMES.join(', ')}`);
147
+ return cfg;
148
+ }
149
+
69
150
  /**
70
151
  * 某工具支持的内容类型列表
71
152
  * @param {string} tool
72
153
  * @returns {string[]}
73
154
  */
74
155
  export function kindsOf(tool) {
75
- const cfg = TOOLS[tool];
76
- if (!cfg) throw new Error(`未知工具:${tool}。可用值:${TOOL_NAMES.join(', ')}`);
156
+ const cfg = toolCfg(tool);
77
157
  return KINDS.filter((k) => Object.hasOwn(cfg.links, k));
78
158
  }
79
159
 
160
+ /**
161
+ * 某工具对某一类内容有没有合并目标。**没有就是「不支持」**。
162
+ *
163
+ * 「没有这个文件」有三种截然不同的意思,别混成一种:
164
+ *
165
+ * 1. 该工具这次没被 `links` 选中 → 什么都不该说
166
+ * 2. 选中了,但该工具根本没有这个机制(codex 的 mcp)→ **必须报警**
167
+ * 「内容装上了,但没有地方能合,不会生效」
168
+ * 3. 支持,但目标文件还不存在 → 正常,创建
169
+ *
170
+ * `verified: false` 表示「这条路没有实证」(目前只有 Trae)。调用方拿它去
171
+ * 决定该报 `ok` 还是「未实证」——**不能把没验证过的东西报成一切正常**。
172
+ *
173
+ * @param {string} tool @param {string} kind MERGE_KINDS 之一
174
+ * @returns {{rel: string, verified: boolean}|null}
175
+ */
176
+ export function mergeTarget(tool, kind) {
177
+ const m = toolCfg(tool).merges?.[kind];
178
+ if (!m) return null;
179
+ return { rel: m.rel, verified: m.verified !== false };
180
+ }
181
+
182
+ /**
183
+ * 某工具支持合并的内容类型。
184
+ * @param {string} tool
185
+ * @returns {string[]}
186
+ */
187
+ export function mergeKindsOf(tool) {
188
+ const cfg = toolCfg(tool);
189
+ return MERGE_KINDS.filter((k) => Object.hasOwn(cfg.merges ?? {}, k));
190
+ }
191
+
192
+ /**
193
+ * 项目根变量在该工具里该写成什么。**没有合并目标的工具返回 `null`**——
194
+ * 它压根没有需要写路径的地方(codex 的 MCP 是 TOML,本工具不做)。
195
+ *
196
+ * @param {string} tool
197
+ * @returns {{var: string, verified: boolean}|null}
198
+ */
199
+ export function projectDirOf(tool) {
200
+ const p = toolCfg(tool).projectDir;
201
+ return p ? { var: p.var, verified: p.verified !== false } : null;
202
+ }
203
+
80
204
  /** 条目的规范写法:"claude/skills" */
81
205
  export function specOf(tool, kind) {
82
206
  return `${tool}/${kind}`;
83
207
  }
84
208
 
85
- /** 某工具支持的全部条目(用于 `tools` 形式的配置展开) */
209
+ /** 某工具支持的全部条目 */
86
210
  export function allSpecs(tool) {
87
211
  return kindsOf(tool).map((k) => specOf(tool, k));
88
212
  }
89
213
 
214
+ /**
215
+ * 把工具名列表展开成条目列表。
216
+ *
217
+ * 配置里只写工具名(`"links": ["claude", "trae"]`)——选用一个工具就是全量适配,
218
+ * 细化到单个目录没有实际意义。条目形式("claude/skills")仅作为内部表示存在,
219
+ * 由这个函数单点展开,别处不再各自拼。
220
+ *
221
+ * @param {string[]} tools
222
+ */
223
+ export function specsOfTools(tools) {
224
+ return tools.flatMap(allSpecs);
225
+ }
226
+
227
+ /**
228
+ * 按 TOOL_NAMES 的固定顺序排序工具名,顺带去重。
229
+ * 固定顺序是为了让每次写出的配置 diff 稳定,不随勾选顺序变化。
230
+ * @param {string[]} tools
231
+ */
232
+ export function sortTools(tools) {
233
+ const set = new Set(tools);
234
+ return TOOL_NAMES.filter((t) => set.has(t));
235
+ }
236
+
90
237
  /**
91
238
  * 解析 "claude/skills" 形式的条目。非法即抛错——配置写错时要立刻说清楚,
92
239
  * 不能静默忽略,否则用户会以为已经生效。
@@ -134,11 +281,3 @@ export function plannedLinks(projectRoot, specs) {
134
281
  });
135
282
  }
136
283
 
137
- /**
138
- * 条目里出现过哪些工具,按 TOOL_NAMES 的固定顺序返回
139
- * @param {string[]} specs
140
- */
141
- export function toolsOfSpecs(specs) {
142
- const seen = new Set(specs.map((s) => parseSpec(s).tool));
143
- return TOOL_NAMES.filter((t) => seen.has(t));
144
- }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-syncer",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "把 .agents/ 下的 AI 资产(skills / rules / commands)分发到 Claude Code、Trae、Codex 等工具的配置目录",
5
5
  "type": "module",
6
6
  "bin": {
@@ -9,7 +9,8 @@
9
9
  "files": [
10
10
  "bin",
11
11
  "lib",
12
- "README.md"
12
+ "README.md",
13
+ "CONTENT-REPO.md"
13
14
  ],
14
15
  "engines": {
15
16
  "node": ">=20.11.0"