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,214 @@
1
+ // @ts-check
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { CONFIG_FILENAME, loadConfig } from '../config.js';
5
+ import { dim, fail, info, ok, plain, skip, title, warn } from '../log.js';
6
+ import {
7
+ ITEM_KINDS,
8
+ ITEM_KIND_NAMES,
9
+ SUPPORT_DIR,
10
+ listBundles,
11
+ listItems,
12
+ loadRepo,
13
+ resolveSelection,
14
+ } from '../manifest.js';
15
+ import { fetchRepo, isGitUrl, localRefWarning } from '../source.js';
16
+ import { TOOLS, mergeTarget } from '../target.js';
17
+
18
+ /**
19
+ * `--kind` 的写法归一成单数类型名。单复数都认——用户平时看的是
20
+ * `.agents/skills/`(复数目录名),而 include 里写的是 `skill:xxx`(单数),
21
+ * 两套词都得能用,不然就得先查文档才知道该写哪个。
22
+ *
23
+ * @param {string} value
24
+ * @returns {string|null} 认不出来返回 null
25
+ */
26
+ function normalizeKind(value) {
27
+ const raw = value.trim().toLowerCase();
28
+ for (const kind of ITEM_KIND_NAMES) {
29
+ if (raw === kind || raw === ITEM_KINDS[kind].dir) return kind;
30
+ }
31
+ return null;
32
+ }
33
+
34
+ /**
35
+ * agent-syncer list —— 列出内容仓库里有哪些**条目**和**模板**。
36
+ *
37
+ * 存在的理由很具体:想用 `include` 逐条挑内容("自定义"用法),得先知道 id 叫什么,
38
+ * 而在此之前只能去翻内容仓库的文件。`sync` 不带 `--bundle` 时只列模板名,
39
+ * 不列条目。
40
+ *
41
+ * 纯只读,但**不是离线的**——它读的是内容仓库而不是项目里的 `.agents/`,
42
+ * 因为要回答的问题正是「仓库里有什么可以挑」。内容仓库是 git 地址时先浅克隆。
43
+ *
44
+ * @param {{cwd: string, flags: Record<string, any>}} ctx
45
+ */
46
+ export async function run({ cwd, flags }) {
47
+ title('agent-syncer list');
48
+ plain(dim(`项目根:${cwd}`));
49
+
50
+ const config = loadConfig(cwd);
51
+ const sourceArg = typeof flags.from === 'string' ? flags.from : config.content;
52
+ if (!sourceArg) {
53
+ fail('没有指定内容仓库');
54
+ plain(
55
+ ` 在 ${CONFIG_FILENAME} 里写 ${dim('"content": "<本地路径 或 git 地址>"')},` +
56
+ `或用 ${dim('--from=<路径>')}。`,
57
+ );
58
+ return 1;
59
+ }
60
+
61
+ const kindFlag = typeof flags.kind === 'string' ? flags.kind : null;
62
+ /** @type {string|null} */
63
+ let onlyKind = null;
64
+ if (kindFlag !== null) {
65
+ onlyKind = normalizeKind(kindFlag);
66
+ if (onlyKind === null) {
67
+ fail(`--kind 不认识 "${kindFlag}"`);
68
+ plain(dim(` 可用:${ITEM_KIND_NAMES.join('、')}(复数写法也行,如 skills)`));
69
+ // scripts 不是条目类型,是整目录同步的支撑目录——明说一句,
70
+ // 否则用户会以为是自己拼错了
71
+ plain(dim(` 注意 ${SUPPORT_DIR}/ 不是条目类型:它整目录同步,不参与 include / exclude。`));
72
+ return 1;
73
+ }
74
+ }
75
+
76
+ const ref = typeof flags.ref === 'string' ? flags.ref : config.ref;
77
+ const remote = isGitUrl(sourceArg);
78
+
79
+ // 和 sync 同一句话:ref 只对 git 地址生效,本地路径读的是那个目录的工作树。
80
+ // 两条命令对同一份配置给出不同的说法,比不说更让人糊涂。
81
+ const refWarning = localRefWarning(sourceArg, ref);
82
+ if (refWarning) warn(refWarning);
83
+ /** @type {(() => void)|null} */
84
+ let cleanup = null;
85
+
86
+ try {
87
+ /** @type {string} */
88
+ let repoRoot;
89
+ if (remote) {
90
+ info(`拉取 ${sourceArg}${ref ? ` @ ${ref}` : ''} …`);
91
+ try {
92
+ const fetched = fetchRepo({ url: sourceArg, ref });
93
+ repoRoot = fetched.root;
94
+ cleanup = fetched.cleanup;
95
+ } catch (e) {
96
+ fail(/** @type {Error} */ (e).message);
97
+ return 1;
98
+ }
99
+ } else {
100
+ repoRoot = path.resolve(cwd, sourceArg);
101
+ }
102
+
103
+ /** @type {{name: string, version?: string}} */
104
+ let repo;
105
+ try {
106
+ repo = loadRepo(repoRoot);
107
+ } catch (e) {
108
+ fail(/** @type {Error} */ (e).message);
109
+ return 1;
110
+ }
111
+ ok(
112
+ `内容仓库:${repo.name}${repo.version ? ` v${repo.version}` : ''} ` +
113
+ dim(remote ? `${sourceArg}${ref ? ` @ ${ref}` : ''}` : repoRoot),
114
+ );
115
+
116
+ // ---- 条目 ----
117
+ const items = listItems(repoRoot);
118
+ title(onlyKind === null ? '条目' : `条目 · ${onlyKind}`);
119
+
120
+ const kinds = onlyKind === null ? ITEM_KIND_NAMES : [onlyKind];
121
+ let total = 0;
122
+ /** @type {string[]} */
123
+ const withContent = [];
124
+ for (const kind of kinds) {
125
+ const ids = items[kind] ?? [];
126
+ total += ids.length;
127
+ if (ids.length === 0) {
128
+ skip(`${kind.padEnd(10)} 没有`);
129
+ continue;
130
+ }
131
+ withContent.push(kind);
132
+ ok(`${kind.padEnd(10)} ${ids.length} 个`);
133
+ for (const id of ids) plain(` ${id}`);
134
+ }
135
+ if (total === 0) {
136
+ plain(dim(onlyKind === null ? ' 仓库里还没有任何条目。' : ' 这一类里还没有任何条目。'));
137
+ }
138
+
139
+ // hooks / mcp 不是链接,是被**合并**进工具自己的配置文件的——说清楚,
140
+ // 不然用户挑了半天才发现某个工具根本没有对应机制。
141
+ // 注意类型名是**单数**(`hook`),别写成目录名 `hooks`。
142
+ const mergedKinds = withContent.filter((k) => k === 'hook' || k === 'mcp');
143
+ if (mergedKinds.length > 0) {
144
+ const dirs = mergedKinds.map((k) => ITEM_KINDS[k].dir);
145
+ info(`${mergedKinds.join(' / ')} 会合并进工具自己的配置文件,不是链接`);
146
+ for (const tool of config.tools) {
147
+ const missing = dirs.filter((d) => !mergeTarget(tool, d));
148
+ if (missing.length > 0) {
149
+ warn(`${TOOLS[tool].label} 没有 ${missing.join(' / ')} 的合并目标——装了不会生效`);
150
+ }
151
+ }
152
+ }
153
+ // scripts/ 不在上面那张表里(它不是条目类型),但用户找 hook 的脚本时会找它
154
+ if (fs.existsSync(path.resolve(repoRoot, SUPPORT_DIR))) {
155
+ info(`${SUPPORT_DIR}/ 整目录同步,不参与 include / exclude`);
156
+ }
157
+
158
+ // ---- 某个模板到底装了些什么 ----
159
+ //
160
+ // 和「列条目」是两个不同的问题:条目是「有哪些可挑」,模板是「这个组合
161
+ // 实际挑了哪些」。后者不展开就只能去读 bundles/*.json。
162
+ if (typeof flags.bundle === 'string' && flags.bundle.trim() !== '') {
163
+ const name = flags.bundle.trim();
164
+ title(`模板 ${name} 的最终内容`);
165
+ try {
166
+ const { items: resolved, dropped } = resolveSelection(repoRoot, {
167
+ bundles: [name],
168
+ include: config.include,
169
+ exclude: config.exclude,
170
+ });
171
+ if (resolved.length === 0) {
172
+ warn('这个模板(叠加项目级 include / exclude 之后)是空的');
173
+ } else {
174
+ ok(`${resolved.length} 个条目`);
175
+ for (const it of resolved) plain(` ${it}`);
176
+ }
177
+ // 项目级的 include / exclude 会改结果,说一句免得和仓库文件对不上
178
+ const extra = [];
179
+ if (config.include.length > 0) extra.push(`include ${config.include.length} 条`);
180
+ if (dropped.length > 0) extra.push(`exclude 砍掉 ${dropped.length} 条`);
181
+ if (extra.length > 0) plain(dim(` (含本项目的 ${extra.join('、')})`));
182
+ } catch (e) {
183
+ fail(/** @type {Error} */ (e).message);
184
+ return 1;
185
+ }
186
+ }
187
+
188
+ // ---- 模板 ----
189
+ if (onlyKind === null) {
190
+ title('模板');
191
+ const bundles = listBundles(repoRoot);
192
+ if (bundles.length === 0) {
193
+ skip('仓库里没有任何模板(只能靠 include 逐条挑)');
194
+ } else {
195
+ // 宽度按实际内容算——写死数字的话,加一个名字长一点的模板就错位了
196
+ const w = Math.max(...bundles.map((b) => b.name.length));
197
+ const w2 = Math.max(...bundles.map((b) => (b.title ?? '').length));
198
+ for (const b of bundles) {
199
+ plain(` ${b.name.padEnd(w)} ${(b.title ?? '').padEnd(w2)} ${dim(b.description ?? '')}`);
200
+ }
201
+ }
202
+ }
203
+
204
+ // ---- 怎么用 ----
205
+ plain('');
206
+ plain(dim(' 条目名写进 agents.json 的 "include",模板名写进 "bundle":'));
207
+ plain(dim(' "include": ["skill:<名字>"] "bundle": "<模板名>"'));
208
+ if (onlyKind === null) plain(dim(' 只看一类:agent-syncer list --kind=skill'));
209
+ return 0;
210
+ } finally {
211
+ // 拉下来的临时目录用完即删——无论成功、失败还是抛错
212
+ if (cleanup) cleanup();
213
+ }
214
+ }
@@ -1,37 +1,60 @@
1
1
  // @ts-check
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
- import { loadConfig } from '../config.js';
4
+ import { CONFIG_FILENAME, loadConfig } from '../config.js';
5
5
  import { checkBlock } from '../gitignore.js';
