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.
@@ -6,47 +6,255 @@ import { CONFIG_FILENAME, loadConfig, saveConfig } from '../config.js';
6
6
  import { writeBlock } from '../gitignore.js';
7
7
  import { STATUS, createLink, inspect, removeLink } from '../link.js';
8
8
  import { dim, fail, info, ok, plain, skip, title, warn } from '../log.js';
9
+ import { rmdirIfEmpty } from '../prune.js';
9
10
  import { checkbox, isInteractive } from '../prompt.js';
10
- import { CONTENT_ROOT, TOOLS, specOf, plannedLinks, kindsOf, TOOL_NAMES } from '../target.js';
11
+ import { findStaleLinks } from '../stale.js';
12
+ import {
13
+ CONTENT_ROOT,
14
+ TOOLS,
15
+ TOOL_NAMES,
16
+ kindsOf,
17
+ plannedLinks,
18
+ specsOfTools,
19
+ toolDir,
20
+ } from '../target.js';
11
21
 
12
22
  /**
13
- * 构造勾选项。
23
+ * 「有哪些工具可选」的清单,带各自会带出哪几个目录。
14
24
  *
15
- * 列出的是**链接目标**(如 .claude/skills),因为用户要判断的正是
16
- * 「哪些目录可以被这份内容接管」,而不是抽象的"用哪个工具"。
25
+ * 给的是**配置里该写的名字**在前,中文名和目录在后——用户要回答的问题是
26
+ * 「links 里填什么」,不是「这个工具叫什么」。
27
+ *
28
+ * @param {string[]} [declared] 已经声明了的,标成 ◉
29
+ */
30
+ function toolTable(declared = []) {
31
+ const rows = TOOL_NAMES.map((tool) => {
32
+ const dir = toolDir(tool);
33
+ const dirs = kindsOf(tool).map((k) => TOOLS[tool].links[k].slice(dir.length + 1));
34
+ return { tool, dir: `${dir}/{${dirs.join(', ')}}`, on: declared.includes(tool) };
35
+ });
36
+ // 宽度按实际内容算——写死数字的话,将来加一个名字长一点的工具就错位了
37
+ const w = Math.max(...rows.map((r) => r.tool.length));
38
+ const w2 = Math.max(...rows.map((r) => TOOLS[r.tool].label.length));
39
+
40
+ return rows.map(
41
+ (r) =>
42
+ ` ${r.on ? '◉' : '◯'} ${r.tool.padEnd(w)} ${TOOLS[r.tool].label.padEnd(w2)} ${dim(r.dir)}`,
43
+ );
44
+ }
45
+
46
+ /**
47
+ * 要说清「还有哪些没启用」,光在配置里找不到线索时就得把选项摆出来。
48
+ * @param {string[]} declared
49
+ */
50
+ function printToolTable(declared = []) {
51
+ for (const line of toolTable(declared)) plain(line);
52
+ }
53
+
54
+ /**
55
+ * 构造勾选项:**一个工具一项**。
56
+ *
57
+ * 粒度就到工具为止——选用一个工具就是它的全部类型一起适配。
58
+ * 拆到 `.claude/skills` 这一层给不出有用的选择:真正要回答的问题是
59
+ * 「这个项目用哪几个编码工具」,而不是「哪些目录可以被接管」。
17
60
  *
18
61
  * 与「怎么问」分开,是为了这一层能单独测试——渲染和按键由 prompt.js 负责。
19
62
  *
20
- * @param {string} cwd @param {string[]} preselected
63
+ * @param {string} cwd
64
+ * @param {string[]} [preselected] 已选的工具名。不推断,所以默认一个都不预先勾上——
65
+ * 预勾是「默认值」,而这里没有默认值,只有「你说了算」。
66
+ */
67
+ export function buildLinkChoices(cwd, preselected = []) {
68
+ return TOOL_NAMES.map((tool) => {
69
+ const kinds = kindsOf(tool);
70
+ const notes = [];
71
+
72
+ const missing = kinds.filter((k) => !fs.existsSync(path.resolve(cwd, CONTENT_ROOT, k)));
73
+ if (missing.length === kinds.length) {
74
+ notes.push(`${CONTENT_ROOT}/ 下暂无该工具可用的内容`);
75
+ } else if (missing.length > 0) {
76
+ notes.push(`暂无内容:${missing.join('、')}`);
77
+ }
78
+ const dir = toolDir(tool);
79
+ if (!fs.existsSync(path.resolve(cwd, dir))) notes.push(`会新建 ${dir}/`);
80
+
81
+ const subs = kinds.map((k) => TOOLS[tool].links[k].slice(dir.length + 1));
82
+
83
+ return {
84
+ value: tool,
85
+ label: `${TOOLS[tool].label} · ${dir}/{${subs.join(', ')}}`,
86
+ note: notes.length > 0 ? `(${notes.join(',')})` : '',
87
+ checked: preselected.includes(tool),
88
+ };
89
+ });
90
+ }
91
+
92
+ /**
93
+ * 建一条链接的完整流程:看现状 → 清理该清的 → 建 → 校验。
94
+ *
95
+ * 两条入口共用同一份安全逻辑:读 agents.json 的批量模式,和命令行 `--src`/`--dst`
96
+ * 的直连模式。**不能各写一份**——「拒绝覆盖实体目录」「只删链接」「创建后校验」
97
+ * 是这个工具的安全底线,抄成两份,将来改一处漏一处是迟早的事。
98
+ *
99
+ * 打印留在这里(每条的状态本来就是给人看的),计数交给调用方:批量模式要分
100
+ * 「新建 / 已存在 / 有问题」统计,直连模式只有一条,用不上那些计数器。
101
+ *
102
+ * @param {{label: string, source: string, target: string, rel: string, srcLabel: string}} item
103
+ * `label` 是打印前缀;`rel` / `srcLabel` 是给人看的路径写法(批量模式写
104
+ * `.agents/skills`,直连模式写相对工作目录的路径)
105
+ * @param {{dryRun: boolean, force: boolean}} opts
106
+ * @returns {{status: 'healthy'|'created'|'would-create'|'failed', problem?: string}}
21
107
  */
