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,332 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import process from 'node:process';
|
|
5
|
+
import { CONFIG_FILENAME, loadConfig, mergeConfig, readConfigRaw, writeConfig } from '../config.js';
|
|
6
|
+
import { dim, fail, info, ok, plain, skip, title, warn } from '../log.js';
|
|
7
|
+
import { listBundles, loadRepo } from '../manifest.js';
|
|
8
|
+
import { checkbox, confirm, isInteractive, select, text } from '../prompt.js';
|
|
9
|
+
import { fetchRepo, isGitUrl, listRefs, orderRefs } from '../source.js';
|
|
10
|
+
import { TOOL_NAMES } from '../target.js';
|
|
11
|
+
import { buildLinkChoices } from './link.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* 取消(Ctrl-C,或者对「确认写入」答了否)时的统一收尾。
|
|
15
|
+
*
|
|
16
|
+
* **退出码是 0**:走到这一步什么都还没写,用户只是不想继续了,
|
|
17
|
+
* 不是出错。报非零会让脚本把「他自己取消的」当成失败。
|
|
18
|
+
*/
|
|
19
|
+
function cancelled() {
|
|
20
|
+
plain(dim('已取消,未做任何改动。'));
|
|
21
|
+
return 0;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 非交互环境下的退路:把配置该怎么写直接摊开。
|
|
26
|
+
*
|
|
27
|
+
* 和 link 的做法一致——**绝不猜,也绝不问**。在 CI / 管道 / postinstall 里
|
|
28
|
+
* 弹提示会永久挂住,这个仓库里没有比这更硬的规矩。
|
|
29
|
+
*/
|
|
30
|
+
function nonInteractiveHelp() {
|
|
31
|
+
fail('init 需要交互终端');
|
|
32
|
+
plain(' 当前不是交互终端(管道、CI、postinstall 里弹提示会永久挂住,所以不问)。');
|
|
33
|
+
plain(` 手写一份 ${CONFIG_FILENAME} 放在项目根即可:`);
|
|
34
|
+
const sample = {
|
|
35
|
+
content: '<内容仓库:本地路径 或 git 地址>',
|
|
36
|
+
ref: '<分支 / 标签 / 提交 SHA,可省略>',
|
|
37
|
+
bundle: 'java-backend',
|
|
38
|
+
links: ['claude'],
|
|
39
|
+
};
|
|
40
|
+
for (const line of JSON.stringify(sample, null, 2).split('\n')) plain(` ${dim(line)}`);
|
|
41
|
+
plain('');
|
|
42
|
+
plain(` links 里写${dim('工具名')}(可用:${TOOL_NAMES.join('、')})。`);
|
|
43
|
+
plain(` 不知道有哪些模板:agent-syncer list --from=<内容仓库>`);
|
|
44
|
+
return 1;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* `--bundle=a,b` 的解析。和 sync 里那段保持一致——同一个选项在两个命令里
|
|
49
|
+
* 意思不一样会让人踩坑。
|
|
50
|
+
*
|
|
51
|
+
* @param {any} value @returns {string[]}
|
|
52
|
+
*/
|
|
53
|
+
function splitBundles(value) {
|
|
54
|
+
return typeof value === 'string'
|
|
55
|
+
? value
|
|
56
|
+
.split(',')
|
|
57
|
+
.map((s) => s.trim())
|
|
58
|
+
.filter(Boolean)
|
|
59
|
+
: [];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* agent-syncer init —— 交互式生成 `agents.json`。
|
|
64
|
+
*
|
|
65
|
+
* 依次问五件事:内容仓库在哪 → 用哪个版本(ref)→ 装哪些模板 → 适配哪些工具
|
|
66
|
+
* → 确认。**只有最后确认过才落盘**,中途 Ctrl-C 一律什么都不写。
|
|
67
|
+
*
|
|
68
|
+
* 每一步都尽量**跳过而不是失败**:内容仓库不是 git 仓库、离线、要凭据——
|
|
69
|
+
* 取不到 refs 就跳过第二步,取不到 bundles 就跳过第三步。用户先把一份能用的
|
|
70
|
+
* 配置写下来,比被一个「拿不到列表」卡住有价值得多。
|
|
71
|
+
*
|
|
72
|
+
* 输入输出可注入,生产环境用默认值,测试时可传替身跑完整交互链路。
|
|
73
|
+
*
|
|
74
|
+
* @param {{cwd: string, flags?: Record<string, any>, input?: NodeJS.ReadStream, output?: NodeJS.WriteStream}} ctx
|
|
75
|
+
*/
|
|
76
|
+
export async function run({ cwd, flags = {}, input = process.stdin, output = process.stdout }) {
|
|
77
|
+
title('agent-syncer init');
|
|
78
|
+
plain(dim(`项目根:${cwd}`));
|
|
79
|
+
|
|
80
|
+
if (!isInteractive(input, output)) return nonInteractiveHelp();
|
|
81
|
+
|
|
82
|
+
/** @type {ReturnType<typeof loadConfig>} */
|
|
83
|
+
let config;
|
|
84
|
+
try {
|
|
85
|
+
config = loadConfig(cwd);
|
|
86
|
+
} catch (e) {
|
|
87
|
+
// 读不出来就得停:init 会把整份文件重写一遍,而它读不出原来写了什么。
|
|
88
|
+
//
|
|
89
|
+
// **别建议「删掉重来」**——这份文件里可能有用户手写的 include / exclude /
|
|
90
|
+
// protect,而它们**没有第二处能推导出来**,init 恰恰就是那个会把整份重写掉的命令。
|
|
91
|
+
// 一句轻飘飘的「删掉重来」正好把人推向最坏的那条路。
|
|
92
|
+
fail(/** @type {Error} */ (e).message);
|
|
93
|
+
plain(' init 要把整份文件重写一遍,读不出来就不能往下走——先修好它。');
|
|
94
|
+
plain(dim(' 里面的 include / exclude / protect 是你手写的,本工具没有第二处能推出它们。'));
|
|
95
|
+
return 1;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** 文件里原有的字段,逐字保留——这是「不做覆盖式重写」的依据 */
|
|
99
|
+
const existing = config.exists ? readConfigRaw(cwd) : {};
|
|
100
|
+
|
|
101
|
+
// 已经配过一次的项目,先把现状摆出来再问要不要重来。这是**两道确认里的第一道**:
|
|
102
|
+
// 第一道问「要不要重新生成」,最后一道问「就写这些吗」。
|
|
103
|
+
if (config.exists) {
|
|
104
|
+
plain('');
|
|
105
|
+
info(`${CONFIG_FILENAME} 已存在,只重问下面几项,其它字段照旧`);
|
|
106
|
+
for (const line of JSON.stringify(existing, null, 2).split('\n')) plain(` ${dim(line)}`);
|
|
107
|
+
const go = await confirm({ message: `重新生成 ${CONFIG_FILENAME}?`, initial: true, input, output });
|
|
108
|
+
if (!go) return cancelled();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ---- 第 1 步:内容仓库 ----
|
|
112
|
+
//
|
|
113
|
+
// --from / agents.json 里的 content 都当作**预填值**:用户上一秒才在命令行里
|
|
114
|
+
// 说过的事,没有道理再让他手打一遍。
|
|
115
|
+
title('第 1 步 · 内容仓库');
|
|
116
|
+
const typed = await text({
|
|
117
|
+
message: '内容仓库在哪?(本地路径 或 git 地址)',
|
|
118
|
+
initial: typeof flags.from === 'string' ? flags.from : (config.content ?? ''),
|
|
119
|
+
validate: (v) => (v.trim() === '' ? '不能为空:写一个本地路径或 git 地址' : null),
|
|
120
|
+
input,
|
|
121
|
+
output,
|
|
122
|
+
});
|
|
123
|
+
if (typed === null) return cancelled();
|
|
124
|
+
const source = typed.trim();
|
|
125
|
+
|
|
126
|
+
// 路径不存在不是错误:内容仓库可能还没克隆下来,配置先写下来照样有用。
|
|
127
|
+
// 但不说一声的话,用户会以为已经万事俱备了。
|
|
128
|
+
if (!isGitUrl(source) && !fs.existsSync(path.resolve(cwd, source))) {
|
|
129
|
+
warn(`这个路径不存在:${path.resolve(cwd, source)}——配置先写下来也行,sync 会再报一次`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ---- 第 2 步:ref ----
|
|
133
|
+
//
|
|
134
|
+
// 三态,别混成一个:
|
|
135
|
+
// undefined —— 这一步跳过了(取不到 refs),文件里原来的 ref 原样保留
|
|
136
|
+
// null —— 用户明确选了「不指定」,把 ref 字段删掉
|
|
137
|
+
// 字符串 —— 用户选了一个分支或标签
|
|
138
|
+
title('第 2 步 · 版本(ref)');
|
|
139
|
+
/** @type {string|null|undefined} */
|
|
140
|
+
let pickedRef;
|
|
141
|
+
|
|
142
|
+
// **本地路径不问这一项。** 一个本地克隆目录也是 git 仓库,分支标签都列得出来,
|
|
143
|
+
// 但 `sync` 读的是那个目录的**工作树**(这正是本地路径「改完内容立刻能试」的
|
|
144
|
+
// 来由),`ref` 一个字都用不上。让用户选一个不生效的版本比不让他选更糟——
|
|
145
|
+
// 他会以为钉住了。要钉版本就把 content 写成 git 地址。
|
|
146
|
+
//
|
|
147
|
+
// 注意这里**不把 --ref 写进配置**:写下去就是个不生效的键,和「选了不生效」
|
|
148
|
+
// 是同一个坑。给过 --ref 的人本来就想要版本钉死,那句话得让他听见。
|
|
149
|
+
const refs = isGitUrl(source) ? listRefs({ source, cwd }) : null;
|
|
150
|
+
|
|
151
|
+
if (!isGitUrl(source)) {
|
|
152
|
+
skip('本地路径由你自己的检出决定内容,没有版本可选——跳过这一步');
|
|
153
|
+
const flagRef = typeof flags.ref === 'string' ? flags.ref.trim() : '';
|
|
154
|
+
if (flagRef !== '') {
|
|
155
|
+
warn(`--ref=${flagRef} 没写进配置:对本地路径不生效。要钉版本请把内容仓库写成 git 地址`);
|
|
156
|
+
}
|
|
157
|
+
} else if (refs === null || (refs.branches.length === 0 && refs.tags.length === 0)) {
|
|
158
|
+
// 「不是 git 仓库」「离线」「要凭据」「超时」在这里是同一种处境:问不出选项。
|
|
159
|
+
// 一行说明带过,不报错——这正是 listRefs 返回 null 而不是抛错的原因。
|
|
160
|
+
skip('取不到分支和标签(不是 git 仓库、离线,或需要凭据)——跳过这一步');
|
|
161
|
+
if (typeof flags.ref === 'string' && flags.ref.trim() !== '') pickedRef = flags.ref.trim();
|
|
162
|
+
if (pickedRef) plain(dim(` 沿用你给的 --ref=${pickedRef}`));
|
|
163
|
+
} else {
|
|
164
|
+
// 「不指定」和「选 main」行为很接近,都得每次 sync 重新取最新——真正的区别在
|
|
165
|
+
// 标签:标签是钉死的版本,内容不会跟着仓库走。这句话不说,用户没理由知道
|
|
166
|
+
// 该不该选。
|
|
167
|
+
plain(dim(' 不指定 / 分支:每次 sync 取最新 标签:内容钉死,仓库变了也不跟'));
|
|
168
|
+
const tagSet = new Set(refs.tags);
|
|
169
|
+
const choices = [
|
|
170
|
+
{ value: '', label: '不指定', note: '(取仓库默认分支的最新)' },
|
|
171
|
+
...orderRefs(refs).map((name) => ({
|
|
172
|
+
value: name,
|
|
173
|
+
label: name,
|
|
174
|
+
note: tagSet.has(name) ? '(标签:钉死的版本)' : '',
|
|
175
|
+
})),
|
|
176
|
+
];
|
|
177
|
+
|
|
178
|
+
// 只预填用户说过的那个值(--ref 或文件里已有的 ref)。**不替他猜 main**:
|
|
179
|
+
// 仓库同时有 main 和 master 时猜错方向,会把他悄悄钉在一个过时的分支上。
|
|
180
|
+
const flagRef = typeof flags.ref === 'string' ? flags.ref.trim() : '';
|
|
181
|
+
const want = flagRef !== '' ? flagRef : config.ref;
|
|
182
|
+
const found = want ? choices.findIndex((c) => c.value === want) : -1;
|
|
183
|
+
|
|
184
|
+
const picked = await select({
|
|
185
|
+
message: '用哪个版本?',
|
|
186
|
+
choices,
|
|
187
|
+
initial: found >= 0 ? found : 0,
|
|
188
|
+
filterable: true,
|
|
189
|
+
input,
|
|
190
|
+
output,
|
|
191
|
+
});
|
|
192
|
+
if (picked === null) return cancelled();
|
|
193
|
+
pickedRef = picked === '' ? null : picked;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ---- 第 3 步:模板 ----
|
|
197
|
+
//
|
|
198
|
+
// 到这里必须真的看到 bundles/*.json 了,所以要拿到仓库的本地根目录(远端就浅克隆
|
|
199
|
+
// 一份,和 list 命令一样)。取不到**不算错**——没网也得能把配置写下来,
|
|
200
|
+
// 只是模板留空、由用户之后自己补。
|
|
201
|
+
title('第 3 步 · 模板(bundle)');
|
|
202
|
+
const flagBundles = splitBundles(flags.bundle);
|
|
203
|
+
const wantBundles = flagBundles.length > 0 ? flagBundles : config.bundles;
|
|
204
|
+
/** @type {string[]|undefined} */
|
|
205
|
+
let pickedBundles;
|
|
206
|
+
|
|
207
|
+
/** @type {string|null} */
|
|
208
|
+
let repoRoot = null;
|
|
209
|
+
/** @type {(() => void)|null} */
|
|
210
|
+
let cleanup = null;
|
|
211
|
+
/** @type {string|null} */
|
|
212
|
+
let repoProblem = null;
|
|
213
|
+
try {
|
|
214
|
+
if (isGitUrl(source)) {
|
|
215
|
+
const fetched = fetchRepo({ url: source, ref: pickedRef ?? undefined });
|
|
216
|
+
repoRoot = fetched.root;
|
|
217
|
+
cleanup = fetched.cleanup;
|
|
218
|
+
} else {
|
|
219
|
+
repoRoot = path.resolve(cwd, source);
|
|
220
|
+
}
|
|
221
|
+
loadRepo(repoRoot);
|
|
222
|
+
} catch (e) {
|
|
223
|
+
repoProblem = /** @type {Error} */ (e).message;
|
|
224
|
+
repoRoot = null;
|
|
225
|
+
if (cleanup) {
|
|
226
|
+
cleanup();
|
|
227
|
+
cleanup = null;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
try {
|
|
232
|
+
if (repoRoot === null) {
|
|
233
|
+
warn(`读不到内容仓库,跳过模板选择:${repoProblem}`);
|
|
234
|
+
// 用户明确给过的 --bundle 别丢:写进配置至少能让 sync 的报错说清是哪个模板不对
|
|
235
|
+
if (flagBundles.length > 0) pickedBundles = flagBundles;
|
|
236
|
+
plain(dim(` 之后可以在 ${CONFIG_FILENAME} 里补 "bundle"。`));
|
|
237
|
+
} else {
|
|
238
|
+
const bundles = listBundles(repoRoot);
|
|
239
|
+
const available = new Set(bundles.map((b) => b.name));
|
|
240
|
+
|
|
241
|
+
// `--bundle=<拼错的名字>` 默认会**悄悄蒸发**:那几项匹配不上 → 复选框一个都不勾 →
|
|
242
|
+
// 用户回车 → `bundle` 键被删掉,全程没有一句提示。人以为自己选了模板,
|
|
243
|
+
// 拿到的配置里没有它——和 `link` 拒绝静默兼容 `"claude/skills"` 是同一个道理。
|
|
244
|
+
const unknown = flagBundles.filter((n) => !available.has(n));
|
|
245
|
+
if (unknown.length > 0) {
|
|
246
|
+
fail(`--bundle 里的模板在仓库里不存在:${unknown.join('、')}`);
|
|
247
|
+
plain(
|
|
248
|
+
` 可用的模板:${
|
|
249
|
+
bundles.length > 0 ? bundles.map((b) => b.name).join('、') : '(一个都没有)'
|
|
250
|
+
}`,
|
|
251
|
+
);
|
|
252
|
+
return 1;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (bundles.length === 0) {
|
|
256
|
+
// 这一步是**跳过**,不是「用户选了空」。仓库里没有模板 ≠ 他想把原来的模板清掉,
|
|
257
|
+
// 所以保持 undefined(原样保留),和 ref 那一步是同一个三态口径。
|
|
258
|
+
skip('仓库里没有任何模板(只能靠 include 逐条挑)');
|
|
259
|
+
if (flagBundles.length > 0) pickedBundles = flagBundles;
|
|
260
|
+
plain(dim(` 之后可以在 ${CONFIG_FILENAME} 里补 "bundle"。`));
|
|
261
|
+
} else {
|
|
262
|
+
plain(dim(' 模板可以多选,取并集;一个都不选也行(之后用 include 逐条挑)'));
|
|
263
|
+
const picked = await checkbox({
|
|
264
|
+
message: '这个项目要装哪些模板?',
|
|
265
|
+
choices: bundles.map((b) => ({
|
|
266
|
+
value: b.name,
|
|
267
|
+
label: b.title ? `${b.name} · ${b.title}` : b.name,
|
|
268
|
+
note: b.description ?? '',
|
|
269
|
+
checked: wantBundles.includes(b.name),
|
|
270
|
+
})),
|
|
271
|
+
input,
|
|
272
|
+
output,
|
|
273
|
+
});
|
|
274
|
+
if (picked === null) return cancelled();
|
|
275
|
+
pickedBundles = picked;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ---- 第 4 步:links ----
|
|
280
|
+
//
|
|
281
|
+
// 复用 link 命令的 choices:note 里写着「暂无内容」「会新建 .claude/」,
|
|
282
|
+
// 那正是用户要判断的东西。复制一份出来的话,两边迟早会长得不一样。
|
|
283
|
+
title('第 4 步 · 编码工具');
|
|
284
|
+
const tools = await checkbox({
|
|
285
|
+
message: `这个项目用哪些编码工具?(写进 ${CONFIG_FILENAME} 的 "links")`,
|
|
286
|
+
choices: buildLinkChoices(cwd, config.tools),
|
|
287
|
+
input,
|
|
288
|
+
output,
|
|
289
|
+
});
|
|
290
|
+
if (tools === null) return cancelled();
|
|
291
|
+
if (tools.length === 0) {
|
|
292
|
+
warn('一个工具都没选——links 会是空的,link / sync 跑起来会无事可做');
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// ---- 第 5 步:确认、落盘、问要不要 sync ----
|
|
296
|
+
title('第 5 步 · 确认');
|
|
297
|
+
/** @type {Record<string, any>} */
|
|
298
|
+
const patch = { content: source, links: tools };
|
|
299
|
+
if (pickedRef !== undefined) patch.ref = pickedRef;
|
|
300
|
+
if (pickedBundles !== undefined) patch.bundle = pickedBundles;
|
|
301
|
+
|
|
302
|
+
// 预览走的就是 saveConfig 内部那一套合并逻辑(同一个纯函数),所以「看到的」
|
|
303
|
+
// 和「写下去的」不可能不一致——包括文件里原有字段被保留这件事。
|
|
304
|
+
const next = mergeConfig(existing, patch);
|
|
305
|
+
plain(` 将要写入 ${CONFIG_FILENAME}:`);
|
|
306
|
+
for (const line of JSON.stringify(next, null, 2).split('\n')) plain(` ${dim(line)}`);
|
|
307
|
+
if (config.exists) plain(dim(' (上面没提到的原有字段照旧保留)'));
|
|
308
|
+
|
|
309
|
+
const write = await confirm({ message: `写入 ${CONFIG_FILENAME}?`, initial: true, input, output });
|
|
310
|
+
if (!write) return cancelled();
|
|
311
|
+
|
|
312
|
+
// 写下去的**就是刚才预览的那一个对象**——不再读一次文件。两次读之间文件被改了,
|
|
313
|
+
// 「看到的」和「写下去的」就会不一样,而那正是这份预览存在的唯一理由。
|
|
314
|
+
const written = writeConfig(cwd, next);
|
|
315
|
+
ok(`${CONFIG_FILENAME} 已写入 → ${path.relative(cwd, written)}`);
|
|
316
|
+
|
|
317
|
+
const now = await confirm({ message: '现在执行一次 sync 吗?', initial: true, input, output });
|
|
318
|
+
if (!now) {
|
|
319
|
+
plain(dim('\n之后跑 agent-syncer sync 即可拉取内容并建链接。'));
|
|
320
|
+
return 0;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// 动态 import:init 和 sync 互相调用,静态 import 会绕成环。
|
|
324
|
+
// no-init 是给 sync 的信号——配置这时已经写好,正常情况下它也不会再走 init,
|
|
325
|
+
// 这一条是防止将来有人改了那个判断条件后变成套娃。
|
|
326
|
+
const { run: runSync } = await import('./sync.js');
|
|
327
|
+
return await runSync({ cwd, flags: { ...flags, 'no-init': true }, input, output });
|
|
328
|
+
} finally {
|
|
329
|
+
// 浅克隆下来的临时目录用完即删——无论成功、失败还是中途取消
|
|
330
|
+
if (cleanup) cleanup();
|
|
331
|
+
}
|
|
332
|
+
}
|