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/CONTENT-REPO.md +526 -0
- package/README.md +540 -47
- package/bin/agent-sync.js +190 -18
- package/lib/commands/doctor.js +302 -23
- package/lib/commands/init.js +332 -0
- package/lib/commands/link.js +379 -103
- package/lib/commands/list.js +214 -0
- package/lib/commands/status.js +236 -46
- package/lib/commands/sync.js +555 -0
- package/lib/config.js +303 -96
- package/lib/gitignore.js +50 -22
- package/lib/install.js +260 -0
- package/lib/log.js +11 -0
- package/lib/manifest.js +442 -0
- package/lib/merge.js +1255 -0
- package/lib/prompt.js +364 -1
- package/lib/prune.js +80 -0
- package/lib/record.js +460 -0
- package/lib/source.js +312 -0
- package/lib/stale.js +130 -0
- package/lib/target.js +152 -13
- package/package.json +3 -2
|
@@ -0,0 +1,555 @@
|
|
|
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 { applyInstall, findOrphans, planInstall } from '../install.js';
|
|
6
|
+
import {
|
|
7
|
+
RECORD_REL,
|
|
8
|
+
backupUnreadableRecord,
|
|
9
|
+
carryOver,
|
|
10
|
+
isTracked,
|
|
11
|
+
planRemovals,
|
|
12
|
+
readRecord,
|
|
13
|
+
snapshotRecord,
|
|
14
|
+
writeRecord,
|
|
15
|
+
} from '../record.js';
|
|
16
|
+
import {
|
|
17
|
+
ITEM_KINDS,
|
|
18
|
+
SUPPORT_DIR,
|
|
19
|
+
groupByKind,
|
|
20
|
+
listBundles,
|
|
21
|
+
loadRepo,
|
|
22
|
+
resolveSelection,
|
|
23
|
+
} from '../manifest.js';
|
|
24
|
+
import { dim, fail, info, ok, plain, rel, title, warn } from '../log.js';
|
|
25
|
+
import { isInteractive } from '../prompt.js';
|
|
26
|
+
import { applyMergeAll, keepByProtect } from '../merge.js';
|
|
27
|
+
import { rmdirIfEmpty, rmdirTreeIfEmpty } from '../prune.js';
|
|
28
|
+
import { fetchRepo, isGitUrl, localRefWarning } from '../source.js';
|
|
29
|
+
import { CONTENT_ROOT, MERGE_KINDS, TOOLS } from '../target.js';
|
|
30
|
+
import { run as runLink } from './link.js';
|
|
31
|
+
|
|
32
|
+
/** 列出可选模板,并告诉用户怎么选 */
|
|
33
|
+
function printBundles(repoRoot) {
|
|
34
|
+
const bundles = listBundles(repoRoot);
|
|
35
|
+
if (bundles.length === 0) {
|
|
36
|
+
plain(' 内容仓库里没有任何 bundle。');
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
plain('\n 可用模板:');
|
|
40
|
+
for (const b of bundles) {
|
|
41
|
+
plain(` ${b.name.padEnd(16)} ${(b.title ?? '').padEnd(10)} ${dim(b.description ?? '')}`);
|
|
42
|
+
}
|
|
43
|
+
plain(
|
|
44
|
+
`\n 用 ${dim('--bundle=<名字>')} 选(多个用逗号分隔),` +
|
|
45
|
+
`或写进 ${CONFIG_FILENAME} 的 "bundle" 字段。`,
|
|
46
|
+
);
|
|
47
|
+
plain(dim(' 只想微调?"include" / "exclude" 可以叠在模板之上,exclude 最后生效。'));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 要透传给 `link` 的选项。**白名单,不是把整个 flags 递过去。**
|
|
52
|
+
*
|
|
53
|
+
* 整个递过去踩过一次(实测):命令层的 flags 没有命名空间,而 `--src`/`--dst`
|
|
54
|
+
* 会把 link 拨到「直连模式」那条路上——于是 sync 装完了内容、写完了记录,却
|
|
55
|
+
* **一条托管链接都没建、.gitignore 也没写**,退出码还是 0。正好是这个工具最
|
|
56
|
+
* 不该出现的状态:链接不在忽略段里,git 会顺着它们把 `.agents/` 的内容再提交
|
|
57
|
+
* 一份,而全程没有一句提示。
|
|
58
|
+
*
|
|
59
|
+
* 这里只留 link 自己认识的开关。将来 link 加了新选项就**显式加进来**——
|
|
60
|
+
* 那点摩擦正是白名单该有的。
|
|
61
|
+
*
|
|
62
|
+
* @param {Record<string, any>} flags
|
|
63
|
+
*/
|
|
64
|
+
function linkFlags(flags) {
|
|
65
|
+
const PASS = ['dry-run', 'force', 'prune', 'yes', 'no-save'];
|
|
66
|
+
/** @type {Record<string, any>} */
|
|
67
|
+
const out = {};
|
|
68
|
+
for (const k of PASS) if (flags[k] !== undefined) out[k] = flags[k];
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 把 hooks / mcp 合并进各工具自己的配置文件。
|
|
74
|
+
*
|
|
75
|
+
* **期望内容取自 `plan` 的 `from`(内容仓库里那条路径),不是读 `.agents/`。**
|
|
76
|
+
* `--dry-run` 时 `applyInstall` 没执行,`.agents/mcp/foo.json` 可能还是上一轮的
|
|
77
|
+
* 旧内容、甚至根本不存在——从那儿读会让预演报「无变化」而真跑大改,或者报
|
|
78
|
+
* 「内容缺失」。
|
|
79
|
+
*
|
|
80
|
+
* 锁住的条目(`protect`)整个不参与合并:既不写入,也不摘除。
|
|
81
|
+
*
|
|
82
|
+
* @param {{
|
|
83
|
+
* cwd: string, plan: any[], config: any, record: any, dryRun: boolean, prune: boolean,
|
|
84
|
+
* }} input
|
|
85
|
+
*/
|
|
86
|
+
function runMerge({ cwd, plan, config, record, dryRun, prune }) {
|
|
87
|
+
/** @type {Record<string, Record<string, string>>} */
|
|
88
|
+
const sourcesByKind = {};
|
|
89
|
+
|
|
90
|
+
// 注意 `plan` 里**不会有被 protect 锁住的条目**(sync 早就把它们滤掉了),
|
|
91
|
+
// 所以这里不必再判一次 protect。锁住的那些由 `keepByProtect` 单独收:它们不在
|
|
92
|
+
// plan 里,却很可能已经合并进过工具配置——不显式算进来的话,「锁住它」到了
|
|
93
|
+
// --prune 就失效,而用户写 protect 的正是「别动这个」。
|
|
94
|
+
for (const e of plan) {
|
|
95
|
+
const dir = ITEM_KINDS[e.kind]?.dir;
|
|
96
|
+
// scripts 没有条目类型,也不合并——它整目录同步,由 hook 按路径引用
|
|
97
|
+
if (!dir || !MERGE_KINDS.includes(dir)) continue;
|
|
98
|
+
(sourcesByKind[dir] ??= {})[e.id] = e.from;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
...applyMergeAll({
|
|
103
|
+
projectRoot: cwd,
|
|
104
|
+
tools: config.tools,
|
|
105
|
+
sourcesByKind,
|
|
106
|
+
keepByKind: keepByProtect(config.protect),
|
|
107
|
+
prevMerged: record.merged ?? {},
|
|
108
|
+
dryRun,
|
|
109
|
+
prune,
|
|
110
|
+
}),
|
|
111
|
+
sourcesByKind,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* 删完残留内容之后,把因此空掉的目录也收掉。
|
|
117
|
+
*
|
|
118
|
+
* 只试 `removable` 里那些条目**自己所在的目录**——没动过的目录一概不碰。
|
|
119
|
+
* `rmdirIfEmpty` 只对空目录成功,所以还有东西留着的目录天然删不掉。
|
|
120
|
+
*
|
|
121
|
+
* @param {string} cwd
|
|
122
|
+
* @param {{key: string, abs: string}[]} removable
|
|
123
|
+
* @returns {string[]} 被删掉的目录(相对项目根的写法)
|
|
124
|
+
*/
|
|
125
|
+
function pruneEmptyDirs(cwd, removable) {
|
|
126
|
+
/** @type {string[]} */
|
|
127
|
+
const out = [];
|
|
128
|
+
/** @type {Set<string>} */
|
|
129
|
+
const seen = new Set();
|
|
130
|
+
let touchedScripts = false;
|
|
131
|
+
|
|
132
|
+
for (const r of removable) {
|
|
133
|
+
// scripts/ 里的相对路径可能带子目录,交给 rmdirTreeIfEmpty 从下往上收
|
|
134
|
+
if (r.key.startsWith('script:')) {
|
|
135
|
+
touchedScripts = true;
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
const dir = path.dirname(r.abs);
|
|
139
|
+
if (seen.has(dir)) continue;
|
|
140
|
+
seen.add(dir);
|
|
141
|
+
if (rmdirIfEmpty(dir)) out.push(rel(cwd, dir));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (touchedScripts) {
|
|
145
|
+
const root = path.resolve(cwd, CONTENT_ROOT, SUPPORT_DIR);
|
|
146
|
+
for (const d of rmdirTreeIfEmpty(root)) out.push(rel(cwd, d));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return out.sort();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* agent-syncer sync —— 从内容仓库挑一个模板,装进项目的 .agents/,然后建链接。
|
|
154
|
+
*
|
|
155
|
+
* `content` 既可以是本地路径,也可以是 git 地址,自动判别:
|
|
156
|
+
* 本地路径直接读;git 地址先浅克隆到临时目录,用完即删。
|
|
157
|
+
*
|
|
158
|
+
* @param {{cwd: string, flags: Record<string, any>, input?: NodeJS.ReadStream, output?: NodeJS.WriteStream}} ctx
|
|
159
|
+
*/
|
|
160
|
+
export async function run({ cwd, flags, input, output }) {
|
|
161
|
+
const dryRun = Boolean(flags['dry-run']);
|
|
162
|
+
const assumeYes = Boolean(flags.yes);
|
|
163
|
+
|
|
164
|
+
title(`agent-syncer sync${dryRun ? dim('(--dry-run,不会写盘)') : ''}`);
|
|
165
|
+
plain(dim(`项目根:${cwd}`));
|
|
166
|
+
|
|
167
|
+
let config = loadConfig(cwd);
|
|
168
|
+
|
|
169
|
+
// 项目还没配置过,而这里又能问——与其把「agents.json 怎么写」甩回给用户,
|
|
170
|
+
// 不如当场带他走一遍 init。
|
|
171
|
+
//
|
|
172
|
+
// **三条同时成立才走**:没有配置文件 + 是交互终端 + 没给 --yes。
|
|
173
|
+
// 少一条都保持原来的行为(报错并说明怎么写):CI、管道、postinstall 里
|
|
174
|
+
// 弹提示会永久挂住,这是这个仓库里最硬的一条规矩。
|
|
175
|
+
//
|
|
176
|
+
// --dry-run 也排除在外,它不是上面三条里的任何一条,但同一个道理:init 的**全部**
|
|
177
|
+
// 产出就是那份配置文件,一边说「预演,不写盘」一边把配置写下去,是自相矛盾。
|
|
178
|
+
// 想要配置就先跑 init,或者手写一份——两条路 --dry-run 都会告诉你。
|
|
179
|
+
//
|
|
180
|
+
// no-init 是 init 反过来调 sync 时带上的信号,防止两边绕成圈。
|
|
181
|
+
if (!config.exists && !assumeYes && !dryRun && !flags['no-init'] && isInteractive(input, output)) {
|
|
182
|
+
// 动态 import:init 反过来要调 sync,静态 import 会绕成环
|
|
183
|
+
const { run: runInit } = await import('./init.js');
|
|
184
|
+
const code = await runInit({ cwd, flags: { ...flags, 'no-init': true }, input, output });
|
|
185
|
+
if (code !== 0) return code;
|
|
186
|
+
|
|
187
|
+
// 用户可能改了 content / bundle / links,甚至选了别的工具——重新读一次再往下走。
|
|
188
|
+
// 还读不到就说明他在 init 里取消了(取消时不写盘),那不是什么错误。
|
|
189
|
+
config = loadConfig(cwd);
|
|
190
|
+
if (!config.exists) {
|
|
191
|
+
plain(dim('没有写配置,就此打住。'));
|
|
192
|
+
return 0;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// 上一次装了些什么。没有这份记录,内容仓库里删掉的条目就永远清不掉——
|
|
197
|
+
// 光看文件系统分不出「本工具装的残留」和「用户自己写的」,见 record.js
|
|
198
|
+
const record = readRecord(cwd);
|
|
199
|
+
if (record.reason) warn(record.reason);
|
|
200
|
+
|
|
201
|
+
// ---- 1. 内容来源 ----
|
|
202
|
+
const sourceArg = typeof flags.from === 'string' ? flags.from : config.content;
|
|
203
|
+
if (!sourceArg) {
|
|
204
|
+
fail('没有指定内容仓库');
|
|
205
|
+
plain(
|
|
206
|
+
` 在 ${CONFIG_FILENAME} 里写 ${dim('"content": "<本地路径 或 git 地址>"')},` +
|
|
207
|
+
`或用 ${dim('--from=<路径>')}。`,
|
|
208
|
+
);
|
|
209
|
+
// 交互终端里本来会先带他走一遍 init,走到这儿只剩 --dry-run 那一种情况。
|
|
210
|
+
// 说一句,免得他以为这个命令除了报错什么都不会做。
|
|
211
|
+
if (!config.exists && !assumeYes && isInteractive(input, output)) {
|
|
212
|
+
plain(dim(' 去掉 --dry-run 再跑一次,会先带你走一遍 agent-syncer init。'));
|
|
213
|
+
}
|
|
214
|
+
return 1;
|
|
215
|
+
}
|
|
216
|
+
const ref = typeof flags.ref === 'string' ? flags.ref : config.ref;
|
|
217
|
+
|
|
218
|
+
const remote = isGitUrl(sourceArg);
|
|
219
|
+
|
|
220
|
+
// 本地路径 + 写了 ref:**说一句**。ref 只对 git 地址生效,本地路径读的是那个
|
|
221
|
+
// 目录的工作树——不吭声的话,用户以为钉在某个版本上,实际拿到的是他当前检出的
|
|
222
|
+
// 那份。这是配置和实际行为对不上,和「漂移」是同一类事,不能静默。
|
|
223
|
+
const refWarning = localRefWarning(sourceArg, ref);
|
|
224
|
+
if (refWarning) warn(refWarning);
|
|
225
|
+
/** @type {string} */
|
|
226
|
+
let repoRoot;
|
|
227
|
+
/** @type {(() => void)|null} */
|
|
228
|
+
let cleanup = null;
|
|
229
|
+
|
|
230
|
+
if (remote) {
|
|
231
|
+
info(`拉取 ${sourceArg}${ref ? ` @ ${ref}` : ''} …`);
|
|
232
|
+
try {
|
|
233
|
+
const fetched = fetchRepo({ url: sourceArg, ref });
|
|
234
|
+
repoRoot = fetched.root;
|
|
235
|
+
cleanup = fetched.cleanup;
|
|
236
|
+
} catch (e) {
|
|
237
|
+
fail(/** @type {Error} */ (e).message);
|
|
238
|
+
return 1;
|
|
239
|
+
}
|
|
240
|
+
} else {
|
|
241
|
+
repoRoot = path.resolve(cwd, sourceArg);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
try {
|
|
245
|
+
// ---- 2. 读仓库 ----
|
|
246
|
+
/** @type {{name: string, version?: string, root: string}} */
|
|
247
|
+
let repo;
|
|
248
|
+
try {
|
|
249
|
+
repo = loadRepo(repoRoot);
|
|
250
|
+
} catch (e) {
|
|
251
|
+
fail(/** @type {Error} */ (e).message);
|
|
252
|
+
return 1;
|
|
253
|
+
}
|
|
254
|
+
ok(
|
|
255
|
+
`内容仓库:${repo.name}${repo.version ? ` v${repo.version}` : ''} ` +
|
|
256
|
+
dim(remote ? `${sourceArg}${ref ? ` @ ${ref}` : ''}` : repoRoot),
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
// ---- 3. 选内容:模板并集 + 项目级 include / exclude ----
|
|
260
|
+
const bundleFlag =
|
|
261
|
+
typeof flags.bundle === 'string'
|
|
262
|
+
? flags.bundle
|
|
263
|
+
.split(',')
|
|
264
|
+
.map((s) => s.trim())
|
|
265
|
+
.filter(Boolean)
|
|
266
|
+
: [];
|
|
267
|
+
const bundles = bundleFlag.length > 0 ? bundleFlag : config.bundles;
|
|
268
|
+
const { include, exclude } = config;
|
|
269
|
+
|
|
270
|
+
// 一个模板都不选也行——纯靠 include 挑条目就是"自定义"用法
|
|
271
|
+
if (bundles.length === 0 && include.length === 0) {
|
|
272
|
+
warn('没有指定要安装的内容');
|
|
273
|
+
printBundles(repoRoot);
|
|
274
|
+
return 1;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** @type {string[]} */
|
|
278
|
+
let items;
|
|
279
|
+
/** @type {string[]} */
|
|
280
|
+
let dropped;
|
|
281
|
+
try {
|
|
282
|
+
const picked = resolveSelection(repoRoot, { bundles, include, exclude });
|
|
283
|
+
items = picked.items;
|
|
284
|
+
dropped = picked.dropped;
|
|
285
|
+
// exclude 里引用了仓库里没有的条目——不致命,但必须让人看见
|
|
286
|
+
for (const w of picked.warnings) warn(w);
|
|
287
|
+
} catch (e) {
|
|
288
|
+
fail(/** @type {Error} */ (e).message);
|
|
289
|
+
return 1;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (items.length === 0) {
|
|
293
|
+
warn('选出来的条目是空的,无事可做');
|
|
294
|
+
return 0;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const what =
|
|
298
|
+
bundles.length > 0
|
|
299
|
+
? `模板 ${bundles.join(' + ')}`
|
|
300
|
+
: '自定义 include(未使用模板)';
|
|
301
|
+
const summary = Object.entries(groupByKind(items))
|
|
302
|
+
.map(([k, v]) => `${ITEM_KINDS[k].label} ${v.length}`)
|
|
303
|
+
.join(' ');
|
|
304
|
+
ok(`${what}:${items.length} 个条目 ${dim(summary)}`);
|
|
305
|
+
if (include.length > 0 && bundles.length > 0) plain(dim(` 含项目级 include ${include.length} 条`));
|
|
306
|
+
if (dropped.length > 0) {
|
|
307
|
+
const shown = dropped.slice(0, 6).join('、');
|
|
308
|
+
info(
|
|
309
|
+
`exclude 排除了 ${dropped.length} 项:${shown}` +
|
|
310
|
+
(dropped.length > 6 ? ` … 还有 ${dropped.length - 6} 项` : ''),
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// ---- 4. 算计划 ----
|
|
315
|
+
// 锁定的条目整个从计划里摘掉:**既不覆盖也不删**。只挡删除是不够的——
|
|
316
|
+
// sync 仍会把它当普通选中项覆盖掉,用户以为锁上了、内容却被冲了,比不锁更糟。
|
|
317
|
+
const locked = new Set(config.protect);
|
|
318
|
+
const keyOf = (e) => (e.kind === 'script' ? `script:${e.id}` : `${e.kind}:${e.id}`);
|
|
319
|
+
|
|
320
|
+
const full = planInstall(repoRoot, cwd, items);
|
|
321
|
+
const plan = full.filter((e) => !locked.has(keyOf(e)));
|
|
322
|
+
/** scripts/ 整体同步,不参与条目挑选,所以单独取一遍 */
|
|
323
|
+
const scripts = plan.filter((e) => e.kind === 'script').map((e) => e.id);
|
|
324
|
+
|
|
325
|
+
if (locked.size > 0) {
|
|
326
|
+
info(`锁定 ${locked.size} 项,本工具不会动它们:`);
|
|
327
|
+
for (const k of config.protect) plain(dim(` · ${k}`));
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** @type {Record<string, typeof plan>} */
|
|
331
|
+
const by = { new: [], update: [], same: [], blocked: [] };
|
|
332
|
+
for (const e of plan) (by[e.status] ??= []).push(e);
|
|
333
|
+
|
|
334
|
+
// ---- 5. 报告 ----
|
|
335
|
+
for (const e of by.blocked) {
|
|
336
|
+
fail(`${e.spec} 路径上有链接,拒绝覆盖:${path.relative(cwd, e.linkAt ?? e.to)}`);
|
|
337
|
+
}
|
|
338
|
+
for (const e of by.new) ok(`${e.spec} ${dim('新增')}`);
|
|
339
|
+
for (const e of by.update) info(`${e.spec} ${dim('更新(本地同名内容会被覆盖)')}`);
|
|
340
|
+
|
|
341
|
+
if (by.blocked.length > 0) {
|
|
342
|
+
title('需要你处理');
|
|
343
|
+
plain(' 下面这些路径(它自己或它的上层目录)是链接,sync 不会写进链接指向的地方:');
|
|
344
|
+
for (const e of by.blocked) {
|
|
345
|
+
const at = e.linkAt ?? e.to;
|
|
346
|
+
plain(` · ${path.relative(cwd, at)}${at === e.to ? '' : dim('(是上层目录)')}`);
|
|
347
|
+
}
|
|
348
|
+
// 链接指向的地方很可能还有别的项目在管同一批内容(每个项目的记录各管各的),
|
|
349
|
+
// 这边写进去、那边 --prune 删掉,会互相踩
|
|
350
|
+
plain(' 那儿可能还有别的项目在管同一批内容。先删掉链接或改用 link 管理,再重跑。');
|
|
351
|
+
return 1;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// ---- 6. 落盘 ----
|
|
355
|
+
const changed = by.new.length + by.update.length;
|
|
356
|
+
if (changed === 0) {
|
|
357
|
+
ok(`内容已是最新(${by.same.length} 项内容一致)`);
|
|
358
|
+
} else if (dryRun) {
|
|
359
|
+
info(`将写入 ${changed} 项:新增 ${by.new.length},更新 ${by.update.length}`);
|
|
360
|
+
} else {
|
|
361
|
+
const { written, problems } = applyInstall(plan);
|
|
362
|
+
ok(`已写入 ${written} 项`);
|
|
363
|
+
if (problems.length > 0) {
|
|
364
|
+
title('写入失败');
|
|
365
|
+
for (const p of problems) plain(` · ${p.spec}:${p.message}`);
|
|
366
|
+
return 1;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// ---- 7. 清理:本工具以前装的、这次不再需要的 ----
|
|
371
|
+
//
|
|
372
|
+
// 这一节是这套东西里唯一会删用户目录的地方。判定只用明面上的两条:
|
|
373
|
+
// **记录里说装过**,且**没被 protect 锁住**。
|
|
374
|
+
// 用户从头自己写的(不在记录里)一律不碰;想保住某个装过的条目,
|
|
375
|
+
// 就在 agents.json 的 protect 里写上它——不写,--prune 就会删。
|
|
376
|
+
const prune = Boolean(flags.prune);
|
|
377
|
+
/**
|
|
378
|
+
* 这次没删成的要留在新记录里继续跟踪。从记录里抹掉就等于失联:
|
|
379
|
+
* 下次同步再也说不出「这是本工具装的」,用户漏看一次就永远找不回来了。
|
|
380
|
+
* @type {string[]}
|
|
381
|
+
*/
|
|
382
|
+
const carry = [];
|
|
383
|
+
/** @type {string[]} */
|
|
384
|
+
const failed = [];
|
|
385
|
+
|
|
386
|
+
// 锁住的条目也要留在记录里:它确实是本工具装的,将来从 protect 里去掉
|
|
387
|
+
// 之后还该认得出它是残留。不留下的话它会变成「不是本工具装的」,那就说错了。
|
|
388
|
+
if (record.usable) carry.push(...config.protect.filter((k) => isTracked(record, k)));
|
|
389
|
+
|
|
390
|
+
if (record.usable) {
|
|
391
|
+
const removable = planRemovals(cwd, record, { items, scripts, protect: config.protect });
|
|
392
|
+
|
|
393
|
+
if (removable.length > 0) {
|
|
394
|
+
if (!prune) {
|
|
395
|
+
carry.push(...removable.map((r) => r.key));
|
|
396
|
+
warn(`有 ${removable.length} 项是以前装的、现在不需要了——不会自动删除`);
|
|
397
|
+
for (const r of removable.slice(0, 8)) plain(dim(` · ${r.key}`));
|
|
398
|
+
if (removable.length > 8) plain(dim(` … 还有 ${removable.length - 8} 项`));
|
|
399
|
+
plain(dim(' 加 --prune 删掉;要留住其中某个,写进 agents.json 的 protect。'));
|
|
400
|
+
} else if (dryRun) {
|
|
401
|
+
carry.push(...removable.map((r) => r.key));
|
|
402
|
+
info(`将删除 ${removable.length} 项不再需要的内容:${removable.slice(0, 6).map((r) => r.key).join('、')}`);
|
|
403
|
+
} else {
|
|
404
|
+
for (const r of removable) {
|
|
405
|
+
try {
|
|
406
|
+
fs.rmSync(r.abs, { recursive: true, force: true });
|
|
407
|
+
ok(`${r.key} ${dim('已删除(不再需要)')}`);
|
|
408
|
+
} catch (e) {
|
|
409
|
+
fail(`${r.key} 删除失败:${/** @type {Error} */ (e).message}`);
|
|
410
|
+
failed.push(`${r.key}:${/** @type {Error} */ (e).message}`);
|
|
411
|
+
carry.push(r.key);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
// 收掉因此空掉的目录。git 不跟踪空目录,所以留着不影响版本库;
|
|
415
|
+
// 但 `.agents/skills/` 空着和不存在在 status 里是两种说法,
|
|
416
|
+
// 而且删空了却不收,下次 sync 又会把它建出来,看着像没删干净。
|
|
417
|
+
const emptied = pruneEmptyDirs(cwd, removable);
|
|
418
|
+
if (emptied.length > 0) info(`顺带收掉空目录:${emptied.join('、')}`);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// ---- 8. 合并 hooks / mcp ----
|
|
424
|
+
//
|
|
425
|
+
// 这两类没法像前四类那样建链接——它们要写进各工具**自己的**配置文件,而
|
|
426
|
+
// 那些文件里还有用户自己的东西,不能整份覆盖。所以是「读—改—写」,而且
|
|
427
|
+
// 归属全靠记录验证(见 merge.js 开头那段)。
|
|
428
|
+
//
|
|
429
|
+
// 因此这一步必须在 writeRecord **之前**:本轮合并了什么,要和记录一起写下去,
|
|
430
|
+
// 下一轮才认得出哪些是我们的。
|
|
431
|
+
const merge = runMerge({ cwd, plan, config, record, dryRun, prune });
|
|
432
|
+
|
|
433
|
+
for (const a of merge.applied) {
|
|
434
|
+
const line = `${a.rel} ${dim(`${a.kind} 合并`)}`;
|
|
435
|
+
if (dryRun) info(`将写入 ${line}`);
|
|
436
|
+
else if (a.created) ok(`新建 ${line}`);
|
|
437
|
+
else info(`更新 ${line}`);
|
|
438
|
+
|
|
439
|
+
// 没实证的路不能报得跟 Claude 那条一样肯定
|
|
440
|
+
if (!a.verified) {
|
|
441
|
+
plain(dim(` ⚠️ ${TOOLS[a.tool].label} 的这条路径未经实证(本机没装),可能不生效`));
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
for (const w of merge.warnings) warn(w);
|
|
446
|
+
|
|
447
|
+
// 冲突 = 「解析下来本该是我们的,但现场不像」。保留 + 报出来,绝不覆盖回去。
|
|
448
|
+
for (const c of merge.conflicts) {
|
|
449
|
+
warn(`${TOOLS[c.tool].label} · ${c.rel}:有 ${c.messages.length} 条本工具没动`);
|
|
450
|
+
for (const m of c.messages) plain(dim(` · ${m}`));
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
if (merge.stale.length > 0) {
|
|
454
|
+
const n = merge.stale.reduce((s, x) => s + x.ids.length, 0);
|
|
455
|
+
if (prune) {
|
|
456
|
+
if (dryRun) info(`将摘掉 ${n} 条不再需要的合并产物`);
|
|
457
|
+
} else {
|
|
458
|
+
// 和内容层一个口径:默认只报告不删
|
|
459
|
+
warn(`有 ${n} 条以前合并进工具配置的、现在不需要了——不会自动摘除`);
|
|
460
|
+
for (const s of merge.stale) {
|
|
461
|
+
plain(dim(` · ${TOOLS[s.tool].label} ${s.kind}:${s.ids.join('、')}`));
|
|
462
|
+
}
|
|
463
|
+
plain(dim(' 加 --prune 摘掉;要留住其中某个,写进 agents.json 的 protect。'));
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// 删掉 status 里那句「合并器未实现」之后,这里是唯一还会为这种情况说话的地方:
|
|
468
|
+
// 内容装上了,但声明的工具里没有一个能承接它。
|
|
469
|
+
for (const u of merge.unsupported) {
|
|
470
|
+
warn(`${TOOLS[u.tool].label} 没有 ${u.kind} 的合并目标——这 ${u.count} 条装了也不会生效`);
|
|
471
|
+
plain(
|
|
472
|
+
dim(` 内容在 ${CONTENT_ROOT}/${u.kind}/ 里存着。该工具的配置格式本工具暂不支持,`) +
|
|
473
|
+
dim('需要手工处理。'),
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// 写进去 ≠ 生效。这是最容易让人查半天的一条。
|
|
478
|
+
//
|
|
479
|
+
// 判据是「有没有 MCP 内容要合」,**不是「本轮有没有变化」**:新克隆的机器上
|
|
480
|
+
// `.mcp.json` 已经提交在仓库里、内容一模一样,本轮恰恰不会有任何变化——
|
|
481
|
+
// 而那正是最需要这句提醒的时候。
|
|
482
|
+
if (config.tools.includes('claude') && Object.keys(merge.sourcesByKind.mcp ?? {}).length > 0) {
|
|
483
|
+
plain('');
|
|
484
|
+
plain(dim(' 提醒:MCP server 还要在 Claude Code 里**逐条批准**才会生效。'));
|
|
485
|
+
plain(dim(' 批准记录存在你本机的 ~/.claude.json,不随仓库共享——队友要各自批一次。'));
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
if (merge.problems.length > 0) {
|
|
489
|
+
title('合并失败');
|
|
490
|
+
for (const p of merge.problems) plain(` · ${p}`);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// 记录必须跟着本次结果重写,否则下次同步会拿旧账来对。
|
|
494
|
+
// 注意**不能放进上面的 `if (record.usable)`**——首次运行时记录还不存在,
|
|
495
|
+
// 那正是它最需要被写下来的时刻。
|
|
496
|
+
if (!dryRun) {
|
|
497
|
+
// 读不出来的那份(坏 JSON / BOM / 别的版本写的)先留个档再重建:
|
|
498
|
+
// `writeRecord` 是整份重写,不备份的话盘上那份就永久没了,而它记的归属
|
|
499
|
+
// 没有第二处能推导出来。
|
|
500
|
+
if (!record.usable && record.reason) {
|
|
501
|
+
const bak = backupUnreadableRecord(cwd);
|
|
502
|
+
if (bak) warn(`原来那份记录读不了,已备份成 ${bak},本次重建一份新的`);
|
|
503
|
+
}
|
|
504
|
+
writeRecord(cwd, carryOver(snapshotRecord(cwd, items, scripts, merge.merged), carry));
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// ---- 9. 本地多余项(只报告,不删)----
|
|
508
|
+
//
|
|
509
|
+
// 有记录之后就能分清两类了:记录里有的上面已经处理过;记录里没有的
|
|
510
|
+
// 就是用户自己写的,没必要每跑一次就唠叨一遍。还没记录时(首次运行、
|
|
511
|
+
// 或记录坏了)分不清,沿用老行为全部列出来。
|
|
512
|
+
const orphans = findOrphans(cwd, items);
|
|
513
|
+
const known = new Set(record.installed);
|
|
514
|
+
const theirs = orphans.filter((o) => !known.has(o));
|
|
515
|
+
if (theirs.length > 0) {
|
|
516
|
+
if (!record.usable) {
|
|
517
|
+
warn(`.agents/ 下有 ${theirs.length} 项不在本模板里——不会自动删除`);
|
|
518
|
+
for (const o of theirs.slice(0, 8)) plain(dim(` · ${o}`));
|
|
519
|
+
if (theirs.length > 8) plain(dim(` … 还有 ${theirs.length - 8} 项`));
|
|
520
|
+
plain(dim(' 可能是上个模板的残留,也可能是你自己加的。确认后手工处理。'));
|
|
521
|
+
} else {
|
|
522
|
+
plain(dim(`(另有 ${theirs.length} 项不在本工具记录里,未做处理)`));
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
if (failed.length > 0) {
|
|
527
|
+
title('删除失败');
|
|
528
|
+
for (const f of failed) plain(` · ${f}`);
|
|
529
|
+
return 1;
|
|
530
|
+
}
|
|
531
|
+
if (!record.usable && !dryRun) {
|
|
532
|
+
plain(dim(`(本次会写下 ${RECORD_REL},下次起就能认出哪些是本工具装的了)`));
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// ---- 10. 建链接 ----
|
|
536
|
+
if (dryRun) {
|
|
537
|
+
plain(dim('\n这是预演,未写盘。去掉 --dry-run 即可实际执行。'));
|
|
538
|
+
// 预演也要如实反映故障:同一份现场,`sync` 退 1 而 `sync --dry-run` 退 0,
|
|
539
|
+
// 就是「预演是绿的、真跑是红的」——CI 里只看退出码的人会被骗一道。
|
|
540
|
+
// 「同一现场同一结论」是 merge.js 里 checkMergeAll 那段定下的口径。
|
|
541
|
+
return merge.problems.length > 0 ? 1 : 0;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
plain('');
|
|
545
|
+
// **只透传 link 认识的选项**,不能整个 flags 递过去——`--src`/`--dst` 会把
|
|
546
|
+
// 它拨到直连模式,托管链接就一条都不建了(见 linkFlags 那段)
|
|
547
|
+
const linkCode = await runLink({ cwd, flags: linkFlags(flags), input, output });
|
|
548
|
+
// 合并出了硬问题(内容文件坏了、目标写不进去)也要反映在退出码上,
|
|
549
|
+
// 否则 CI 里只看退出码的人会以为一切都好
|
|
550
|
+
return linkCode !== 0 ? linkCode : merge.problems.length > 0 ? 1 : 0;
|
|
551
|
+
} finally {
|
|
552
|
+
// 拉取下来的临时目录用完即删——无论成功、失败还是抛错
|
|
553
|
+
if (cleanup) cleanup();
|
|
554
|
+
}
|
|
555
|
+
}
|