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
package/lib/config.js
CHANGED
|
@@ -1,170 +1,377 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
} from './target.js';
|
|
4
|
+
import { ITEM_KINDS, ITEM_KIND_NAMES, SUPPORT_DIR } from './manifest.js';
|
|
5
|
+
// 借 merge.js 的两样东西:原子写和剥 BOM。
|
|
6
|
+
//
|
|
7
|
+
// 这个文件的依赖方向确实有点逆(config 比 merge 底层),但两个都是**安全原语**
|
|
8
|
+
// ——「tmp + rename」和「JSON 文本怎么读」,再抄一份出来比这个方向更糟:
|
|
9
|
+
// link.js 里还有一份内联的原子写,而 BOM 这件事原先在三处各有一个说法,
|
|
10
|
+
// record.js 那份干脆没有,于是记事本改一下记录就让归属全丢。
|
|
11
|
+
import { isMissingPath, stripBom, writeJsonFile } from './merge.js';
|
|
12
|
+
import { TOOL_NAMES, isTool, sortTools, specsOfTools } from './target.js';
|
|
13
13
|
|
|
14
14
|
export const CONFIG_FILENAME = 'agents.json';
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* 读 `agents.json` 的原文。三种结果分得很开,**混起来就是上次那个 blocker**:
|
|
18
|
+
*
|
|
19
|
+
* - 文件不存在 → `null`(调用方按「还没配过」处理)
|
|
20
|
+
* - 有东西但**读不到**(权限 / IO)→ 抛
|
|
21
|
+
* - 读到了但**不是合法 JSON** → 抛
|
|
22
|
+
*
|
|
23
|
+
* 后两种一旦被降级成「不存在」,调用方拿到一份空配置、紧接着就是**整份重写**,
|
|
24
|
+
* 用户的 `content` / `bundle` / `include` / `protect` 会一次被抹掉。判据和
|
|
25
|
+
* `merge.js` 里那条一样:读失败装作不存在、然后接着写,是**进攻方向**。
|
|
26
|
+
*
|
|
27
|
+
* @param {string} projectRoot
|
|
28
|
+
* @returns {string|null}
|
|
29
|
+
*/
|
|
30
|
+
function readConfigText(projectRoot) {
|
|
31
|
+
const p = path.resolve(projectRoot, CONFIG_FILENAME);
|
|
32
|
+
try {
|
|
33
|
+
return fs.readFileSync(p, 'utf8');
|
|
34
|
+
} catch (e) {
|
|
35
|
+
const err = /** @type {any} */ (e);
|
|
36
|
+
if (isMissingPath(err)) return null;
|
|
37
|
+
throw new Error(`${CONFIG_FILENAME} 读不了(${err.code ?? err.message}):${p}`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 解析配置正文。顺带把「顶层必须是一个对象」这条也收在这里——
|
|
43
|
+
* 它和「不是合法 JSON」是同一类事:文件写得不对,而不是配置项写得不对。
|
|
44
|
+
*
|
|
45
|
+
* @param {string} text
|
|
46
|
+
* @returns {Record<string, any>}
|
|
47
|
+
*/
|
|
48
|
+
function parseConfigText(text) {
|
|
49
|
+
/** @type {any} */
|
|
50
|
+
let parsed;
|
|
51
|
+
try {
|
|
52
|
+
parsed = JSON.parse(stripBom(text));
|
|
53
|
+
} catch (e) {
|
|
54
|
+
throw new Error(`${CONFIG_FILENAME} 不是合法的 JSON:${/** @type {Error} */ (e).message}`);
|
|
55
|
+
}
|
|
56
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
57
|
+
throw new Error(`${CONFIG_FILENAME} 的顶层必须是一个对象`);
|
|
58
|
+
}
|
|
59
|
+
return parsed;
|
|
60
|
+
}
|
|
61
|
+
|
|
16
62
|
/**
|
|
17
63
|
* 读取项目配置 agents.json。
|
|
18
64
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* { "
|
|
65
|
+
* links 里写的是**工具名**,不是某个目录:
|
|
66
|
+
*
|
|
67
|
+
* { "links": ["claude", "trae"] }
|
|
22
68
|
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
69
|
+
* 选用一个工具就是全量适配——想要 Claude Code 就四个目录一起要,
|
|
70
|
+
* 没有「只要 .claude/rules 不要 .claude/skills」这种真实场景。
|
|
71
|
+
* 因此配置里不提供目录级的开关;条目形式("claude/skills")只在内部表示里用。
|
|
72
|
+
*
|
|
73
|
+
* **完全不做推断。** 没写 links(或者压根没有配置文件)时 `tools` 就是空的,
|
|
74
|
+
* `toolsDeclared` 为 false,由调用方决定怎么办——`link` 的做法是当场问用户,
|
|
75
|
+
* 问不了就把可选项和写法列出来。读配置和决定用哪些工具是两件事,不混在一起。
|
|
25
76
|
*
|
|
26
77
|
* @param {string} projectRoot
|
|
27
|
-
* @returns {{
|
|
78
|
+
* @returns {{tools: string[], specs: string[], content: string|null, ref: string|null, bundles: string[], include: string[], exclude: string[], protect: string[], path: string, exists: boolean, toolsDeclared: boolean, warnings: string[]}}
|
|
28
79
|
*/
|
|
29
80
|
export function loadConfig(projectRoot) {
|
|
30
81
|
const cfgPath = path.resolve(projectRoot, CONFIG_FILENAME);
|
|
31
82
|
/** @type {string[]} */
|
|
32
83
|
const warnings = [];
|
|
33
84
|
|
|
34
|
-
|
|
35
|
-
|
|
85
|
+
const text = readConfigText(projectRoot);
|
|
86
|
+
|
|
87
|
+
// 「文件不存在」才走这一条。**读不了是抛错,不是「没有」**——两者混起来,
|
|
88
|
+
// 调用方会拿一份空配置去整份重写,把用户写的东西抹掉。
|
|
89
|
+
if (text === null) {
|
|
36
90
|
return {
|
|
37
|
-
|
|
38
|
-
|
|
91
|
+
tools: [],
|
|
92
|
+
specs: [],
|
|
93
|
+
content: null,
|
|
94
|
+
ref: null,
|
|
95
|
+
bundles: [],
|
|
96
|
+
include: [],
|
|
97
|
+
exclude: [],
|
|
98
|
+
protect: [],
|
|
39
99
|
path: cfgPath,
|
|
40
100
|
exists: false,
|
|
41
|
-
|
|
101
|
+
toolsDeclared: false,
|
|
42
102
|
warnings,
|
|
43
103
|
};
|
|
44
104
|
}
|
|
45
105
|
|
|
46
|
-
|
|
47
|
-
let raw;
|
|
48
|
-
try {
|
|
49
|
-
raw = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
|
|
50
|
-
} catch (e) {
|
|
51
|
-
throw new Error(`${CONFIG_FILENAME} 不是合法的 JSON:${/** @type {Error} */ (e).message}`);
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
55
|
-
throw new Error(`${CONFIG_FILENAME} 的顶层必须是一个对象`);
|
|
56
|
-
}
|
|
106
|
+
const raw = parseConfigText(text);
|
|
57
107
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
} else {
|
|
66
|
-
warnings.push(`${CONFIG_FILENAME} 里既没有 links 也没有 tools`);
|
|
67
|
-
links = [];
|
|
68
|
-
}
|
|
108
|
+
// 没有 links 就**留空**,不按已有目录猜。「这个项目用 Trae」是个持久事实,
|
|
109
|
+
// 从「目录恰好存在」猜出来的东西用户既没同意过、也看不见,
|
|
110
|
+
// 而且猜错的方向很糟:给一个其实不用的工具建了链接。
|
|
111
|
+
//
|
|
112
|
+
// 调用方(link)拿到 toolsDeclared === false 时该去问用户,或者告诉他怎么写。
|
|
113
|
+
const toolsDeclared = raw.links !== undefined;
|
|
114
|
+
const tools = toolsDeclared ? normalizeLinks(raw.links, warnings) : [];
|
|
69
115
|
|
|
70
116
|
return {
|
|
71
|
-
|
|
72
|
-
|
|
117
|
+
tools,
|
|
118
|
+
specs: specsOfTools(tools),
|
|
119
|
+
// sync 用:内容来源、版本与选中的模板。
|
|
120
|
+
// content 既可以是本地路径,也可以是 git 地址,由 source.js 自动判别。
|
|
121
|
+
content: typeof raw.content === 'string' ? raw.content.trim() : null,
|
|
122
|
+
ref: typeof raw.ref === 'string' ? raw.ref.trim() : null,
|
|
123
|
+
// bundle 允许写一个,也允许写数组(多个模板取并集)
|
|
124
|
+
bundles: normalizeList(raw.bundle, 'bundle'),
|
|
125
|
+
// 项目级的增删,叠在模板之上;exclude 最后过一遍
|
|
126
|
+
include: normalizeListArray(raw.include, 'include'),
|
|
127
|
+
exclude: normalizeListArray(raw.exclude, 'exclude'),
|
|
128
|
+
// 锁定的条目:sync 不覆盖也不删。写错了必须立刻报错——
|
|
129
|
+
// 这是用户用来保护自己劳动成果的声明,静默忽略比报错危险得多
|
|
130
|
+
protect: normalizeProtect(raw.protect),
|
|
73
131
|
path: cfgPath,
|
|
74
132
|
exists: true,
|
|
75
|
-
|
|
133
|
+
toolsDeclared,
|
|
76
134
|
warnings,
|
|
77
135
|
};
|
|
78
136
|
}
|
|
79
137
|
|
|
80
138
|
/**
|
|
81
|
-
*
|
|
139
|
+
* 读出配置文件里的**原始对象**,一个字段都不校验、不归一化。
|
|
140
|
+
*
|
|
141
|
+
* 两个用处:
|
|
142
|
+
* - `saveConfig` 靠它保住用户自己写的字段(include / exclude / protect,
|
|
143
|
+
* 以及本工具还不认识的将来字段)
|
|
144
|
+
* - `init` 靠它拼出「将要写入的整份配置」给用户过目——那份预览必须和真写下去的
|
|
145
|
+
* 东西长得一模一样,所以走的是同一份原始数据
|
|
146
|
+
*
|
|
147
|
+
* **只有文件真的不存在才返回空对象**,读不了和不是合法 JSON 都**抛**。
|
|
148
|
+
*
|
|
149
|
+
* 第一版这里是个 `catch {}`,把三种情况一起降成 `{}`——那是**进攻方向**:
|
|
150
|
+
* 调用方(`saveConfig`)拿到空对象后紧接着就是整份重写,用户的
|
|
151
|
+
* `content` / `bundle` / `include` / `protect` 会一次没掉。当时靠「调用方都先
|
|
152
|
+
* loadConfig 过」来兜底,但那是一条**约定**,不是一道**保证**——多一个调用方
|
|
153
|
+
* 就多一个漏的机会。判据和 `merge.js` 那条 blocker 完全一样。
|
|
154
|
+
*
|
|
155
|
+
* @param {string} projectRoot
|
|
156
|
+
* @returns {Record<string, any>}
|
|
157
|
+
*/
|
|
158
|
+
export function readConfigRaw(projectRoot) {
|
|
159
|
+
const text = readConfigText(projectRoot);
|
|
160
|
+
return text === null ? {} : parseConfigText(text);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* 把一份改动叠到已有配置上,返回**要写进文件的完整对象**。纯函数。
|
|
165
|
+
*
|
|
166
|
+
* patch 里每个字段都是三态的,这个区别很要紧:
|
|
167
|
+
*
|
|
168
|
+
* - `undefined` —— 没问过这一项,**保持文件里的原样**(比如内容仓库不是 git 仓库,
|
|
169
|
+
* ref 那一步跳过了,就不能把人家写好的 ref 抹掉)
|
|
170
|
+
* - `null` —— 明确要清掉这个字段(用户在下拉里选了「不指定」)
|
|
171
|
+
* - 其它值 —— 覆盖
|
|
172
|
+
*
|
|
173
|
+
* `bundle` 清空时**删掉键**而不是写 `[]`:两句话意思一样,但空数组会让
|
|
174
|
+
* 文件里多一行解释不清的东西。工具名按 TOOL_NAMES 的固定顺序排列,
|
|
82
175
|
* 这样每次勾选后产生的 diff 是稳定的,不会因为点击顺序变来变去。
|
|
83
|
-
*
|
|
176
|
+
*
|
|
177
|
+
* @param {Record<string, any>} existing @param {Record<string, any>} patch
|
|
178
|
+
* @returns {Record<string, any>}
|
|
84
179
|
*/
|
|
85
|
-
export function
|
|
86
|
-
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
180
|
+
export function mergeConfig(existing, patch) {
|
|
181
|
+
/** @type {Record<string, any>} */
|
|
182
|
+
const next = { ...existing };
|
|
183
|
+
|
|
184
|
+
/** @param {string} key @param {any} value */
|
|
185
|
+
const set = (key, value) => {
|
|
186
|
+
if (value === undefined) return;
|
|
187
|
+
if (value === null || value === '') delete next[key];
|
|
188
|
+
else next[key] = value;
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
set('content', patch.content);
|
|
192
|
+
set('ref', patch.ref);
|
|
193
|
+
|
|
194
|
+
if (patch.bundle !== undefined) {
|
|
195
|
+
const list = patch.bundle === null ? [] : normalizeList(patch.bundle, 'bundle');
|
|
196
|
+
if (list.length === 0) delete next.bundle;
|
|
197
|
+
// 只选了一个就写成裸字符串:和文档里的例子一致,也省得用户为了加第二个
|
|
198
|
+
// 模板再去找方括号的位置(loadConfig 两种都认)
|
|
199
|
+
else next.bundle = list.length === 1 ? list[0] : list;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (patch.links !== undefined) next.links = sortTools(patch.links ?? []);
|
|
203
|
+
|
|
204
|
+
return next;
|
|
90
205
|
}
|
|
91
206
|
|
|
92
|
-
/**
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
207
|
+
/**
|
|
208
|
+
* 写入配置。会保留文件里已有的其它字段——覆盖式写入是个一旦踩到就很难查的坑
|
|
209
|
+
* (用户的 include / exclude / protect 会被悄悄抹掉)。
|
|
210
|
+
*
|
|
211
|
+
* 两种调用方式都认:
|
|
212
|
+
*
|
|
213
|
+
* saveConfig(root, ['claude']) // 老写法:只改 links
|
|
214
|
+
* saveConfig(root, { content, ref, bundle, links }) // init 用的完整写法
|
|
215
|
+
*
|
|
216
|
+
* 数组是 `{ links }` 的简写,老调用方和测试不必跟着改。
|
|
217
|
+
*
|
|
218
|
+
* @param {string} projectRoot
|
|
219
|
+
* @param {string[]|{content?: string|null, ref?: string|null, bundle?: string[]|string|null, links?: string[]}} config
|
|
220
|
+
*/
|
|
221
|
+
export function saveConfig(projectRoot, config) {
|
|
222
|
+
const patch = Array.isArray(config) ? { links: config } : config;
|
|
223
|
+
return writeConfig(projectRoot, mergeConfig(readConfigRaw(projectRoot), patch));
|
|
100
224
|
}
|
|
101
225
|
|
|
102
226
|
/**
|
|
103
|
-
*
|
|
104
|
-
*
|
|
227
|
+
* 把一份**已经算好的**配置写下去。原子写(同目录 tmp + rename)。
|
|
228
|
+
*
|
|
229
|
+
* 为什么不像 `record.js` / `gitignore.js` 那样直接 `writeFileSync`——那里的分界
|
|
230
|
+
* 不是「谁高谁低」,而是**这份文件丢了能不能自己重生**:
|
|
231
|
+
*
|
|
232
|
+
* | 文件 | 丢了会怎样 |
|
|
233
|
+
* | --- | --- |
|
|
234
|
+
* | 链接、合并产物 | 重跑 `sync` / `link` 就回来了 |
|
|
235
|
+
* | `.agents/.agent-sync.json` | 退化成「不再自动清理」,内容还在 |
|
|
236
|
+
* | **`agents.json`** | `content` / `ref` / `include` / `exclude` / `protect` **没有第二处能推导出来** |
|
|
237
|
+
*
|
|
238
|
+
* 而且 `writeFileSync` 是先截断再写:写到一半崩掉,旧内容**一并没了**,
|
|
239
|
+
* 此后 `loadConfig` 对**所有**命令都抛「不是合法的 JSON」,整个项目卡死到有人
|
|
240
|
+
* 手工修好为止。方向是反的,所以这个文件按 `merge.js` 的标准来。
|
|
241
|
+
*
|
|
242
|
+
* 单独留一个「写算好的对象」的入口,还为了让 init 的**预览**和**落盘**用的是
|
|
243
|
+
* 同一个对象,而不是各读一次文件(两次读之间文件可能被别人改)。
|
|
244
|
+
*
|
|
245
|
+
* @param {string} projectRoot @param {Record<string, any>} next
|
|
246
|
+
*/
|
|
247
|
+
export function writeConfig(projectRoot, next) {
|
|
248
|
+
const p = path.resolve(projectRoot, CONFIG_FILENAME);
|
|
249
|
+
writeJsonFile(p, next);
|
|
250
|
+
return p;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* 字符串列表归一化:去空白、丢空串、去重、保序。
|
|
255
|
+
*
|
|
256
|
+
* 字段缺失返回空数组。单个字符串也接受——`"bundle": "common"` 和
|
|
257
|
+
* `"bundle": ["common"]` 都好写,没有理由要求用户为了加第二个模板先加方括号。
|
|
258
|
+
*
|
|
259
|
+
* @param {unknown} value @param {string} field
|
|
105
260
|
* @returns {string[]}
|
|
106
261
|
*/
|
|
107
|
-
|
|
108
|
-
if (
|
|
109
|
-
|
|
110
|
-
}
|
|
262
|
+
function normalizeList(value, field) {
|
|
263
|
+
if (value === undefined || value === null) return [];
|
|
264
|
+
const arr = Array.isArray(value) ? value : [value];
|
|
111
265
|
/** @type {string[]} */
|
|
112
266
|
const out = [];
|
|
113
|
-
for (const item of
|
|
267
|
+
for (const item of arr) {
|
|
114
268
|
if (typeof item !== 'string') {
|
|
115
|
-
throw new Error(
|
|
269
|
+
throw new Error(`${field} 里出现了非字符串项:${JSON.stringify(item)}`);
|
|
116
270
|
}
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
271
|
+
const s = item.trim();
|
|
272
|
+
if (s !== '' && !out.includes(s)) out.push(s);
|
|
273
|
+
}
|
|
274
|
+
return out;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* include / exclude 归一化。
|
|
279
|
+
*
|
|
280
|
+
* 与 bundle 不同,这里**要求写成数组**——它是给人手写的精细控制,
|
|
281
|
+
* 写成裸字符串多半是笔误(`"include": "skill:x"`),报错比默默接受好。
|
|
282
|
+
*
|
|
283
|
+
* @param {unknown} value @param {string} field
|
|
284
|
+
* @returns {string[]}
|
|
285
|
+
*/
|
|
286
|
+
function normalizeListArray(value, field) {
|
|
287
|
+
if (value === undefined || value === null) return [];
|
|
288
|
+
if (!Array.isArray(value)) {
|
|
289
|
+
throw new Error(`${field} 必须是字符串数组,例如 ["skill:api-conventions"]`);
|
|
290
|
+
}
|
|
291
|
+
return normalizeList(value, field);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* 校验并归一化 protect 字段。
|
|
296
|
+
*
|
|
297
|
+
* 条目写成 `"kind:id"`:`"skill:my-customized"`;脚本写成 `"script:相对路径"`,
|
|
298
|
+
* 和 `sync --prune` 输出里用的是同一套词。
|
|
299
|
+
*
|
|
300
|
+
* **不支持 `*` 通配**(对 include / exclude 是支持的)。`protect` 是「这个我要
|
|
301
|
+
* 自己留着」的声明,`skill:*` 那种整体锁定实际含义是「这个项目不要用 sync」,
|
|
302
|
+
* 说成通配只会让人以为只是顺手省几个字。要那样就别跑 sync。
|
|
303
|
+
*
|
|
304
|
+
* @param {unknown} value
|
|
305
|
+
* @returns {string[]}
|
|
306
|
+
*/
|
|
307
|
+
function normalizeProtect(value) {
|
|
308
|
+
const list = normalizeListArray(value, 'protect');
|
|
309
|
+
for (const entry of list) {
|
|
310
|
+
const colon = entry.indexOf(':');
|
|
311
|
+
const kind = colon === -1 ? '' : entry.slice(0, colon).trim();
|
|
312
|
+
const id = colon === -1 ? '' : entry.slice(colon + 1).trim();
|
|
313
|
+
|
|
314
|
+
if (kind === 'script') {
|
|
315
|
+
if (id === '') throw new Error(`protect 里的 ${JSON.stringify(entry)} 少了脚本路径`);
|
|
122
316
|
continue;
|
|
123
317
|
}
|
|
124
|
-
|
|
318
|
+
const known = /** @type {Record<string, unknown>} */ (ITEM_KINDS)[kind];
|
|
319
|
+
if (!known) {
|
|
320
|
+
throw new Error(
|
|
321
|
+
`protect 里的 ${JSON.stringify(entry)} 类型不对:应为 "类型:名字",` +
|
|
322
|
+
`类型可用 ${ITEM_KIND_NAMES.join('、')},脚本写 "script:${SUPPORT_DIR}/ 下的相对路径"`,
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
if (id === '') throw new Error(`protect 里的 ${JSON.stringify(entry)} 少了名字`);
|
|
326
|
+
if (id === '*') {
|
|
327
|
+
throw new Error(
|
|
328
|
+
`protect 不支持通配:把 ${JSON.stringify(entry)} 拆成具体条目。` +
|
|
329
|
+
'想整个类型都不让 sync 管,那这个项目就不该跑 sync。',
|
|
330
|
+
);
|
|
331
|
+
}
|
|
125
332
|
}
|
|
126
|
-
return
|
|
333
|
+
return list;
|
|
127
334
|
}
|
|
128
335
|
|
|
129
336
|
/**
|
|
130
|
-
* 校验并归一化
|
|
337
|
+
* 校验并归一化 links 字段。
|
|
338
|
+
*
|
|
339
|
+
* 值必须是工具名。写明目录的旧写法("claude/skills")直接抛错而不是静默接受:
|
|
340
|
+
* 用户写下的是一个**比实际行为更窄**的东西,若默默按整个工具处理,
|
|
341
|
+
* 他事后会以为自己只启用了 skills。这种误解必须当场说清楚。
|
|
342
|
+
*
|
|
131
343
|
* @param {unknown} value @param {string[]} warnings
|
|
132
344
|
* @returns {string[]}
|
|
133
345
|
*/
|
|
134
|
-
export function
|
|
346
|
+
export function normalizeLinks(value, warnings = []) {
|
|
135
347
|
if (!Array.isArray(value)) {
|
|
136
|
-
throw new Error('
|
|
348
|
+
throw new Error('links 必须是字符串数组,例如 ["claude", "trae"]');
|
|
137
349
|
}
|
|
138
350
|
/** @type {string[]} */
|
|
139
351
|
const out = [];
|
|
140
352
|
for (const item of value) {
|
|
141
353
|
if (typeof item !== 'string') {
|
|
142
|
-
throw new Error(`
|
|
354
|
+
throw new Error(`links 里出现了非字符串项:${JSON.stringify(item)}`);
|
|
143
355
|
}
|
|
144
356
|
const name = item.trim().toLowerCase();
|
|
357
|
+
if (name.includes('/')) {
|
|
358
|
+
throw new Error(
|
|
359
|
+
`links 里只写工具名:把 "${item.trim()}" 改成 "${name.split('/')[0]}"。\n` +
|
|
360
|
+
' 选用一个工具就是该工具全部类型的适配,配置里不再细化到单个目录。',
|
|
361
|
+
);
|
|
362
|
+
}
|
|
145
363
|
if (!isTool(name)) {
|
|
146
364
|
warnings.push(`忽略未知工具 "${item}"(可用值:${TOOL_NAMES.join(', ')})`);
|
|
147
365
|
continue;
|
|
148
366
|
}
|
|
149
367
|
if (!out.includes(name)) out.push(name);
|
|
150
368
|
}
|
|
151
|
-
return out;
|
|
369
|
+
return sortTools(out);
|
|
152
370
|
}
|
|
153
371
|
|
|
154
|
-
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
372
|
+
/*
|
|
373
|
+
* 这里本来有一个 inferTools():项目里存在哪些工具目录,就推断出哪些工具。
|
|
374
|
+
* 已经删掉了——理由见 loadConfig 里那段注释。别再把它加回来:
|
|
375
|
+
* 它猜错的代价是**给一个其实不用的工具建了链接**,而用户看不见这件事发生过。
|
|
376
|
+
* 现在由 link 命令负责问,或者告诉用户怎么写。
|
|
158
377
|
*/
|
|
159
|
-
function inferLinks(projectRoot) {
|
|
160
|
-
/** @type {string[]} */
|
|
161
|
-
const links = [];
|
|
162
|
-
for (const tool of TOOL_NAMES) {
|
|
163
|
-
if (!fs.existsSync(path.resolve(projectRoot, `.${tool}`))) continue;
|
|
164
|
-
for (const spec of allSpecs(tool)) {
|
|
165
|
-
const { kind } = parseSpec(spec);
|
|
166
|
-
if (fs.existsSync(contentDir(projectRoot, kind))) links.push(spec);
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
return links;
|
|
170
|
-
}
|
package/lib/gitignore.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
-
import {
|
|
4
|
+
import { isSymlink } from './link.js';
|
|
5
|
+
import { RECORD_REL } from './record.js';
|
|
6
|
+
import { CONTENT_DIRS, CONTENT_ROOT, TOOLS, kindsOf } from './target.js';
|
|
5
7
|
|
|
6
8
|
export const BEGIN_MARK = '# >>> agent-sync >>>';
|
|
7
9
|
export const END_MARK = '# <<< agent-sync <<<';
|
|
@@ -13,15 +15,19 @@ export const END_MARK = '# <<< agent-sync <<<';
|
|
|
13
15
|
* 提交上去在别人的机器上全是错的;更危险的是 git 会**穿透链接**,
|
|
14
16
|
* 把 .agents/ 里的内容原样再提交一份,仓库里出现两套内容。
|
|
15
17
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
+
* 只忽略**确实建了链接**的目录,两条判断缺一不可:
|
|
19
|
+
*
|
|
20
|
+
* - **内容不存在 → 不忽略。** link 会跳过没有内容的类型,也就不会建链接。
|
|
21
|
+
* 配置里写的是工具名(全量适配),四个目录里可能只有一个有内容。
|
|
22
|
+
* - **目标已是实体目录 → 不忽略。** link 拒绝覆盖实体目录,那就是用户自己的东西;
|
|
23
|
+
* 忽略它会导致人家写的东西进不了版本库,是帮倒忙。
|
|
18
24
|
*
|
|
19
25
|
* 注意:.claude/settings.json 与 .claude/hooks/ **不能**忽略——
|
|
20
26
|
* 那些是项目真实配置,需要进版本库。
|
|
21
27
|
*
|
|
22
|
-
* @param {string[]}
|
|
28
|
+
* @param {string} projectRoot @param {string[]} tools 工具名列表
|
|
23
29
|
*/
|
|
24
|
-
export function buildBlock(
|
|
30
|
+
export function buildBlock(projectRoot, tools) {
|
|
25
31
|
/** @type {string[]} */
|
|
26
32
|
const lines = [
|
|
27
33
|
BEGIN_MARK,
|
|
@@ -29,18 +35,29 @@ export function buildBlock(specs) {
|
|
|
29
35
|
`${CONTENT_ROOT}/*`,
|
|
30
36
|
];
|
|
31
37
|
|
|
32
|
-
//
|
|
33
|
-
|
|
38
|
+
// 白名单:放行所有真实内容目录,其余(临时文件、缓存)一律忽略。
|
|
39
|
+
// 必须用 CONTENT_DIRS 而非 KINDS —— hooks / mcp / scripts 不参与链接,
|
|
40
|
+
// 但同样是必须提交的真实文件,漏掉它们会让别人克隆下来只剩空壳。
|
|
41
|
+
for (const kind of CONTENT_DIRS) {
|
|
34
42
|
lines.push(`!${CONTENT_ROOT}/${kind}/`);
|
|
35
43
|
lines.push(`!${CONTENT_ROOT}/${kind}/*`);
|
|
36
44
|
}
|
|
37
45
|
|
|
46
|
+
// 「装过什么」的记录也要进版本库:它描述的是 .agents/ 的内容,与机器无关。
|
|
47
|
+
// 不提交的话队友克隆下来,sync 就认不出哪些是本工具装的了,清理功能等于失效。
|
|
48
|
+
// 它被上面的 `.agents/*` 忽略,所以必须单独放行。
|
|
49
|
+
lines.push(`!${RECORD_REL}`);
|
|
50
|
+
|
|
38
51
|
/** @type {string[]} */
|
|
39
52
|
const targets = [];
|
|
40
|
-
for (const
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
53
|
+
for (const tool of tools) {
|
|
54
|
+
for (const kind of kindsOf(tool)) {
|
|
55
|
+
if (!fs.existsSync(path.resolve(projectRoot, CONTENT_ROOT, kind))) continue;
|
|
56
|
+
const rel = TOOLS[tool].links[kind];
|
|
57
|
+
const abs = path.resolve(projectRoot, rel);
|
|
58
|
+
if (fs.existsSync(abs) && !isSymlink(abs)) continue;
|
|
59
|
+
if (!targets.includes(rel)) targets.push(rel);
|
|
60
|
+
}
|
|
44
61
|
}
|
|
45
62
|
lines.push(...targets);
|
|
46
63
|
|
|
@@ -60,9 +77,9 @@ export function readGitignore(projectRoot) {
|
|
|
60
77
|
|
|
61
78
|
/**
|
|
62
79
|
* 只读检查:托管段是否存在、缺哪些条目。
|
|
63
|
-
* @param {string} projectRoot @param {string[]}
|
|
80
|
+
* @param {string} projectRoot @param {string[]} tools
|
|
64
81
|
*/
|
|
65
|
-
export function checkBlock(projectRoot,
|
|
82
|
+
export function checkBlock(projectRoot, tools) {
|
|
66
83
|
const content = readGitignore(projectRoot);
|
|
67
84
|
const start = content.indexOf(BEGIN_MARK);
|
|
68
85
|
const end = content.indexOf(END_MARK);
|
|
@@ -71,24 +88,35 @@ export function checkBlock(projectRoot, specs) {
|
|
|
71
88
|
return { present: false, missing: [], extra: [] };
|
|
72
89
|
}
|
|
73
90
|
|
|
74
|
-
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
91
|
+
/** 逐行拆出来:注释和空行不算,两边都用这把尺子 */
|
|
92
|
+
const linesIn = (/** @type {string} */ text) =>
|
|
93
|
+
text
|
|
94
|
+
.split('\n')
|
|
95
|
+
.map((l) => l.trim())
|
|
96
|
+
.filter((l) => l !== '' && !l.startsWith('#'));
|
|
97
|
+
|
|
98
|
+
const expected = linesIn(buildBlock(projectRoot, tools));
|
|
99
|
+
const found = linesIn(content.slice(start + BEGIN_MARK.length, end));
|
|
100
|
+
|
|
101
|
+
// 按行比,不用 `block.includes(l)` 那种子串判断:期望项 `.claude/rules` 会被
|
|
102
|
+
// 段里的一行 `.claude/rules-old` 命中,于是「缺了这条」永远报不出来。
|
|
103
|
+
const missing = expected.filter((l) => !found.includes(l));
|
|
104
|
+
// 段里多出来的行。`writeBlock` 是**整段重建**的,这几行会在下一次 link 时
|
|
105
|
+
// 无声消失——报出来,别让「写在托管段里也能生效」变成一个事后才发现不成立的假设。
|
|
106
|
+
const extra = found.filter((l) => !expected.includes(l));
|
|
79
107
|
|
|
80
|
-
return { present: true, missing, extra
|
|
108
|
+
return { present: true, missing, extra };
|
|
81
109
|
}
|
|
82
110
|
|
|
83
111
|
/**
|
|
84
112
|
* 写入 / 更新托管段。幂等。
|
|
85
|
-
* @param {string} projectRoot @param {string[]}
|
|
113
|
+
* @param {string} projectRoot @param {string[]} tools
|
|
86
114
|
* @param {{dryRun?: boolean}} [opts]
|
|
87
115
|
*/
|
|
88
|
-
export function writeBlock(projectRoot,
|
|
116
|
+
export function writeBlock(projectRoot, tools, opts = {}) {
|
|
89
117
|
const p = path.resolve(projectRoot, '.gitignore');
|
|
90
118
|
const current = readGitignore(projectRoot);
|
|
91
|
-
const block = buildBlock(
|
|
119
|
+
const block = buildBlock(projectRoot, tools);
|
|
92
120
|
|
|
93
121
|
const start = current.indexOf(BEGIN_MARK);
|
|
94
122
|
const end = current.indexOf(END_MARK);
|