6
6
  import { STATUS, inspect } from '../link.js';
7
- import { dim, fail, info, ok, plain, skip, title, warn } from '../log.js';
8
- import { CONTENT_ROOT, KINDS, TOOLS, plannedLinks } from '../target.js';
7
+ import { dim, fail, info, ok, plain, rel, skip, title, warn } from '../log.js';
8
+ import { checkMergeView, isMissingPath } from '../merge.js';
9
+ import { RECORD_REL, checkRecord, readRecord, unparsableKeys } from '../record.js';
10
+ import { findStaleLinks, findStaleMerges } from '../stale.js';
11
+ import { CONTENT_DIRS, CONTENT_ROOT, TOOLS, TOOL_NAMES, plannedLinks } from '../target.js';
9
12
 
10
- /** 统一的相对路径显示(正斜杠,跨平台一致) */
11
- const rel = (from, to) => path.relative(from, to).split(path.sep).join('/');
13
+ /**
14
+ * 一个路径是「不存在」「读不了」还是「在」。
15
+ *
16
+ * 判据和 `merge.js` 的 `presentIn` 是同一个(`isMissingPath`):**只有
17
+ * `ENOENT` / `ENOTDIR` 算不存在**,权限错、IO 错一律算读不到。
18
+ *
19
+ * @param {string} p @returns {'ok' | 'missing' | 'unreadable'}
20
+ */
21
+ function pathState(p) {
22
+ try {
23
+ return fs.statSync(p).isDirectory() ? 'ok' : 'missing';
24
+ } catch (e) {
25
+ return isMissingPath(e) ? 'missing' : 'unreadable';
26
+ }
27
+ }
12
28
 
