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/bin/agent-sync.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // @ts-check
3
3
  import path from 'node:path';
4
4
  import process from 'node:process';
5
- import { bold, dim, fail, plain, title } from '../lib/log.js';
5
+ import { bold, dim, fail, plain } from '../lib/log.js';
6
6
  import { TOOL_NAMES } from '../lib/target.js';
7
7
 
8
8
  const USAGE = `${bold('agent-syncer')} — 把 .agents/ 下的 AI 资产分发到各编码工具的配置目录
@@ -11,38 +11,170 @@ ${bold('用法')}
11
11
  agent-syncer <命令> [选项]
12
12
 
13
13
  ${bold('命令')}
14
- link 建好目录链接并维护 .gitignore;没有 agents.json 时会先让你勾选
15
- status 只读报告:每个链接当前是否健康,.agents/ 下各有多少内容
16
- doctor 只读自检:运行环境、链接能力、.gitignore、各工具是否存在
14
+ init 交互式生成 agents.json(问内容仓库、版本、模板、要适配的工具)
15
+ sync 从内容仓库挑一个模板装进 .agents/,然后建好链接(一条命令搞定)
16
+ link 只建目录链接并维护 .gitignore;没有 agents.json 时会先让你勾选
17
+ 带上 --src/--dst 则退化成「在这儿建一条链接」,不读任何项目配置
18
+ list 只读报告:内容仓库里有哪些条目和模板(自定义 include 之前先看这个)
19
+ status 只读报告:每个链接是否健康、.agents/ 下各有多少内容、装过什么、
20
+ 有没有「不再使用的链接 / 合并产物」
21
+ doctor 只读自检:运行环境、链接能力、内容、记录、.gitignore、行尾、
22
+ 合并产物、MCP 批没批准、各工具是否存在
17
23
 
18
24
  ${bold('选项')}
19
- --dry-run 只打印计划,不写盘(link 支持)
20
- --force 覆盖指向别处的链接(绝不删除实体目录)
21
- --yes 不询问,直接按已有目录推断(CI / postinstall 用)
22
- --no-save 交互式勾选后不写入 agents.json
23
- --cwd=<路径> 指定项目根,默认当前目录
24
- --help 显示本帮助
25
+ ${dim('一律写成 --名字=值。空格分隔不支持(--src ../x)——那样写会被当成位置参数,本工具会当场提醒。')}
26
+ --from=<来源> 内容仓库位置(本地路径或 git 地址),覆盖 agents.json 里的 content
27
+ --ref=<版本> 分支名 / 标签名 / 提交 SHA,覆盖 agents.json 里的 ref
28
+ --bundle=<名字> 装哪个模板,覆盖 agents.json 里的 bundle;多个用逗号分隔
29
+ list --bundle=<名字> 则展开这个模板,看它最终会装哪些条目
30
+ --kind=<类型> 只给 list 用:只看一类条目(单复数都行,如 skill / skills)
31
+ --src=<目录> 只给 link 用:只建一条链接,这是内容源;与 --dst 配对。
32
+ --dst=<路径> 两个一起给时**不读 agents.json**,就是「在这儿建一个链接」。
33
+ 相对路径按 --cwd 解析,绝对路径原样用。
34
+ ⚠️ 这样建的链接**不在托管范围内**:不写 .gitignore 托管段、
35
+ status 看不到它、--prune 也不清理它。落点在 git 仓库里时记得
36
+ 自己把落点写进 .gitignore——git 会穿透链接把源的内容再提交一份。
37
+ --dry-run 只打印计划,不写盘
38
+ --force 覆盖指向别处的链接(绝不删除实体目录)
39
+ --prune 清理不再需要的链接和内容(默认只报告不删,见「不再需要的东西」)
40
+ --yes 不询问(CI / postinstall 用)。它只是不弹提示,**不负责猜**:
41
+ links 没写在 agents.json 里时直接报错并告诉你怎么写
42
+ --no-save 交互式勾选后不写入 agents.json
43
+ --cwd=<路径> 指定项目根(--src/--dst 模式下是工作目录),默认当前目录
44
+ --help 显示本帮助
25
45
 
26
46
  ${bold('内容约定')}
27
47
  所有内容只在 ${bold('.agents/')} 下保存一份:
28
- .agents/skills/<名字>/SKILL.md
29
- .agents/rules/<名字>.md
30
- .agents/commands/<名字>.md
31
- .agents/agents/<名字>.md
32
- 再由本工具在各工具目录建链接指过来,因此永远只有一份真源。
48
+ .agents/skills/<名字>/SKILL.md 链接给各工具
49
+ .agents/rules/<名字>.md 链接给各工具
50
+ .agents/commands/<名字>.md 链接给各工具
51
+ .agents/agents/<名字>.md 链接给各工具
52
+ .agents/hooks/<名字>.json 合并进 .claude/settings.json 的 hooks 段
53
+ .agents/mcp/<名字>.json 合并进 .mcp.json / .trae/mcp.json
54
+ .agents/scripts/ 被 hooks / mcp 按路径引用(选了它们才同步)
55
+ .agents/.agent-sync.json 本工具写的:记着哪些内容是它装的(要提交)
56
+ 前四类在各工具目录建**链接**指过来,因此永远只有一份真源;
57
+ 后两类没法链接(那些文件里还有你自己的东西),只能读—改—写地${bold('合并')}进去,
58
+ 本工具只动自己写过的那些条目。
59
+
60
+ ${bold('合并产物要提交进版本库')}:.mcp.json 和 .claude/settings.json 都是团队共享的
61
+ 配置,密钥一律写成 ${'${环境变量}'} 占位符,别写死。
62
+
63
+ ${bold('MCP 写进去了不等于生效')}:Claude Code 要你逐条批准才会连。批准记录
64
+ 存在你本机的 ~/.claude.json 里,${bold('不随仓库共享')}——队友要各自批一次。
65
+
66
+ ${bold('项目根怎么写')}:内容里引用项目内脚本时,写 ${'${CLAUDE_PROJECT_DIR}'}
67
+ 还是 ${'${workspaceFolder}'} 都认,合并时按目标工具翻译成它自己的写法。
68
+ 别的 ${'${VAR}'} 原样保留,由工具自己在运行时展开。
69
+
70
+ ${bold('第一次用:init')}
71
+ 没有 agents.json 的项目,跑 agent-syncer init 会依次问四件事:
72
+
73
+ 1. 内容仓库在哪(本地路径 或 git 地址)
74
+ 2. 用哪个版本(分支 / 标签)。**本地路径不问**——sync 读的是那个目录的工作树,
75
+ ref 不生效,让你选一个不生效的版本比不让你选更糟。不是 git 仓库、离线
76
+ 或要凭据时也跳过。要钉版本请把内容仓库写成 git 地址(file:///… 也算)
77
+ 3. 装哪些模板(可以一个都不选,之后用 include 逐条挑)
78
+ 4. 适配哪些编码工具(写进 "links")
79
+
80
+ 然后把将要写入的整份配置打印出来让你确认,${bold('确认过才落盘')},
81
+ 最后问一句要不要立刻 sync。中途 Ctrl-C 退出,一个文件都不会写。
82
+
83
+ ${bold('在终端里直接跑 sync 也一样')}:没有 agents.json 时会先带你走一遍 init。
84
+ --from / --ref / --bundle 传进来的值会当作对应问题的${bold('预填值')},不必重答。
85
+ 非交互环境(管道、CI、postinstall)和 --yes ${bold('都不会')}弹提示,还是照旧
86
+ 报错并告诉你配置怎么写——在那些地方弹提示会永久挂住。
87
+
88
+ ${bold('项目配置 agents.json')}(init 生成,也可以手写;放项目根)
89
+ {
90
+ "content": "<内容仓库:本地路径或 git 地址>",
91
+ "ref": "<分支 / 标签 / 提交 SHA,可省略>",
92
+ "bundle": "java-backend",
93
+ "include": ["skill:extra"],
94
+ "exclude": ["hook:dept-hooks"],
95
+ "protect": ["skill:my-customized"],
96
+ "links": ["claude", "trae"]
97
+ }
98
+ 注意 links 里写的是${bold('工具名')},不是目录。选用一个工具就是它的
99
+ 全部类型一起适配——没有「只要 .claude/rules 不要 .claude/skills」这种场景,
100
+ 所以配置里不提供目录级开关。
101
+
102
+ ${bold('links 没写会怎样')}:link 不猜。在终端里跑它会列出来让你勾选;
103
+ 非交互环境(管道、CI、--yes)则报错,并把可选项和写法一起列出来。
104
+ 「这个项目用 Trae」是个持久事实,从「目录恰好存在」推出来的东西
105
+ 用户既没同意过、也看不见,猜错还会给一个其实不用的工具建链接。
106
+
107
+ ${bold('挑内容:模板 + 项目级增删')}
108
+ bundle 可以是一个模板,也可以是数组(多个模板的结果取并集):
109
+
110
+ "bundle": ["common", "frontend"]
111
+
112
+ 一个模板都不写也行,只用 include 逐条挑,这就是"自定义"用法。
113
+ 项目级的 include / exclude 叠在模板之上,exclude ${bold('最后过一遍')}:
114
+
115
+ 模板并集 → +include → −exclude
116
+
117
+ 所以 exclude 能砍掉上面任何一步加进来的东西,包括 include 刚加的。
118
+ 多个模板之间互不干扰:各自解析完再取并集,A 模板的 exclude 不会
119
+ 影响 B 模板——否则结果会取决于组合顺序,没法推理。模板里再套
120
+ 模板也一样("bundle": ["a", "b"] 写进 bundles/*.json 后用同一条规则)。
121
+
122
+ include / exclude 只认这两种写法:
123
+ "skill:code-style" 单个条目
124
+ "skill:*" 该类型全部
125
+
126
+ 引用别的模板一律用 bundle 字段,**模板文件里也一样**:
127
+ "bundle": ["common"] 项目根和 bundles/*.json 用的是同一个字段
128
+
129
+ 早期那种写进 include 的 "@common" 已经移除,写了会当场报错并给出新写法。
130
+ 代价是 exclude 排不掉一整个模板了(以前 exclude: ["@common"] 可以),
131
+ 只能逐条列、或者把那个模板拆小。
132
+
133
+ ${bold('不再需要的东西')}
134
+ 换工具会留下链接和合并产物,换模板(或内容仓库改版)会留下内容。
135
+ 它们都不会自动消失,而且不清理是有实际后果的:残留的链接已不在 .gitignore
136
+ 托管段里,git 会顺着它们把 .agents/ 的内容再提交一份。
137
+
138
+ 三者都默认只报告,确认后加 --prune:
139
+
140
+ agent-syncer sync --prune # 内容、链接、合并产物一起清
141
+ agent-syncer link --prune # 只清链接
142
+
143
+ 界限划得很死:链接只删链接;内容只删「记录里说本工具装过的」;
144
+ 合并产物只摘掉「记录说本工具写进去的」。**你自己写的一律不碰。**
145
+ 删空之后剩下的空目录会顺手收掉,**但一个非空的目录都删不掉**
146
+ (rmdir 只对空目录成功,这是内核挡的,不是靠判断)。
147
+
148
+ 合并那一边还有个更硬的规矩:**归属是被验证的,不是被断言的。**
149
+ 记录说某条是本工具写的还不够,还要现场确实还是当初写的那份才动——
150
+ 你改过它,本工具就保留你的改动并报一声,不会覆盖回去。
151
+
152
+ 除了上面那些,「装过的条目里哪些你改过」本工具是认不出的——想保住就自己声明:
153
+
154
+ "protect": ["skill:my-customized", "script:tools/legacy.sh", "mcp:dept-wiki"]
155
+
156
+ 锁 = 本工具整个不碰它:sync 不覆盖、--prune 不删、也不合并进工具配置。
157
+ 写法与 --prune 输出里的条目名一致,不支持通配,写错会直接报错。
33
158
 
34
159
  ${bold('可用工具')}
35
160
  ${TOOL_NAMES.join('、')}
36
161
 
37
162
  ${bold('示例')}
163
+ agent-syncer init # 没有配置时跑这个,一路问下来就有 agents.json 了
164
+ agent-syncer init --from=../dept-content # 把已知的值预填进问题里
165
+ agent-syncer sync --from=http://git.example.com/team/dept-content.git --ref=v2.0.0 --bundle=java-backend
166
+ agent-syncer sync --from=../dept-content --bundle=java-backend
167
+ agent-syncer sync --bundle=frontend --dry-run
38
168
  agent-syncer status
169
+ agent-syncer list --kind=skill # 有哪些技能可以挑
170
+ agent-syncer list --bundle=java-backend # 这个模板最终装哪些条目
39
171
  agent-syncer link # 没有配置时会让你勾选要建立哪些链接
40
- agent-syncer link --dry-run
41
- agent-syncer link --force
42
172
  agent-syncer link --yes # 不询问,适合脚本
173
+ agent-syncer link --src=../shared/prompts --dst=.claude/skills # 只建这一条,不读配置
174
+ agent-syncer sync --prune # 清掉不再需要的内容和链接
43
175
  `;
