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/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.1",
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"