13
- /** 统计目录下的条目数;目录不存在返回 null */
29
+ /**
30
+ * 统计目录下的条目数。
31
+ *
32
+ * 「读不了」和「不存在」分开报,理由见上面 `pathState`:「无此目录」说的是
33
+ * 「这儿没东西」,而读不到时我们唯一知道的是「这儿的东西我看不到」——
34
+ * 这两句话对用户的意义完全不同。
35
+ *
36
+ * @param {string} dir @returns {{state: 'ok', count: number} | {state: 'missing' | 'unreadable'}}
37
+ */
14
38
  function countEntries(dir) {
15
39
  try {
16
- return fs.readdirSync(dir).length;
17
- } catch {
18
- return null;
40
+ return { state: /** @type {const} */ ('ok'), count: fs.readdirSync(dir).length };
41
+ } catch (e) {
42
+ return { state: isMissingPath(e) ? 'missing' : 'unreadable' };
19
43
  }
20
44
  }
21
45
 
22
- const KIND_LABEL = {
23
- skills: 'skills',
24
- rules: 'rules',
25
- commands: 'commands',
26
- agents: 'agents',
27
- };
28
-
29
46
  /**
30
47
  * agent-syncer status —— 纯只读。报告内容清单与每个链接的健康状况。
31
48
  * @param {{cwd: string, flags: Record<string, any>}} ctx
32
49
  */