44
176
 
45
- /** 手写参数解析,与 iqcs_web/scripts 下的既有风格一致(不引第三方依赖) */
177
+ /** 手写参数解析——本工具零依赖,不引第三方命令行库 */
46
178
  function parseArgs(argv) {
47
179
  /** @type {Record<string, string|boolean> & {_: string[]}} */
48
180
  const flags = { _: [] };
@@ -59,8 +191,45 @@ function parseArgs(argv) {
59
191
  return flags;
60
192
  }
61
193
 
194
+ /**
195
+ * 取值型选项:写这几个必须用 `--名字=值`。
196
+ *
197
+ * 写成空格分隔(`--src shared/prompts`)时,手写解析器会把 `shared/prompts`
198
+ * 收进位置参数、把 `--src` 置成 `true`,于是**报出一句和事实相反的话**——
199
+ * `link --src shared/prompts` 得到的是「--src 和 --dst 要一起给(少了 --src)」
200
+ * (实测过)。人明明写了 src,被告知没写,只会去怀疑自己敲错了命令名。
201
+ */
202
+ const VALUE_FLAGS = ['from', 'ref', 'bundle', 'kind', 'src', 'dst', 'cwd'];
203
+
204
+ /**
205
+ * 把「空格分隔」这件事当场说清楚,而不是放宽语法。
206
+ *
207
+ * 支持空格分隔就得回答「后面那个位置参数到底是不是这个选项的值」,而本工具的
208
+ * 命令行里命令名、路径、选项混在一起,靠猜只会引入更难查的错。所以只认
209
+ * `--名字=值`,但**必须在用户写错时立刻指出来**——静默变成 `true` 是最坏的一种。
210
+ *
211
+ * @param {Record<string, any>} args
212
+ */
213
+ function checkValueFlags(args) {
214
+ const bad = VALUE_FLAGS.filter((k) => args[k] === true);
215
+ if (bad.length === 0) return true;
216
+
217
+ const k = bad[0];
218
+ fail(`--${k} 后面要用等号,写成 --${k}=<值>`);
219
+ plain(
220
+ ` 本工具只认 ${bold('--名字=值')} 这一种写法。你敲的 \`--${k}\` 后面那个词被当成了位置参数——` +
221
+ `等于没给 ${dim(`--${k}`)}。`,
222
+ );
223
+ plain(dim(' 例如: agent-syncer link --src=../shared/prompts --dst=.claude/skills'));
224
+ plain(dim(' 值本身带空格的话,整个包在引号里:--bundle="a b"'));
225
+ return false;
226
+ }
227
+
62
228
  const COMMANDS = /** @type {const} */ ({
229
+ init: '../lib/commands/init.js',
230
+ sync: '../lib/commands/sync.js',
63
231
  link: '../lib/commands/link.js',
232
+ list: '../lib/commands/list.js',
64
233
  status: '../lib/commands/status.js',
65
234
  doctor: '../lib/commands/doctor.js',
66
235
  });
@@ -82,6 +251,9 @@ async function main() {
82
251
  return 0;
83
252
  }
84
253
 
254
+ // 放在 --help 之后:写明「怎么看帮助」的人应当先看到帮助。
255
+ if (!checkValueFlags(args)) return 1;
256
+
85
257
  const loader = /** @type {Record<string, string>} */ (COMMANDS)[cmd];
86
258
  if (!loader) {
87
259
  fail(`未知命令:${cmd}`);
@@ -1,19 +1,115 @@
1
1
  // @ts-check
2
+ import { spawnSync } from 'node:child_process';
2
3
  import fs from 'node:fs';
4
+ import os from 'node:os';
3
5
  import path from 'node:path';
4
6
  import process from 'node:process';
5
7
  import { CONFIG_FILENAME, loadConfig } from '../config.js';
6
8
  import { checkBlock } from '../gitignore.js';
7
- import { probeLinkCapability } from '../link.js';
9
+ import { probeLinkCapability, samePath } from '../link.js';
8
10
  import { dim, fail, ok, plain, skip, title, warn } from '../log.js';
9
- import { CONTENT_ROOT, TOOLS, TOOL_NAMES } from '../target.js';
11
+ import { checkMergeView } from '../merge.js';
12
+ import { RECORD_REL, readRecord, unparsableKeys } from '../record.js';
13
+ import { CONTENT_ROOT, TOOLS, TOOL_NAMES, mergeKindsOf, mergeTarget, toolDir } from '../target.js';
10
14
 
11
- /** @type {string[]} */
12
- const PACKAGE_TOOL_HINTS = {
13
- claude: '.claude',
14
- trae: '.trae',
15
- codex: '.codex',
16
- };
15
+ /**
16
+ * `.gitattributes` 里有没有一条「统一按文本处理」的规则。
17
+ *
18
+ * 只认 `text` 和 `text=auto` 这两种写法,**不认 `-text`**——那是「当成二进制,
19
+ * 别动它」,方向正好相反。`*.png binary` 这类也不会误伤,`binary` 是
20
+ * `-text -diff` 的别名,字面上没有 `text` 这个属性。
21
+ *
22
+ * @param {string} content
23
+ */
24
+ function hasTextAuto(content) {
25
+ for (const line of content.split(/\r?\n/)) {
26
+ const s = line.trim();
27
+ if (s === '' || s.startsWith('#')) continue;
28
+ // 第一列是路径匹配式,后面才是属性
29
+ for (const attr of s.split(/\s+/).slice(1)) {
30
+ if (attr === 'text' || attr === 'text=auto') return true;
31
+ }
32
+ }
33
+ return false;
34
+ }
35
+
36
+ /**
37
+ * Claude Code 批没批准项目里的这几位 MCP server。
38
+ *
39
+ * 为什么这一条最值钱:合并成功之后最可能的失败**不是**「文件没写对」,而是
40
+ * 「文件写对了、一个人都没生效」。写 `.mcp.json` 只是第一步——Claude Code
41
+ * 要你逐条批准才会连,而批准记录存在**你自己机器**上的 `~/.claude.json` 里,
42
+ * 不随仓库共享。所以这一节回答的是「生效了吗」,而「文件在不在」回答不了。
43
+ *
44
+ * 只读、只看当前项目那一格、不打印文件内容。字段名没有权威依据,探测不到
45
+ * 就如实说「无法判断」,**绝不猜**。
46
+ *
47
+ * @param {string} cwd @param {string[]} names 我们合进去的 server 名
48
+ * @returns {{state: 'ok'|'pending'|'never'|'unknown', detail: string}}
49
+ */
50
+ function claudeApproval(cwd, names) {
51
+ if (names.length === 0) return { state: 'ok', detail: '' };
52
+
53
+ /** @type {any} */
54
+ let raw;
55
+ try {
56
+ raw = JSON.parse(fs.readFileSync(path.resolve(os.homedir(), '.claude.json'), 'utf8'));
57
+ } catch {
58
+ return { state: 'unknown', detail: '读不到 ~/.claude.json(没装 Claude Code,或还没跑过)' };
59
+ }
60
+
61
+ const projects = raw?.projects;
62
+ if (projects === null || typeof projects !== 'object') {
63
+ return { state: 'unknown', detail: '~/.claude.json 的结构和预期不一样,无法判断' };
64
+ }
65
+
66
+ // 项目的键是绝对路径;Windows 上大小写和斜杠都可能和这里的不一样
67
+ const key = Object.keys(projects).find((k) => samePath(path.resolve(k), cwd));
68
+ if (key === undefined) {
69
+ return { state: 'never', detail: 'Claude Code 还没在这个项目里跑过' };
70
+ }
71
+
72
+ const entry = projects[key] ?? {};
73
+ const enabled = Array.isArray(entry.enabledMcpjsonServers) ? entry.enabledMcpjsonServers : [];
74
+ const missing = names.filter((n) => !enabled.includes(n));
75
+ if (missing.length === 0) return { state: 'ok', detail: '' };
76
+
77
+ return { state: 'pending', detail: `还有 ${missing.length} 条没批准:${missing.join('、')}` };
78
+ }
79
+
80
+ /**
81
+ * 一个文件在 git 眼里是什么状态。
82
+ *
83
+ * **先看有没有被跟踪**:`.gitignore` 对已跟踪的文件完全无效,只看
84
+ * `check-ignore` 会把「早就提交过了」误报成「被忽略了」。
85
+ *
86
+ * 不是 git 仓库、或者没有 git,就什么都不说(返回 `skipped`)。
87
+ *
88
+ * @param {string} cwd @param {string} rel
89
+ * @returns {{state: 'tracked'|'untracked'|'ignored'|'skipped', rule?: string}}
90
+ */
91
+ function gitStatusOf(cwd, rel) {
92
+ /** @param {string[]} args */
93
+ const run = (args) =>
94
+ spawnSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
95
+
96
+ if (run(['rev-parse', '--is-inside-work-tree']).status !== 0) return { state: 'skipped' };
97
+ if (run(['ls-files', '--error-unmatch', '--', rel]).status === 0) return { state: 'tracked' };
98
+
99
+ const ignored = run(['check-ignore', '-v', '--', rel]);
100
+ if (ignored.status === 0) return { state: 'ignored', rule: (ignored.stdout ?? '').trim() };
101
+
102
+ return { state: 'untracked' };
103
+ }
104
+
105
+ /**
106
+ * Windows 上 hook 靠 `sh` 执行(由 Git Bash 提供)。它不在 PATH 里的话,
107
+ * hook 会**静默不跑**——这是「hook 配了没反应」最常见的真实原因之一。
108
+ * 检查成本极低,而且不碰任何文件。
109
+ */
110
+ function hasSh() {
111
+ return spawnSync('sh', ['-c', 'exit 0'], { stdio: 'ignore' }).status === 0;
112
+ }
17
113
 
18
114
  /**
19
115
  * agent-syncer doctor —— 纯只读的环境自检。
@@ -67,23 +163,53 @@ export async function run({ cwd }) {
67
163
  warned += 1;
68
164
  }
69
165
 
70
- // ---- 4. 项目配置 ----
166
+ // ---- 4. 记录(哪些内容是本工具装的)----
167
+ //
168
+ // 这份文件坏了**不报错**,但会让清理功能静默失效:sync 认不出哪些内容是自己
169
+ // 装的,`--prune` 于是什么都不删;合并产物的归属(merged)也跟着丢。症状只会
170
+ // 以「怎么清不掉」的形式很晚才浮出来——而 doctor 的职责正是提前把话说清楚。
171
+ title('记录');
172
+ const record = readRecord(cwd);
173
+ if (record.usable) {
174
+ ok(`${RECORD_REL} 可读`);
175
+ // 解析不了的条目映射不到路径,清理会少管它们——说一声,别让它变成一份
176
+ // 「看起来完整、实际漏了几条」的记录
177
+ const broken = unparsableKeys(record);
178
+ if (broken.length > 0) {
179
+ warn(`记录里有 ${broken.length} 项名字解析不了:${broken.join('、')}`);
180
+ plain(dim(' 它们不会被清理,也不会被覆盖——多半是手改记录时改坏的。'));
181
+ warned += 1;
182
+ }
183
+ } else if (record.reason) {
184
+ warn(record.reason);
185
+ plain(dim(' 后果:清理功能会失效(认不出哪些内容是本工具装的)。'));
186
+ plain(dim(' sync 会先把它备份成 .bak,再重建一份新的。'));
187
+ warned += 1;
188
+ } else {
189
+ skip(`还没有 ${RECORD_REL}`);
190
+ plain(dim(' 下次 sync 会写下它,之后就能认出哪些内容是本工具装的了。'));
191
+ }
192
+
193
+ // ---- 5. 项目配置 ----
71
194
  title('项目配置');
72
195
  const config = loadConfig(cwd);
73
196
  if (config.exists) {
74
197
  ok(`${CONFIG_FILENAME} 存在`);
75
- if (config.links.length > 0) plain(dim(` links:${config.links.join('、')}`));
76
- else warn('links 为空');
198
+ if (config.toolsDeclared) plain(dim(` links:${config.tools.join('、') || '(空)'}`));
199
+ else {
200
+ warn(`没有写 links——link 会问你要用哪些工具,本工具不替你猜`);
201
+ plain(dim(` 可选:${TOOL_NAMES.join('、')}`));
202
+ warned += 1;
203
+ }
77
204
  } else {
78
- skip(`${CONFIG_FILENAME} 不存在(link 会交互式询问,或按已有目录推断)`);
79
- if (config.links.length > 0) plain(dim(` 推断结果:${config.links.join('、')}`));
80
- else plain(dim(' 没有可推断的项——需要交互式勾选'));
205
+ skip(`${CONFIG_FILENAME} 不存在`);
206
+ plain(dim(` link 会问你要用哪些工具(${TOOL_NAMES.join('、')}),或写进 ${CONFIG_FILENAME}`));
81
207
  }
82
208
  for (const w of config.warnings) warn(w);
83
209
 
84
- // ---- 5. .gitignore ----
210
+ // ---- 6. .gitignore ----
85
211
  title('.gitignore');
86
- const gi = checkBlock(cwd, config.links);
212
+ const gi = checkBlock(cwd, config.tools);
87
213
  if (!gi.present) {
88
214
  warn('未找到 agent-syncer 托管段,运行 link 会写入');
89
215
  warned += 1;
@@ -91,6 +217,11 @@ export async function run({ cwd }) {
91
217
  warn(`托管段缺少 ${gi.missing.length} 个条目,运行 link 会补齐`);
92
218
  for (const m of gi.missing) plain(dim(` · ${m}`));
93
219
  warned += 1;
220
+ } else if (gi.extra.length > 0) {
221
+ // 托管段是整段重建的,这几行会在下一次 link 时无声消失
222
+ warn(`托管段里有 ${gi.extra.length} 行不是本工具生成的,运行 link 会被清掉`);
223
+ for (const e of gi.extra) plain(dim(` · ${e}`));
224
+ warned += 1;
94
225
  } else {
95
226
  ok('托管段完整');
96
227
  }
@@ -101,20 +232,168 @@ export async function run({ cwd }) {
101
232
  ),
102
233
  );
103
234
 
104
- // ---- 6. 各工具是否存在 ----
235
+ // ---- 7. .gitattributes ----
236
+ //
237
+ // 为什么要在意:`sync` 判断「内容变没变」是按字节比的,于是检出成 CRLF 的那份
238
+ // 会被当成「变了」。危害不大(多报一次「更新」,第二遍自愈),但每次克隆后
239
+ // 第一次 sync 都会莫名报一堆「更新」,而且会不会顺带弄脏 git 完全取决于
240
+ // **各人本机的 core.autocrlf**——这不该是个因人而异的变量。
241
+ //
242
+ // **只提示,绝不代写。** `* text=auto` 改的是整个仓库所有文件的检出行为,
243
+ // 是仓库级的决定,得由人来做。
244
+ title('.gitattributes');
245
+ const gaPath = path.resolve(cwd, '.gitattributes');
246
+ if (!fs.existsSync(gaPath)) {
247
+ warn('未找到——行尾行为会取决于各人本机的 core.autocrlf,不同机器结果不一样');
248
+ plain(dim(' 建议在项目根加一份,内容:'));
249
+ plain(dim(' * text=auto'));
250
+ plain(dim(' 否则每次克隆后第一次 sync 会多报一遍「更新」(第二遍自愈);'));
251
+ plain(dim(' 而 core.autocrlf=false 的项目里,行尾改动作会被当成真改动提交进去。'));
252
+ warned += 1;
253
+ } else if (!hasTextAuto(fs.readFileSync(gaPath, 'utf8'))) {
254
+ warn('存在,但没有 text / text=auto 规则');
255
+ plain(dim(' 行尾行为仍然取决于各人本机的 core.autocrlf,建议补一行:* text=auto'));
256
+ warned += 1;
257
+ } else {
258
+ ok('已声明 text=auto,行尾行为不再取决于各人本机配置');
259
+ }
260
+
261
+ // ---- 8. 合并产物(hooks / mcp)----
262
+ //
263
+ // 这一节查的**不是「我写了吗」,是「生效了吗」**。写进 `.mcp.json` 只是第一步,
264
+ // 后面还有批准、还有「它进不进得了版本库」两道关。
265
+ title('合并产物(hooks / mcp)');
266
+
267
+ // 判断整个交给 merge.js 的 checkMergeView——status 用的是同一份。两个只读命令
268
+ // 对同一份现场给出相反结论,是以前真出过的事(见 checkMergeAll 的注释)。
269
+ const view = checkMergeView({ projectRoot: cwd, config, record });
270
+
271
+ // 「目录读不出来」和「目录是空的」是两件事。前者我们根本不知道里面有什么,
272
+ // 打成「没有 hooks / mcp 内容,不需要合并」就是替它说一句我们没资格说的话。
273
+ for (const d of view.unreadable) {
274
+ warn(`读不了 ${CONTENT_ROOT}/${d}/——里面有什么、能不能合,判断不了`);
275
+ warned += 1;
276
+ }
277
+
278
+ if (!view.hasWork) {
279
+ // 空项目不该因为「`.mcp.json` 不存在」就吃一笔警告,否则小结没人看
280
+ skip('没有 hooks / mcp 内容,不需要合并');
281
+ } else {
282
+ for (const r of view.rows) {
283
+ const label = `${TOOLS[r.tool].label} · ${r.rel}`.padEnd(28);
284
+ if (r.state === 'ok') {
285
+ ok(`${label} ${dim('和内容一致')}`);
286
+ } else if (r.state === 'missing') {
287
+ warn(`${label} 还没生成——跑一次 sync`);
288
+ warned += 1;
289
+ } else if (r.state === 'drift') {
290
+ warn(`${label} 和 ${CONTENT_ROOT}/ 里的内容对不上——跑一次 sync`);
291
+ warned += 1;
292
+ } else if (r.state === 'unreadable') {
293
+ warn(`${label} ${r.detail}`);
294
+ warned += 1;
295
+ } else if (r.state === 'stale') {
296
+ // 「默认不摘」是设计,说清楚有这么几条躺着就行。
297
+ // 措辞留余地:里面可能混着你改过的,那几条按设计会被保留。
298
+ warn(
299
+ `${label} 有 ${r.ids.length} 条不再需要的:${r.ids.join('、')}` +
300
+ '(加 --prune 可摘,你改过的会保留)',
301
+ );
302
+ warned += 1;
303
+ } else {
304
+ // 冲突 = 这里有你自己的东西,本工具不碰。说清楚就行,不是故障。
305
+ warn(`${label} 有本工具没动的地方`);
306
+ plain(dim(` ${r.detail}`));
307
+ }
308
+ }
309
+
310
+ // 声明的工具里有没有承接不了这类内容的(codex 的 mcp 就是)
311
+ for (const u of view.unsupported) {
312
+ warn(`${TOOLS[u.tool].label} 没有 ${u.dir} 的合并目标——这 ${u.count} 条装了不会生效`);
313
+ warned += 1;
314
+ }
315
+
316
+ // 没实证的那条路不能报得跟 Claude 那条一样肯定
317
+ for (const u of view.unverified) {
318
+ plain(dim(` ⚠️ ${TOOLS[u.tool].label} 的 ${u.rel} 未经实证(本机没装),可能不生效`));
319
+ }
320
+
321
+ // ---- 写进去了,生效了吗 ----
322
+ if (config.tools.includes('claude') && (view.present.mcp?.length ?? 0) > 0) {
323
+ const ap = claudeApproval(cwd, view.present.mcp ?? []);
324
+ if (ap.state === 'ok') {
325
+ ok('Claude Code 已批准项目里的 MCP server');
326
+ } else if (ap.state === 'pending') {
327
+ warn(`Claude Code 还没批准这些 MCP server`);
328
+ plain(dim(` ${ap.detail}`));
329
+ plain(dim(' 在会话里用 /mcp 批准。这是逐人逐项目的——批准记录在你本机的'));
330
+ plain(dim(' ~/.claude.json 里,不随仓库共享,队友要各自批一次。'));
331
+ warned += 1;
332
+ } else if (ap.state === 'never') {
333
+ skip(`Claude Code 的批准状态未知:${ap.detail}`);
334
+ } else {
335
+ skip(`Claude Code 的批准状态无法判断:${ap.detail}`);
336
+ }
337
+ }
338
+
339
+ // ---- 进得了版本库吗 ----
340
+ //
341
+ // 和链接相反:合并产物**必须**提交(它们是团队共享的配置,正是 MCP
342
+ // 「配一次全组可用」的前提)。但用户自己的 .gitignore 里常有 .mcp.json。
343
+ // `.trae/mcp.json` 更绕——它在 .trae/ 里面,整个忽略 .trae/ 的话
344
+ // `!.trae/mcp.json` 是**没用**的(git 不允许重新包含被排除目录下的文件)。
345
+ // 所以这里只查、只报,**不往托管段里塞白名单**:一条时灵时不灵、失灵时
346
+ // 还没有任何信号的规则,比没有更糟。
347
+ /** @type {string[]} */
348
+ const ignored = [];
349
+ let checked = 0;
350
+ for (const tool of config.tools) {
351
+ for (const dir of mergeKindsOf(tool)) {
352
+ const rel = mergeTarget(tool, dir).rel;
353
+ if (!fs.existsSync(path.resolve(cwd, rel))) continue;
354
+
355
+ const g = gitStatusOf(cwd, rel);
356
+ if (g.state === 'skipped') continue;
357
+ checked += 1;
358
+ if (g.state === 'ignored') {
359
+ if (g.rule) ignored.push(`${rel}(命中 ${g.rule})`);
360
+ else ignored.push(rel);
361
+ }
362
+ }
363
+ }
364
+ if (ignored.length > 0) {
365
+ warn(`${ignored.length} 个合并产物被 .gitignore 忽略了——它们应该提交进版本库`);
366
+ for (const i of ignored) plain(dim(` · ${i}`));
367
+ plain(dim(' .gitignore 对已跟踪的文件无效;要让队友拿到这份配置就得放行。'));
368
+ warned += 1;
369
+ } else if (checked > 0) {
370
+ ok('合并产物都在版本库的范围内');
371
+ }
372
+
373
+ // ---- hook 在 Windows 上靠 sh ----
374
+ if ((view.present.hooks?.length ?? 0) > 0 && process.platform === 'win32') {
375
+ if (hasSh()) ok('sh 可用(Windows 上 hook 靠它执行)');
376
+ else {
377
+ warn('PATH 里找不到 sh —— hook 会静默不跑');
378
+ plain(dim(' 装 Git for Windows,并确保 Git Bash 的 bin 目录在 PATH 里。'));
379
+ warned += 1;
380
+ }
381
+ }
382
+ }
383
+
384
+ // ---- 9. 各工具是否存在 ----
105
385
  title('工具');
106
386
  for (const name of TOOL_NAMES) {
107
- const dir = path.resolve(cwd, /** @type {Record<string,string>} */ (PACKAGE_TOOL_HINTS)[name]);
387
+ const dir = path.resolve(cwd, toolDir(name));
108
388
  const onDisk = fs.existsSync(dir);
109
389
  const declared = config.tools.includes(name);
110
390
  const kinds = Object.keys(TOOLS[name].links).join('/');
111
391
  if (onDisk && declared) ok(`${TOOLS[name].label.padEnd(12)} 目录存在,已声明 ${dim(kinds)}`);
112
- else if (onDisk && !declared) warn(`${TOOLS[name].label.padEnd(12)} 目录存在,但未在 tools 里声明`);
392
+ else if (onDisk && !declared) warn(`${TOOLS[name].label.padEnd(12)} 目录存在,但未在 links 里声明`);
113
393
  else if (!onDisk && declared) {
114
- const how = config.inferred ? '默认使用' : '已声明';
115
- warn(`${TOOLS[name].label.padEnd(12)} ${how},但项目里没有 ${'.' + name} 目录`);
116
- }
117
- else skip(`${TOOLS[name].label.padEnd(12)} 未使用`);
394
+ // 声明了但目录不在是正常的(link 会新建),所以只是提示不是问题
395
+ warn(`${TOOLS[name].label.padEnd(12)} 已声明,但项目里没有 ${toolDir(name)} 目录`);
396
+ } else skip(`${TOOLS[name].label.padEnd(12)} 未使用`);
118
397
  }
119
398
 
120
399
  // ---- 小结 ----