22
- export function buildLinkChoices(cwd, preselected) {
23
- /** @type {{value: string, label: string, note: string, checked: boolean}[]} */
24
- const choices = [];
25
-
26
- for (const tool of TOOL_NAMES) {
27
- const toolDirExists = fs.existsSync(path.resolve(cwd, `.${tool}`));
28
- for (const kind of kindsOf(tool)) {
29
- const spec = specOf(tool, kind);
30
- const notes = [];
31
- if (!fs.existsSync(path.resolve(cwd, CONTENT_ROOT, kind))) {
32
- notes.push(`${CONTENT_ROOT}/${kind}/ 暂无内容`);
108
+ function applyOne({ label, source, target, rel, srcLabel }, { dryRun, force }) {
109
+ const before = inspect(target, source);
110
+
111
+ if (before.status === STATUS.HEALTHY) {
112
+ ok(`${label} 已是最新`);
113
+ return { status: 'healthy' };
114
+ }
115
+
116
+ // 位置被实体目录/文件占了,绝不覆盖——那可能是用户自己的东西。
117
+ //
118
+ // 光说「拒绝覆盖」是把人晾在半路上:他不知道**后果**(这类内容根本没生效,
119
+ // 工具里看不到),也不知道**下一步做什么**,还可能去试一个没用的开关
120
+ // (--force 只对 ELSEWHERE 生效,对实体路径无效)。三件事一次说完。
121
+ if (before.status === STATUS.REPLACED) {
122
+ fail(`${label} 已存在实体目录/文件,不是链接,拒绝覆盖`);
123
+ plain(dim(` ${srcLabel} 的内容不会生效;--force 对它也无效(不替你做删除决定)`));
124
+ plain(dim(` 想接管:把 ${rel} 的内容挪进 ${srcLabel},再删掉 ${rel}`));
125
+ return {
126
+ status: 'failed',
127
+ problem: `${rel} 是实体目录,${srcLabel} 的内容未生效;挪走它并重跑 link 可接管`,
128
+ };
129
+ }
130
+
131
+ if (before.status === STATUS.ELSEWHERE && !force) {
132
+ fail(`${label} 已是指向别处的链接 → ${before.actual}`);
133
+ // 直连模式(`link --src/--dst`)建的链接正好落进这一支。得说清 `--force`
134
+ // 是**换掉**它而不是「修好」它——否则这句提示会被当成一个修问题的按钮,
135
+ // 把用户昨天特意建的那条链接顺手覆盖掉,还以为自己是在修配置。
136
+ plain(dim(' 它可能是你特意建的(link --src/--dst),也可能是别处留的'));
137
+ plain(dim(` --force 会把它换成指向 ${srcLabel},原来那条链接就没了`));
138
+ return { status: 'failed', problem: `${rel} 指向别处;确认要换成 ${srcLabel} 再加 --force` };
139
+ }
140
+
141
+ // 走到这里只可能是 ABSENT / BROKEN / (ELSEWHERE + --force),
142
+ // 三种情况都只涉及链接本身,删除是安全的
143
+ if (before.status === STATUS.BROKEN || before.status === STATUS.ELSEWHERE) {
144
+ if (dryRun) {
145
+ info(`${label} 将先移除现有链接(${before.status === STATUS.BROKEN ? '断链' : '指向别处'})`);
146
+ } else {
147
+ const removed = removeLink(target);
148
+ if (!removed.ok) {
149
+ fail(`${label} 移除旧链接失败:${removed.message}`);
150
+ return { status: 'failed', problem: `${rel}:${removed.message}` };
33
151
  }
34
- if (!toolDirExists) notes.push(`会新建 .${tool}/`);
35
-
36
- choices.push({
37
- value: spec,
38
- label: `${TOOLS[tool].label} · ${TOOLS[tool].links[kind]}`,
39
- note: notes.length > 0 ? `(${notes.join(',')})` : '',
40
- checked: preselected.includes(spec),
41
- });
42
152
  }
43
153
  }
44
- return choices;
154
+
155
+ const res = createLink(source, target, { dryRun });
156
+ if (!res.ok) {
157
+ fail(`${label} 创建失败:${res.message}`);
158
+ return { status: 'failed', problem: `${rel}:${res.message}` };
159
+ }
160
+
161
+ if (dryRun) {
162
+ info(`${label} 将创建 → ${srcLabel}`);
163
+ return { status: 'would-create' };
164
+ }
165
+
166
+ const after = inspect(target, source);
167
+ if (after.status === STATUS.HEALTHY) {
168
+ ok(`${label} 已创建`);
169
+ return { status: 'created' };
170
+ }
171
+ fail(`${label} 创建后校验失败(状态:${after.status})`);
172
+ return { status: 'failed', problem: `${rel}:创建后校验未通过` };
173
+ }
174
+
175
+ /**
176
+ * `--src` / `--dst` 直连模式:在这儿建一条链接,**完全不碰项目配置**。
177
+ *
178
+ * 存在的理由和批量模式不同。批量模式回答「这个项目适配哪些工具」,内容有固定的
179
+ * 家(`.agents/<kind>/`);这里回答的是「就这一处,建个链接」,源和目标都可以在
180
+ * 项目外,跟 `.agents/`、`agents.json` 全无关系。
181
+ *
182
+ * 因此也**不纳入托管**:不写 .gitignore 托管段、不进 `.agent-sync.json` 记录、
183
+ * `status` 看不见它、`--prune` 不碰它。这是刻意的——托管那套的前提是「内容都从
184
+ * `.agents/` 来」,硬把一条任意路径塞进去,等于让每个子系统都得认识一个例外。
185
+ * 想被管起来,把内容放进 `.agents/` 走批量模式。
186
+ *
187
+ * 相对路径按 `cwd` 解析:用户敲命令时看的就是当前目录。
188
+ *
189
+ * ⚠️ 不写托管段是有代价的:如果落点在某个 git 仓库里,git 会**穿透链接**把源的
190
+ * 内容在仓库里再提交一份。所以结尾要提醒一句。
191
+ *
192
+ * @param {{cwd: string, src: string|null, dst: string|null, dryRun: boolean, force: boolean, prune: boolean}} opts
193
+ */
194
+ function runDirect({ cwd, src, dst, dryRun, force, prune }) {
195
+ title(`agent-syncer link --src/--dst${dryRun ? dim('(--dry-run,不会写盘)') : ''}`);
196
+ plain(dim(`工作目录:${cwd}`));
197
+
198
+ if (src === null || dst === null) {
199
+ fail(`--src 和 --dst 要一起给(少了 --${src === null ? 'src' : 'dst'})`);
200
+ plain(' 只给一个不知道该建什么——源和落点是两件事,都得说。例如:');
201
+ plain(dim(' agent-syncer link --src=../shared/prompts --dst=.claude/skills'));
202
+ plain(dim(' 相对路径按上面那个工作目录解析,绝对路径原样用。'));
203
+ return 1;
204
+ }
205
+
206
+ if (prune) {
207
+ warn('--prune 是批量模式的选项,这里用不上——直连模式不托管任何东西');
208
+ }
209
+
210
+ const source = path.resolve(cwd, src);
211
+ const target = path.resolve(cwd, dst);
212
+
213
+ /**
214
+ * 显示用路径。上一层、上两层都用相对路径——用户刚敲的就是这个写法,
215
+ * 回显成对得上他才好核对自己有没有搞反方向。爬得太远(一串 `../` 数不清)
216
+ * 或者跨盘(Windows 上 `path.relative` 这时直接返回绝对路径)才写绝对的。
217
+ */
218
+ const shown = (p) => {
219
+ const r = path.relative(cwd, p);
220
+ if (r === '') return '.';
221
+ const up = r.split(path.sep).filter((s) => s === '..').length;
222
+ return up <= 2 ? r : p;
223
+ };
224
+
225
+ const rel = shown(target);
226
+ const srcLabel = shown(source);
227
+ plain(`\n${srcLabel} ${dim('→')} ${rel}\n`);
228
+
229
+ const r = applyOne({ label: rel, source, target, rel, srcLabel }, { dryRun, force });
230
+
231
+ if (r.status === 'failed') {
232
+ title('需要你处理');
233
+ plain(` · ${r.problem}`);
234
+ return 1;
235
+ }
236
+
237
+ plain(dim('\n这条链接不在托管范围内:status 看不到它,--prune 也不会清理它。'));
238
+ // 反过来的那一面:落点正好落在工具托管目录里时,批量模式会把它当成「指向别处」
239
+ // 而拒绝接管。先说一句,免得用户照那句「加 --force 覆盖」把自己这条换掉。
240
+ const inToolDir = TOOL_NAMES.some((t) => {
241
+ const dir = path.resolve(cwd, toolDir(t));
242
+ return target !== dir && target.startsWith(dir + path.sep);
243
+ });
244
+ if (inToolDir) {
245
+ plain(dim('注意:这个落点在某个工具的托管目录里——之后跑批量 link 会认为它「指向别处」'));
246
+ plain(dim(' 而拒绝接管。那是它在保护这条链接,别照着 --force 的提示把它覆盖掉。'));
247
+ }
248
+ if (dryRun) plain(dim('这是预演,未写盘。去掉 --dry-run 即可实际执行。'));
249
+ return 0;
45
250
  }
46
251
 
47
252
  /**
48
253
  * agent-syncer link —— 建好所有目录链接,并维护 .gitignore 的托管段。
49
254
  *
255
+ * 除了「建」还有一步「反向检查」:以前建过、这次配置里不再需要的链接会被列出来
256
+ * (见 stale.js)。默认只报告,加 --prune 才删——**且只删链接**。
257
+ *
50
258
  * 安全底线:**绝不删除实体目录或实体文件**。
51
259
  * 路径上已有非链接的东西时一律拒绝并报错,交由用户自己处置。
52
260
  *
@@ -57,8 +265,19 @@ export function buildLinkChoices(cwd, preselected) {
57
265
  export async function run({ cwd, flags, input = process.stdin, output = process.stdout }) {
58
266
  const dryRun = Boolean(flags['dry-run']);
59
267
  const force = Boolean(flags.force);
268
+
269
+ // --src / --dst 是另一条路:在这儿建一条链接,和项目配置毫无关系。
270
+ // 所以必须在「.agents/ 存不存在」这类项目检查**之前**分流——直连模式完全可以
271
+ // 在一个没有 .agents/、没有 agents.json 的目录里跑,那正是它的用处。
272
+ const src = typeof flags.src === 'string' ? flags.src : null;
273
+ const dst = typeof flags.dst === 'string' ? flags.dst : null;
274
+ if (src !== null || dst !== null) {
275
+ return runDirect({ cwd, src, dst, dryRun, force, prune: Boolean(flags.prune) });
276
+ }
277
+
60
278
  const assumeYes = Boolean(flags.yes);
61
279
  const noSave = Boolean(flags['no-save']);
280
+ const prune = Boolean(flags.prune);
62
281
 
63
282
  title(`agent-syncer link${dryRun ? dim('(--dry-run,不会写盘)') : ''}`);
64
283
  plain(dim(`项目根:${cwd}`));
@@ -72,48 +291,85 @@ export async function run({ cwd, flags, input = process.stdin, output = process.
72
291
 
73
292
  let config = loadConfig(cwd);
74
293
 
75
- // 没有配置:交互式问一次,并把答案记下来,下次不再问
76
- if (!config.exists && !assumeYes) {
77
- if (!isInteractive(input, output)) {
78
- warn(`未找到 ${CONFIG_FILENAME},且当前不是交互终端——按已有目录推断`);
79
- warn(` 推断结果:${config.links.join('、') || '(空)'}`);
80
- plain(dim(' 想自己选,请在终端里直接运行;或先手写一个 agents.json。'));
81
- } else {
82
- plain('');
83
- const picked = await checkbox({
84
- message: `没有找到 ${CONFIG_FILENAME},请勾选要建立链接的目录:`,
85
- choices: buildLinkChoices(cwd, config.links),
86
- input,
87
- output,
88
- });
89
-
90
- if (picked === null) {
91
- plain(dim('已取消,未做任何改动。'));
92
- return 0;
93
- }
94
- if (picked.length === 0) {
95
- warn('没有勾选任何目录,无事可做');
96
- return 0;
97
- }
294
+ // 没声明过用哪些工具。**不猜**——「这个项目用 Trae」是个持久事实,
295
+ // 从「目录恰好存在」推出来的东西用户既没同意过也看不见,猜错还会给一个
296
+ // 其实不用的工具建链接。能做到的就是:问,或者把选项和写法摆出来。
297
+ if (!config.toolsDeclared) {
298
+ if (assumeYes || !isInteractive(input, output)) {
299
+ fail('不知道要给哪些工具建链接');
300
+ plain(
301
+ config.exists
302
+ ? ` ${CONFIG_FILENAME} 里没有 ${dim('links')}。`
303
+ : ` 没有找到 ${CONFIG_FILENAME}。`,
304
+ );
305
+ plain(
306
+ assumeYes
307
+ ? dim(' --yes 只是「不询问」,它不负责猜——先把 links 写进配置。')
308
+ : ' 当前不是交互终端,没法问你。',
309
+ );
98
310
 
99
- if (dryRun || noSave) {
100
- info(`将要建立 ${picked.length} 个链接(${dryRun ? '--dry-run' : '--no-save'}:不写入 ${CONFIG_FILENAME})`);
101
- } else {
102
- const p = saveConfig(cwd, picked);
103
- ok(`${CONFIG_FILENAME} 已写入 → ${path.relative(cwd, p)}`);
104
- }
105
- config = { ...config, links: picked, exists: true };
311
+ plain('\n 两种做法:');
312
+ plain(` · ${CONFIG_FILENAME} 里写一行,之后不用再管(推荐)`);
313
+ plain(dim(' "links": ["claude"]'));
314
+ plain(' · 或者在终端里直接跑 agent-syncer link,会列出来让你勾选');
315
+
316
+ plain(`\n 可用工具(${dim('links')} 里写名字,要多个就写成数组):`);
317
+ printToolTable();
318
+
319
+ plain(dim(' 例如: "links": ["claude", "trae"]'));
320
+ return 1;
321
+ }
322
+
323
+ plain('');
324
+ const picked = await checkbox({
325
+ message: config.exists
326
+ ? `${CONFIG_FILENAME} 里没有 links,请勾选这个项目使用哪些编码工具:`
327
+ : `没有找到 ${CONFIG_FILENAME},请勾选这个项目使用哪些编码工具:`,
328
+ choices: buildLinkChoices(cwd),
329
+ input,
330
+ output,
331
+ });
332
+
333
+ if (picked === null) {
334
+ plain(dim('已取消,未做任何改动。'));
335
+ return 0;
336
+ }
337
+ if (picked.length === 0) {
338
+ warn('没有勾选任何工具,无事可做');
339
+ return 0;
106
340
  }
341
+
342
+ if (dryRun || noSave) {
343
+ info(`选中 ${picked.length} 个工具(${dryRun ? '--dry-run' : '--no-save'}:不写入 ${CONFIG_FILENAME})`);
344
+ } else {
345
+ const p = saveConfig(cwd, picked);
346
+ ok(`${CONFIG_FILENAME} 已写入 → ${path.relative(cwd, p)}`);
347
+ }
348
+ config = { ...config, tools: picked, specs: specsOfTools(picked), exists: true, toolsDeclared: true };
107
349
  }
108
350
 
109
351
  for (const w of config.warnings) warn(w);
110
352
 
111
- if (config.links.length === 0) {
112
- warn('没有任何要建立的链接,无事可做');
353
+ // 用户是在一个工具都没声明的情况下进来的;显式写 "links": [] 是另一回事,
354
+ // 那是「我就要一个都不建」,照旧按无事可做处理
355
+ if (config.tools.length === 0) {
356
+ warn('没有选中任何工具,无事可做');
113
357
  return 0;
114
358
  }
115
359
 
116
- const plan = plannedLinks(cwd, config.links);
360
+ // 顺带说一声还有哪些没启用——不然用户不一定会想到可以同时适配多个工具
361
+ ok(`工具:${config.tools.map((t) => `${t}(${TOOLS[t].label})`).join('、')}`);
362
+ const rest = TOOL_NAMES.filter((t) => !config.tools.includes(t));
363
+ if (rest.length > 0) {
364
+ plain(
365
+ dim(
366
+ ` 还支持 ${rest.map((t) => `${t}(${TOOLS[t].label})`).join('、')}` +
367
+ `——想一起适配就加进 ${CONFIG_FILENAME} 的 "links"`,
368
+ ),
369
+ );
370
+ }
371
+
372
+ const plan = plannedLinks(cwd, config.specs);
117
373
  let created = 0;
118
374
  let alreadyOk = 0;
119
375
  let skipped = 0;
@@ -129,64 +385,79 @@ export async function run({ cwd, flags, input = process.stdin, output = process.
129
385
  continue;
130
386
  }
131
387
 
132
- const before = inspect(item.target, item.source);
388
+ const r = applyOne(
389
+ {
390
+ label,
391
+ source: item.source,
392
+ target: item.target,
393
+ rel: item.rel,
394
+ srcLabel: `${CONTENT_ROOT}/${item.kind}`,
395
+ },
396
+ { dryRun, force },
397
+ );
133
398
 
134
- if (before.status === STATUS.HEALTHY) {
135
- ok(`${label} 已是最新`);
136
- alreadyOk += 1;
137
- continue;
138
- }
139
-
140
- if (before.status === STATUS.REPLACED) {
141
- fail(`${label} 已存在实体目录/文件,拒绝覆盖`);
142
- problems.push(`${item.rel} 是实体目录,请自行处理后再运行`);
143
- continue;
144
- }
399
+ if (r.status === 'healthy') alreadyOk += 1;
400
+ else if (r.status === 'created') created += 1;
401
+ else if (r.status === 'failed') problems.push(/** @type {string} */ (r.problem));
402
+ }
145
403
 
146
- if (before.status === STATUS.ELSEWHERE && !force) {
147
- fail(`${label} 已是指向别处的链接 → ${before.actual}`);
148
- problems.push(`${item.rel} 指向别处,确认无误后加 --force 覆盖`);
149
- continue;
150
- }
404
+ // ---- 反向检查:以前建过、这次配置里不再需要的链接 ----
405
+ //
406
+ // link 只加不减,把工具从 links 里去掉不会动已建好的 junction,而托管段已经
407
+ // 不忽略它们了——git 会顺着这些链接把 .agents/ 的内容再提交一份。
408
+ //
409
+ // 默认只报告:分不清这是刚去掉的残留,还是用户自己建的链接。加 --prune 才删,
410
+ // 且 removeLink 本身就拒绝删实体目录,误伤不了用户自己的东西。
411
+ const stale = findStaleLinks(cwd, config.tools);
412
+ let pruned = 0;
413
+ // 链接删完之后空掉、并真的被收走的工具目录(如 .trae/)
414
+ /** @type {string[]} */
415
+ const emptied = [];
151
416
 
152
- // 走到这里只可能是 ABSENT / BROKEN / (ELSEWHERE + --force)
153
- // 三种情况都只涉及链接本身,删除是安全的
154
- if (before.status === STATUS.BROKEN || before.status === STATUS.ELSEWHERE) {
155
- if (dryRun) {
156
- info(`${label} 将先移除现有链接(${before.status === STATUS.BROKEN ? '断链' : '指向别处'})`);
157
- } else {
158
- const removed = removeLink(item.target);
417
+ if (stale.length > 0) {
418
+ if (prune) {
419
+ for (const s of stale) {
420
+ const label = `${TOOLS[s.tool].label} ${dim('·')} ${s.rel} ${dim('不再需要')}`;
421
+ if (dryRun) {
422
+ info(`${label} 将删除`);
423
+ continue;
424
+ }
425
+ const removed = removeLink(s.target);
159
426
  if (!removed.ok) {
160
- fail(`${label} 移除旧链接失败:${removed.message}`);
161
- problems.push(`${item.rel}:${removed.message}`);
427
+ fail(`${label} 删除失败:${removed.message}`);
428
+ problems.push(`${s.rel}:${removed.message}`);
162
429
  continue;
163
430
  }
431
+ if (removed.action === 'removed') {
432
+ ok(`${label} 已删除`);
433
+ pruned += 1;
434
+ } else {
435
+ info(`${label} 已不存在`);
436
+ }
164
437
  }
165
- }
166
-
167
- const res = createLink(item.source, item.target, { dryRun });
168
- if (!res.ok) {
169
- fail(`${label} 创建失败:${res.message}`);
170
- problems.push(`${item.rel}:${res.message}`);
171
- continue;
172
- }
173
438
 
174
- if (dryRun) {
175
- info(`${label} 将创建 ${CONTENT_ROOT}/${item.kind}`);
176
- } else {
177
- const after = inspect(item.target, item.source);
178
- if (after.status === STATUS.HEALTHY) {
179
- ok(`${label} 已创建`);
180
- created += 1;
181
- } else {
182
- fail(`${label} 创建后校验失败(状态:${after.status})`);
183
- problems.push(`${item.rel}:创建后校验未通过`);
439
+ // 收掉因此空掉的工具目录。留着不影响版本库(git 不跟踪空目录),
440
+ // doctor 见到 `.trae/` 还在就会报「目录存在,但未在 links 里声明」,
441
+ // 而这只是上一轮的残渣——白白让人以为自己配置写漏了。
442
+ // 非空目录一个都删不掉(rmdir 只对空目录成功),工具目录里有用户自己的
443
+ // 东西时天然安全。
444
+ if (!dryRun) {
445
+ for (const tool of new Set(stale.map((s) => s.tool))) {
446
+ const rel = toolDir(tool);
447
+ if (rmdirIfEmpty(path.resolve(cwd, rel))) emptied.push(rel);
448
+ }
449
+ if (emptied.length > 0) info(`顺带收掉空目录:${emptied.join('、')}`);
184
450
  }
451
+ } else {
452
+ warn(`发现 ${stale.length} 个不再使用的链接——不会自动删除`);
453
+ for (const s of stale) plain(dim(` · ${s.rel} → ${CONTENT_ROOT}/${s.kind}`));
454
+ plain(dim(' 它们已不在 .gitignore 托管段里,留着会让 git 把内容重复提交一份。'));
455
+ plain(dim(' 确认不需要后加 --prune 删除(只删链接,不动实体目录)。'));
185
456
  }
186
457
  }
187
458
 
188
459
  // .gitignore:junction 记录绝对路径,必须忽略;否则 git 会穿透链接重复提交内容
189
- const gi = writeBlock(cwd, config.links, { dryRun });
460
+ const gi = writeBlock(cwd, config.tools, { dryRun });
190
461
  if (gi.changed) {
191
462
  if (dryRun) info(`${dim('.gitignore')} 将写入托管段`);
192
463
  else ok(`${dim('.gitignore')} 托管段已更新`);
@@ -197,6 +468,9 @@ export async function run({ cwd, flags, input = process.stdin, output = process.
197
468
  title('小结');
198
469
  plain(
199
470
  ` 新创建 ${created} 已存在 ${alreadyOk} 跳过 ${skipped}` +
471
+ `${pruned ? ` 清理 ${pruned}` : ''}` +
472
+ `${emptied.length ? ` 空目录 ${emptied.length}` : ''}` +
473
+ `${stale.length > 0 && !prune ? ` 不再使用 ${stale.length}` : ''}` +
200
474
  `${problems.length ? ` 问题 ${problems.length}` : ''}`,
201
475
  );
202
476
 
@@ -208,6 +482,8 @@ export async function run({ cwd, flags, input = process.stdin, output = process.
208
482
 
209
483
  if (dryRun) {
210
484
  plain(dim('\n这是预演,未写盘。去掉 --dry-run 即可实际执行。'));
485
+ } else if (stale.length > 0 && !prune) {
486
+ plain(dim('\n提示:不再使用的链接还留在原处,确认后运行 agent-syncer link --prune 清理。'));
211
487
  } else if (created > 0) {
212
488
  plain(dim('\n提示:这些链接已加入 .gitignore,别把它们提交上去。'));
213
489
  }