33
50
  export async function run({ cwd }) {
34
51
  const config = loadConfig(cwd);
52
+ /**
53
+ * 需要用户处理的问题。**唯一的退出码来源**——「内容存在但没生效」之类
54
+ * 不算问题(那是设计如此),只有「和配置描述的状态对不上」才算。
55
+ * @type {string[]}
56
+ */
57
+ const problems = [];
35
58
 
36
59
  title('agent-syncer status');
37
60
  plain(dim(`项目根:${cwd}`));
@@ -39,34 +62,184 @@ export async function run({ cwd }) {
39
62
  // ---- 内容 ----
40
63
  title(`内容(${CONTENT_ROOT}/)`);
41
64
  const contentRoot = path.resolve(cwd, CONTENT_ROOT);
42
- if (!fs.existsSync(contentRoot)) {
65
+ // `fs.existsSync` 在这里不能用:它对**任何**错误都返回 false,于是权限错会被
66
+ // 报成「还没有任何内容」——和下一层那个「无此目录」是同一个病,只是高了一层。
67
+ const rootState = pathState(contentRoot);
68
+ if (rootState === 'unreadable') {
69
+ warn(`${CONTENT_ROOT}/ 读不了——里面有什么,判断不了`);
70
+ } else if (rootState === 'missing') {
43
71
  fail(`${CONTENT_ROOT}/ 不存在——还没有任何内容`);
44
72
  } else {
45
- for (const kind of KINDS) {
46
- const n = countEntries(path.resolve(contentRoot, kind));
47
- if (n === null) {
48
- skip(`${KIND_LABEL[kind].padEnd(9)} 无此目录`);
49
- } else {
50
- ok(`${KIND_LABEL[kind].padEnd(9)} ${String(n).padStart(3)} 项`);
73
+ // 遍历 CONTENT_DIRS 而不是 KINDS:hooks / mcp / scripts 不参与链接,
74
+ // 但它们同样是 sync 装下来的内容,只报四类会让人以为 sync 把后三类漏了。
75
+ // doctor 本来就列全部子目录,这里跟上,两条命令的口径才一致。
76
+ for (const kind of CONTENT_DIRS) {
77
+ const r = countEntries(path.resolve(contentRoot, kind));
78
+ const label = kind.padEnd(9);
79
+ // 空出数字列的宽度,好让这两行和上面的「N 项」对成一张表
80
+ if (r.state === 'missing') {
81
+ skip(`${label} 无此目录`);
82
+ continue;
83
+ }
84
+ if (r.state === 'unreadable') {
85
+ // **不算问题**——和下一节「合并」读不了时一个口径:判断不了的事如实说,
86
+ // 但不把它变成用户要去点一下的修复项(那会是个他不会采纳的修复)。
87
+ warn(`${label} 读不了——有多少条目判断不了`);
88
+ continue;
89
+ }
90
+ ok(`${label} ${String(r.count).padStart(3)} 项`);
91
+ }
92
+ }
93
+
94
+ // ---- 记录:哪些内容是本工具装的 ----
95
+ //
96
+ // 这一节回答的是「sync 认不认得这些内容」。不认得的话,内容仓库里删掉的
97
+ // 条目就永远清不掉——见 record.js 开头那段。status 读不到内容仓库(它是
98
+ // 离线命令),判断不了「该不该装」,但判断得了「还是不是当初那份」。
99
+ title(`本工具装的(${RECORD_REL})`);
100
+ const record = readRecord(cwd);
101
+ if (!record.usable) {
102
+ if (record.reason) warn(record.reason);
103
+ else plain(dim(' 还没有记录。下次 sync 会写下,之后就能认出哪些内容是本工具装的了。'));
104
+ } else {
105
+ const tracked = checkRecord(cwd, record);
106
+ const missing = tracked.filter((t) => !t.exists).length;
107
+ ok(`跟踪 ${tracked.length} 项`);
108
+ if (missing > 0) plain(dim(` 其中 ${missing} 项本地已不存在`));
109
+ if (config.protect.length > 0) {
110
+ plain(dim(` 另有 ${config.protect.length} 项被 protect 锁住,本工具不覆盖也不删`));
111
+ }
112
+ // 名字都解析不了的条目映射不到路径,只能跳过——但**跳过不等于没有这回事**:
113
+ // 清理功能会因此少管几项,而用户只看到「跟踪 N 项」莫名其妙变少了
114
+ const broken = unparsableKeys(record);
115
+ if (broken.length > 0) {
116
+ warn(`其中 ${broken.length} 项的名字解析不了:${broken.join('、')}`);
117
+ plain(dim(' 它们不会被清理,也不会被覆盖——多半是手改记录时改坏的。'));
118
+ }
119
+ }
120
+
121
+ // ---- 合并:hooks / mcp 有没有真的写进工具自己的配置 ----
122
+ //
123
+ // 这两类不建链接,而是合并进 `.mcp.json` / `.claude/settings.json`。
124
+ // 五态各有各的说法,别混成一句「有问题」:
125
+ // 一致 / 还没生成 / 漂移 / 读不了 / 有本工具没动的地方
126
+ // 判断整个交给 merge.js 的 checkMergeView——doctor 用的是同一份。两个只读
127
+ // 命令对同一份现场给出相反结论,是以前真出过的事(见 checkMergeAll 的注释)。
128
+ const view = checkMergeView({ projectRoot: cwd, config, record });
129
+
130
+ // 「目录读不出来」和「目录是空的」是两件事,不能一声不吭当成后者:
131
+ // 前者我们根本不知道里面有什么,也就答不了「该合的合了没有」。
132
+ // 报一声,但**不算问题**——和下面「记录读不了」一个口径:判断不了的事
133
+ // 如实说,不把它变成用户要去点一下的修复项(那会是个他不会采纳的修复)。
134
+ for (const d of view.unreadable) {
135
+ warn(`${CONTENT_ROOT}/${d}/ 读不了——里面有什么、能不能合,判断不了`);
136
+ }
137
+
138
+ if (config.tools.length > 0 && view.hasWork) {
139
+ title('合并(hooks / mcp)');
140
+
141
+ for (const r of view.rows) {
142
+ const label = `${TOOLS[r.tool].label} · ${r.rel}`.padEnd(30);
143
+
144
+ switch (r.state) {
145
+ case 'ok':
146
+ ok(`${label} ${dim('一致')}`);
147
+ break;
148
+ case 'missing':
149
+ skip(`${label} ${dim('还没生成——跑一次 sync')}`);
150
+ break;
151
+ case 'drift':
152
+ warn(`${label} 和 ${CONTENT_ROOT}/ 里的内容对不上`);
153
+ problems.push(`${r.rel} 漂移了,跑一次 sync 重新合并`);
154
+ break;
155
+ case 'unreadable':
156
+ fail(`${label} ${r.detail}`);
157
+ problems.push(`${r.rel} ${r.detail}`);
158
+ break;
159
+ case 'stale':
160
+ // 「默认不摘」是设计(和内容层一个口径),所以**不算问题**——
161
+ // 但得让人知道有这么几条躺着,以及怎么摘。
162
+ //
163
+ // 措辞留了余地:里面可能混着**你改过的那几条**,按设计它们不会被摘
164
+ // (归属要现场对得上才动)。说成「跑 --prune 就没了」会让人以为一次
165
+ // 就能清干净,跑完发现还在,又不知道该信哪句。
166
+ warn(`${label} 有 ${r.ids.length} 条不再需要的:${r.ids.join('、')}`);
167
+ plain(dim(' 默认不会摘。确认后跑 sync --prune——你改过的那几条会保留。'));
168
+ break;
169
+ default:
170
+ // 冲突不是「故障」,是「这里有你自己的东西,本工具不碰」。
171
+ // 不该让 status 退出码变成 1——那会逼着人去点一个他不会采纳的修复。
172
+ warn(`${label} 有本工具没动的地方`);
173
+ plain(dim(` ${r.detail}`));
51
174
  }
52
175
  }
176
+
177
+ // 合并器做完之后,这是唯一还会为「装了没生效」说话的地方
178
+ for (const u of view.unsupported) {
179
+ warn(`${TOOLS[u.tool].label} 没有 ${u.dir} 的合并目标——这 ${u.count} 条装了不会生效`);
180
+ }
181
+
182
+ if (config.tools.includes('claude') && (view.present.mcp?.length ?? 0) > 0) {
183
+ plain(
184
+ dim(' 注意:MCP server 还要在 Claude Code 里逐条批准才生效——批准记录存在本机,不随仓库共享。'),
185
+ );
186
+ }
187
+ }
188
+
189
+ // ---- 不再使用的合并产物 ----
190
+ //
191
+ // 和上面「不再使用的链接」同构:工具从 links 里去掉了,我们以前写进它配置
192
+ // 文件里的条目就没人管了——而那个文件是**提交进版本库**的。
193
+ //
194
+ // 这一段刻意不放在上面那个 `config.tools.length > 0` 的守卫里:把工具全删光
195
+ // 恰恰是最会产生这类残留的情形,而它前面还有个「未声明任何工具」的提前返回。
196
+ const staleMerges = findStaleMerges(cwd, config.tools, record.merged ?? {});
197
+ if (staleMerges.length > 0) {
198
+ title('不再使用的合并产物');
199
+ for (const s of staleMerges) {
200
+ warn(
201
+ `${s.rel.padEnd(22)} ${s.ids.length} 条 —— ${TOOLS[s.tool].label} 已不在配置里:${s.ids.join('、')}`,
202
+ );
203
+ }
204
+ plain(dim(' 这些是本工具以前合进去的,留着不会再被更新。'));
205
+ plain(dim(' 跑 sync --prune 摘掉(只摘我们写过的那些,你自己的不动)。'));
206
+ problems.push(`有 ${staleMerges.length} 处合并产物已不再需要,跑 sync --prune 摘掉`);
53
207
  }
54
208
 
55
209
  // ---- 链接 ----
210
+ //
211
+ // 收尾统一走这里。**`problems` 是唯一的退出码来源**,任何提前返回都得先把它
212
+ // 打出来:以前「一个工具都没声明」那条路是直接 `return 0` 的,于是前面几节
213
+ // (「不再使用的合并产物」正是会走到这一支的情形)攒下的问题会一声不吭地
214
+ // 消失——连小结都不打。
215
+ let healthy = 0;
216
+ let absent = 0;
217
+ const finish = () => {
218
+ title('小结');
219
+ plain(` 健康 ${healthy} 未创建 ${absent}${problems.length ? ` 问题 ${problems.length}` : ''}`);
220
+ if (problems.length > 0) {
221
+ title('需要你处理');
222
+ for (const p of problems) plain(` · ${p}`);
223
+ return 1;
224
+ }
225
+ if (absent > 0) {
226
+ info(`有 ${absent} 个链接未创建,运行 ${dim('agent-syncer link')} 即可建好`);
227
+ }
228
+ return 0;
229
+ };
230
+
56
231
  if (config.tools.length === 0) {
57
232
  title('链接');
58
- warn('未声明任何工具');
59
- return 0;
233
+ warn(`未声明任何工具——${CONFIG_FILENAME} 里没有 links`);
234
+ plain(dim(' 可选:' + TOOL_NAMES.join('、')));
235
+ plain(dim(' 写进配置里,或在终端里跑一次 agent-syncer link 让它问你要用哪些。'));
236
+ return finish();
60
237
  }
61
238
  if (!config.exists) {
62
239
  plain(dim(`(未找到 agents.json:${config.warnings.join(';')})`));
63
240
  }
64
241
 
65
- const plan = plannedLinks(cwd, config.links);
66
- let healthy = 0;
67
- let absent = 0;
68
- /** @type {string[]} */
69
- const problems = [];
242
+ const plan = plannedLinks(cwd, config.specs);
70
243
 
71
244
  // 按工具分组展示,读起来比一条长清单清楚
72
245
  for (const tool of config.tools) {
@@ -76,6 +249,13 @@ export async function run({ cwd }) {
76
249
  const target = rel(cwd, item.target);
77
250
  const source = rel(cwd, item.source);
78
251
 
252
+ // 配置里写的是工具名,会带出该工具的全部类型;没有内容的那些 link 根本不会建,
253
+ // 报成「未创建」会让人以为漏了——它们本来就不适用
254
+ if (!fs.existsSync(item.source)) {
255
+ skip(`${target.padEnd(20)} ${dim(`无内容(${source} 不存在)`)}`);
256
+ continue;
257
+ }
258
+
79
259
  switch (st.status) {
80
260
  case STATUS.HEALTHY:
81
261
  ok(`${target.padEnd(20)} ${dim(`→ ${source}`)}`);
@@ -95,7 +275,10 @@ export async function run({ cwd }) {
95
275
  break;
96
276
  case STATUS.REPLACED:
97
277
  warn(`${target.padEnd(20)} 是实体目录/文件,不是链接`);
98
- problems.push(`${target} 是实体目录,link 不会覆盖它`);
278
+ // link 命令一个口径:说清后果(这类内容没生效)和下一步,
279
+ // 否则用户只知道「不正常」,不知道该做什么
280
+ plain(dim(` ${source} 的内容不会生效;link 不会覆盖它,--force 也一样`));
281
+ problems.push(`${target} 是实体目录,内容未生效;挪走它并重跑 link 可接管`);
99
282
  break;
100
283
  default:
101
284
  fail(`${target.padEnd(20)} 未知状态:${st.status}`);
@@ -104,30 +287,37 @@ export async function run({ cwd }) {
104
287
  }
105
288
  }
106
289
 
290
+ // ---- 不再使用的链接 ----
291
+ //
292
+ // 只遍历 config.tools 的话,被移出配置的工具留下的链接就彻底隐身了——
293
+ // 而它们恰恰是最危险的一类:托管段已经不忽略它们,git 会顺着链接把
294
+ // .agents/ 的内容再提交一份,且没有任何提示。
295
+ const stale = findStaleLinks(cwd, config.tools);
296
+ if (stale.length > 0) {
297
+ title('不再使用的链接');
298
+ for (const s of stale) {
299
+ const target = rel(cwd, s.target);
300
+ warn(`${target.padEnd(20)} → ${CONTENT_ROOT}/${s.kind},但 ${TOOLS[s.tool].label} 已不在配置里`);
301
+ problems.push(`${target} 已不再需要,运行 link --prune 删除(只删链接)`);
302
+ }
303
+ plain(dim(' 它们不在 .gitignore 托管段里,留着会让 git 把内容重复提交一份。'));
304
+ }
305
+
107
306
  // ---- .gitignore ----
108
307
  title('.gitignore 托管段');
109
- const gi = checkBlock(cwd, config.links);
308
+ const gi = checkBlock(cwd, config.tools);
110
309
  if (!gi.present) {
111
310
  warn('未找到 agent-syncer 托管段,运行 link 会写入');
112
311
  } else if (gi.missing.length > 0) {
113
312
  warn(`托管段已存在,但缺少 ${gi.missing.length} 个条目,运行 link 会补齐`);
114
313
  for (const m of gi.missing) plain(dim(` · ${m}`));
314
+ } else if (gi.extra.length > 0) {
315
+ // 托管段是整段重建的,这几行会在下一次 link 时无声消失
316
+ warn(`托管段里有 ${gi.extra.length} 行不是本工具生成的,运行 link 会被清掉`);
317
+ for (const e of gi.extra) plain(dim(` · ${e}`));
115
318
  } else {
116
319
  ok('完整');
117
320
  }
118
321
 
119
- // ---- 小结 ----
120
- title('小结');
121
- plain(` 健康 ${healthy} 未创建 ${absent}${problems.length ? ` 问题 ${problems.length}` : ''}`);
122
-
123
- if (problems.length > 0) {
124
- title('需要你处理');
125
- for (const p of problems) plain(` · ${p}`);
126
- return 1;
127
- }
128
-
129
- if (absent > 0) {
130
- info(`有 ${absent} 个链接未创建,运行 ${dim('agent-syncer link')} 即可建好`);
131
- }
132
- return 0;
322
+ return finish();
133
323
  }