agent-syncer 0.1.1 → 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/README.md +30 -3
- package/bin/agent-sync.js +43 -3
- package/lib/commands/doctor.js +57 -71
- package/lib/commands/link.js +16 -1
- package/lib/commands/status.js +91 -85
- package/lib/commands/sync.js +53 -31
- package/lib/config.js +8 -14
- package/lib/gitignore.js +17 -6
- package/lib/install.js +43 -22
- package/lib/log.js +11 -0
- package/lib/manifest.js +22 -0
- package/lib/merge.js +151 -6
- package/lib/record.js +87 -7
- package/lib/source.js +93 -21
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -359,12 +359,19 @@ agent-syncer link --src/--dst
|
|
|
359
359
|
✅ .claude/skills 已创建
|
|
360
360
|
|
|
361
361
|
这条链接不在托管范围内:status 看不到它,--prune 也不会清理它。
|
|
362
|
+
注意:这个落点在某个工具的托管目录里——之后跑批量 link 会认为它「指向别处」
|
|
363
|
+
而拒绝接管。那是它在保护这条链接,别照着 --force 的提示把它覆盖掉。
|
|
362
364
|
```
|
|
363
365
|
|
|
364
366
|
相对路径按**当前目录**解析(`--cwd` 可改),绝对路径原样用。源必须已经存在且是目录。
|
|
365
367
|
安全检查和不带参数时**完全一样**:拒绝覆盖实体目录或文件、只删链接、指向别处的链接要
|
|
366
368
|
`--force` 才接管、创建后校验。`--dry-run` 同样有效。
|
|
367
369
|
|
|
370
|
+
**两种模式会互相看见,但不会互相接管。** 落点落在某个工具的托管目录里(上面那个例子
|
|
371
|
+
就是)时,之后跑批量 `link` 会把它当成「指向别处」而拒绝——那是它在保护这条链接,
|
|
372
|
+
不要顺手加 `--force` 覆盖掉,那会把这条特意建的链接换成指向 `.agents/` 的。
|
|
373
|
+
反过来,`link` 的提示里也会说清 `--force` 是**换成**而不是「修好」。
|
|
374
|
+
|
|
368
375
|
**它刻意不在托管范围内。** 不写 `.gitignore` 托管段、不进 `.agent-sync.json`、
|
|
369
376
|
`status` 看不见它、`--prune` 也不碰它。理由是托管那一整套的前提是「内容都从
|
|
370
377
|
`.agents/` 来」——`findStaleLinks` 只认指向本项目 `.agents/<kind>/` 的链接,记录文件
|
|
@@ -385,11 +392,14 @@ agent-syncer link --src/--dst
|
|
|
385
392
|
| `sync` | 从内容仓库挑一个模板装进 `.agents/`,然后建链接 | 是(支持 `--dry-run`) |
|
|
386
393
|
| `link` | 只建目录链接并维护 `.gitignore`;没写 `links` 时会问你或告诉你怎么写。加 `--src`/`--dst` 则是单独建一条链接,不读配置 | 是(支持 `--dry-run`) |
|
|
387
394
|
| `list` | 内容仓库里有哪些条目和模板 | 否 |
|
|
388
|
-
| `status` | 每个链接是否健康、`.agents/`
|
|
389
|
-
| `doctor` |
|
|
395
|
+
| `status` | 每个链接是否健康、`.agents/` 下各有多少内容、装过什么、hooks/mcp 合并没有、有没有「不再使用的链接 / 合并产物」 | 否 |
|
|
396
|
+
| `doctor` | 运行环境、链接能力实测、内容、**记录文件**、项目配置、`.gitignore`、`.gitattributes`、合并产物、MCP 批没批准、各工具是否存在 | 否 |
|
|
390
397
|
|
|
391
398
|
选项:`--from=<路径>` `--bundle=<名字>` `--kind=<类型>` `--ref=<版本>` `--src=<目录>` `--dst=<路径>` `--dry-run` `--force` `--prune` `--yes` `--no-save` `--cwd=<路径>` `--help`
|
|
392
399
|
|
|
400
|
+
选项一律写成 `--名字=值`。**空格分隔不支持**(`--src ../shared/prompts`)——本工具会
|
|
401
|
+
当场提醒你写成等号,而不是把它当成「没给这个选项」。
|
|
402
|
+
|
|
393
403
|
## 目录约定
|
|
394
404
|
|
|
395
405
|
```
|
|
@@ -482,6 +492,13 @@ agent-syncer link --src/--dst
|
|
|
482
492
|
**绝不删除有内容的目录。** 路径上已经有用户自己的目录或文件时,`link` 一律拒绝并报错,
|
|
483
493
|
退出码非零。指向别处的链接需要显式 `--force` 才接管,且 `--force` 也只删链接、不删实体目录。
|
|
484
494
|
|
|
495
|
+
**路径上有链接就不往里写。** `sync` 落每一份内容之前,会看路径**自己**和它的**每一层
|
|
496
|
+
上层目录**:哪一段是链接就拒绝,并报出是哪一段。原先只看最后那一段,于是
|
|
497
|
+
`.agents/skills` 自己是链接(比如你把它指向了别处的共享目录)时,`.agents/skills/alpha`
|
|
498
|
+
并不是链接,守卫整条放过——内容顺着链接写进了那个目录,退出码还是 0。而那里的内容
|
|
499
|
+
很可能**还有别的项目在管**(每个项目的记录各管各的),这边 `--prune` 一删,那边就凭空少东西。
|
|
500
|
+
上溯只到**项目根**为止:项目根自己的祖先是不是链接(macOS 上 `/tmp` 就是)不归本工具管。
|
|
501
|
+
|
|
485
502
|
`--prune` 会收掉被删空的目录,但那和上面这条不矛盾:`fs.rmdirSync` **只对空目录成功**,
|
|
486
503
|
非空一律报错,所以「有内容的目录删不掉」不是靠判断维持的,是内核挡在那儿——
|
|
487
504
|
判断逻辑写错了也删不掉。另外只认**实体**目录,`lstat` 一看是链接就放手:
|
|
@@ -497,6 +514,9 @@ Windows 上 `RemoveDirectoryW` 对 junction 的语义是「摘掉重解析点」
|
|
|
497
514
|
东西进不了版本库)。配置写的是工具名,一个工具最多带出四个目标,所以这两条判断是必需的
|
|
498
515
|
——不然会给一堆根本没建链接的路径平白加上忽略规则。
|
|
499
516
|
|
|
517
|
+
段是**整段重建**的:你在段里手写的行会在下一次 `link` 时无声消失。所以 `status` / `doctor`
|
|
518
|
+
会把它们报出来(「托管段里有 N 行不是本工具生成的」),而不是报一句「完整」了事。
|
|
519
|
+
|
|
500
520
|
**只存一份状态,而且只存推不出来的那部分。** 链接、内容、`ref` 全从文件系统推,
|
|
501
521
|
不额外记。唯一的例外是 `.agents/.agent-sync.json`,见下节——因为它回答的问题
|
|
502
522
|
(「这份内容是本工具装的吗」)**光看文件系统推不出来**。
|
|
@@ -553,7 +573,14 @@ sync 就认不出哪些是本工具装的,清理功能等于失效。`.gitigno
|
|
|
553
573
|
键序**——工具自己重排一次键序就触发一次写入的话,那个文件会一直抖。
|
|
554
574
|
|
|
555
575
|
记录坏了、或者 `schemaVersion` 对不上,一律当作「没有记录」:**什么都不会删**,
|
|
556
|
-
下次 sync
|
|
576
|
+
下次 sync 重新写一份——**写之前先把坏的那份备份成 `.agent-sync.json.bak`**(只备一次,
|
|
577
|
+
不把最初那份越冲越远)。这份文件里记的归属没有第二处能推导出来,直接盖掉是不可逆的。
|
|
578
|
+
|
|
579
|
+
⚠️ 「当作没有记录」对**删除**是保守的,对**归属**却是破坏性的:`merged` 一丢,本工具就
|
|
580
|
+
认不出 `.mcp.json` 里哪条是自己写的了,下一轮会把它报成「与记录之外的 server 重名」,
|
|
581
|
+
劝你「删掉那一份,或写进 protect」——而那份本来就是它自己写的。所以 `doctor` 会专门查
|
|
582
|
+
这份文件,`status` 也会报。顺带一提,带 BOM 的记录(记事本、PowerShell 的 `>` 都会写)
|
|
583
|
+
照常读得出来,不会走到这条路。
|
|
557
584
|
|
|
558
585
|
> 首次升级到带记录功能的版本时,`.agents/` 里已有的内容**不会被认领**——sync 分不清
|
|
559
586
|
> 它们是谁的。那一次它会照老规矩把「不在本模板里」的条目全列出来,之后写下的记录
|
package/bin/agent-sync.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// @ts-check
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import process from 'node:process';
|
|
5
|
-
import { bold, dim, fail, plain
|
|
5
|
+
import { bold, dim, fail, plain } from '../lib/log.js';
|
|
6
6
|
import { TOOL_NAMES } from '../lib/target.js';
|
|
7
7
|
|
|
8
8
|
const USAGE = `${bold('agent-syncer')} — 把 .agents/ 下的 AI 资产分发到各编码工具的配置目录
|
|
@@ -16,10 +16,13 @@ ${bold('命令')}
|
|
|
16
16
|
link 只建目录链接并维护 .gitignore;没有 agents.json 时会先让你勾选
|
|
17
17
|
带上 --src/--dst 则退化成「在这儿建一条链接」,不读任何项目配置
|
|
18
18
|
list 只读报告:内容仓库里有哪些条目和模板(自定义 include 之前先看这个)
|
|
19
|
-
status
|
|
20
|
-
|
|
19
|
+
status 只读报告:每个链接是否健康、.agents/ 下各有多少内容、装过什么、
|
|
20
|
+
有没有「不再使用的链接 / 合并产物」
|
|
21
|
+
doctor 只读自检:运行环境、链接能力、内容、记录、.gitignore、行尾、
|
|
22
|
+
合并产物、MCP 批没批准、各工具是否存在
|
|
21
23
|
|
|
22
24
|
${bold('选项')}
|
|
25
|
+
${dim('一律写成 --名字=值。空格分隔不支持(--src ../x)——那样写会被当成位置参数,本工具会当场提醒。')}
|
|
23
26
|
--from=<来源> 内容仓库位置(本地路径或 git 地址),覆盖 agents.json 里的 content
|
|
24
27
|
--ref=<版本> 分支名 / 标签名 / 提交 SHA,覆盖 agents.json 里的 ref
|
|
25
28
|
--bundle=<名字> 装哪个模板,覆盖 agents.json 里的 bundle;多个用逗号分隔
|
|
@@ -188,6 +191,40 @@ function parseArgs(argv) {
|
|
|
188
191
|
return flags;
|
|
189
192
|
}
|
|
190
193
|
|
|
194
|
+
/**
|
|
195
|
+
* 取值型选项:写这几个必须用 `--名字=值`。
|
|
196
|
+
*
|
|
197
|
+
* 写成空格分隔(`--src shared/prompts`)时,手写解析器会把 `shared/prompts`
|
|
198
|
+
* 收进位置参数、把 `--src` 置成 `true`,于是**报出一句和事实相反的话**——
|
|
199
|
+
* `link --src shared/prompts` 得到的是「--src 和 --dst 要一起给(少了 --src)」
|
|
200
|
+
* (实测过)。人明明写了 src,被告知没写,只会去怀疑自己敲错了命令名。
|
|
201
|
+
*/
|
|
202
|
+
const VALUE_FLAGS = ['from', 'ref', 'bundle', 'kind', 'src', 'dst', 'cwd'];
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* 把「空格分隔」这件事当场说清楚,而不是放宽语法。
|
|
206
|
+
*
|
|
207
|
+
* 支持空格分隔就得回答「后面那个位置参数到底是不是这个选项的值」,而本工具的
|
|
208
|
+
* 命令行里命令名、路径、选项混在一起,靠猜只会引入更难查的错。所以只认
|
|
209
|
+
* `--名字=值`,但**必须在用户写错时立刻指出来**——静默变成 `true` 是最坏的一种。
|
|
210
|
+
*
|
|
211
|
+
* @param {Record<string, any>} args
|
|
212
|
+
*/
|
|
213
|
+
function checkValueFlags(args) {
|
|
214
|
+
const bad = VALUE_FLAGS.filter((k) => args[k] === true);
|
|
215
|
+
if (bad.length === 0) return true;
|
|
216
|
+
|
|
217
|
+
const k = bad[0];
|
|
218
|
+
fail(`--${k} 后面要用等号,写成 --${k}=<值>`);
|
|
219
|
+
plain(
|
|
220
|
+
` 本工具只认 ${bold('--名字=值')} 这一种写法。你敲的 \`--${k}\` 后面那个词被当成了位置参数——` +
|
|
221
|
+
`等于没给 ${dim(`--${k}`)}。`,
|
|
222
|
+
);
|
|
223
|
+
plain(dim(' 例如: agent-syncer link --src=../shared/prompts --dst=.claude/skills'));
|
|
224
|
+
plain(dim(' 值本身带空格的话,整个包在引号里:--bundle="a b"'));
|
|
225
|
+
return false;
|
|
226
|
+
}
|
|
227
|
+
|
|
191
228
|
const COMMANDS = /** @type {const} */ ({
|
|
192
229
|
init: '../lib/commands/init.js',
|
|
193
230
|
sync: '../lib/commands/sync.js',
|
|
@@ -214,6 +251,9 @@ async function main() {
|
|
|
214
251
|
return 0;
|
|
215
252
|
}
|
|
216
253
|
|
|
254
|
+
// 放在 --help 之后:写明「怎么看帮助」的人应当先看到帮助。
|
|
255
|
+
if (!checkValueFlags(args)) return 1;
|
|
256
|
+
|
|
217
257
|
const loader = /** @type {Record<string, string>} */ (COMMANDS)[cmd];
|
|
218
258
|
if (!loader) {
|
|
219
259
|
fail(`未知命令:${cmd}`);
|
package/lib/commands/doctor.js
CHANGED
|
@@ -8,18 +8,9 @@ import { CONFIG_FILENAME, loadConfig } from '../config.js';
|
|
|
8
8
|
import { checkBlock } from '../gitignore.js';
|
|
9
9
|
import { probeLinkCapability, samePath } from '../link.js';
|
|
10
10
|
import { dim, fail, ok, plain, skip, title, warn } from '../log.js';
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
CONTENT_ROOT,
|
|
16
|
-
MERGE_KINDS,
|
|
17
|
-
TOOLS,
|
|
18
|
-
TOOL_NAMES,
|
|
19
|
-
mergeKindsOf,
|
|
20
|
-
mergeTarget,
|
|
21
|
-
toolDir,
|
|
22
|
-
} from '../target.js';
|
|
11
|
+
import { checkMergeView } from '../merge.js';
|
|
12
|
+
import { RECORD_REL, readRecord, unparsableKeys } from '../record.js';
|
|
13
|
+
import { CONTENT_ROOT, TOOLS, TOOL_NAMES, mergeKindsOf, mergeTarget, toolDir } from '../target.js';
|
|
23
14
|
|
|
24
15
|
/**
|
|
25
16
|
* `.gitattributes` 里有没有一条「统一按文本处理」的规则。
|
|
@@ -172,7 +163,34 @@ export async function run({ cwd }) {
|
|
|
172
163
|
warned += 1;
|
|
173
164
|
}
|
|
174
165
|
|
|
175
|
-
// ---- 4.
|
|
166
|
+
// ---- 4. 记录(哪些内容是本工具装的)----
|
|
167
|
+
//
|
|
168
|
+
// 这份文件坏了**不报错**,但会让清理功能静默失效:sync 认不出哪些内容是自己
|
|
169
|
+
// 装的,`--prune` 于是什么都不删;合并产物的归属(merged)也跟着丢。症状只会
|
|
170
|
+
// 以「怎么清不掉」的形式很晚才浮出来——而 doctor 的职责正是提前把话说清楚。
|
|
171
|
+
title('记录');
|
|
172
|
+
const record = readRecord(cwd);
|
|
173
|
+
if (record.usable) {
|
|
174
|
+
ok(`${RECORD_REL} 可读`);
|
|
175
|
+
// 解析不了的条目映射不到路径,清理会少管它们——说一声,别让它变成一份
|
|
176
|
+
// 「看起来完整、实际漏了几条」的记录
|
|
177
|
+
const broken = unparsableKeys(record);
|
|
178
|
+
if (broken.length > 0) {
|
|
179
|
+
warn(`记录里有 ${broken.length} 项名字解析不了:${broken.join('、')}`);
|
|
180
|
+
plain(dim(' 它们不会被清理,也不会被覆盖——多半是手改记录时改坏的。'));
|
|
181
|
+
warned += 1;
|
|
182
|
+
}
|
|
183
|
+
} else if (record.reason) {
|
|
184
|
+
warn(record.reason);
|
|
185
|
+
plain(dim(' 后果:清理功能会失效(认不出哪些内容是本工具装的)。'));
|
|
186
|
+
plain(dim(' sync 会先把它备份成 .bak,再重建一份新的。'));
|
|
187
|
+
warned += 1;
|
|
188
|
+
} else {
|
|
189
|
+
skip(`还没有 ${RECORD_REL}`);
|
|
190
|
+
plain(dim(' 下次 sync 会写下它,之后就能认出哪些内容是本工具装的了。'));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ---- 5. 项目配置 ----
|
|
176
194
|
title('项目配置');
|
|
177
195
|
const config = loadConfig(cwd);
|
|
178
196
|
if (config.exists) {
|
|
@@ -189,7 +207,7 @@ export async function run({ cwd }) {
|
|
|
189
207
|
}
|
|
190
208
|
for (const w of config.warnings) warn(w);
|
|
191
209
|
|
|
192
|
-
// ----
|
|
210
|
+
// ---- 6. .gitignore ----
|
|
193
211
|
title('.gitignore');
|
|
194
212
|
const gi = checkBlock(cwd, config.tools);
|
|
195
213
|
if (!gi.present) {
|
|
@@ -199,6 +217,11 @@ export async function run({ cwd }) {
|
|
|
199
217
|
warn(`托管段缺少 ${gi.missing.length} 个条目,运行 link 会补齐`);
|
|
200
218
|
for (const m of gi.missing) plain(dim(` · ${m}`));
|
|
201
219
|
warned += 1;
|
|
220
|
+
} else if (gi.extra.length > 0) {
|
|
221
|
+
// 托管段是整段重建的,这几行会在下一次 link 时无声消失
|
|
222
|
+
warn(`托管段里有 ${gi.extra.length} 行不是本工具生成的,运行 link 会被清掉`);
|
|
223
|
+
for (const e of gi.extra) plain(dim(` · ${e}`));
|
|
224
|
+
warned += 1;
|
|
202
225
|
} else {
|
|
203
226
|
ok('托管段完整');
|
|
204
227
|
}
|
|
@@ -209,7 +232,7 @@ export async function run({ cwd }) {
|
|
|
209
232
|
),
|
|
210
233
|
);
|
|
211
234
|
|
|
212
|
-
// ----
|
|
235
|
+
// ---- 7. .gitattributes ----
|
|
213
236
|
//
|
|
214
237
|
// 为什么要在意:`sync` 判断「内容变没变」是按字节比的,于是检出成 CRLF 的那份
|
|
215
238
|
// 会被当成「变了」。危害不大(多报一次「更新」,第二遍自愈),但每次克隆后
|
|
@@ -235,54 +258,28 @@ export async function run({ cwd }) {
|
|
|
235
258
|
ok('已声明 text=auto,行尾行为不再取决于各人本机配置');
|
|
236
259
|
}
|
|
237
260
|
|
|
238
|
-
// ----
|
|
261
|
+
// ---- 8. 合并产物(hooks / mcp)----
|
|
239
262
|
//
|
|
240
263
|
// 这一节查的**不是「我写了吗」,是「生效了吗」**。写进 `.mcp.json` 只是第一步,
|
|
241
264
|
// 后面还有批准、还有「它进不进得了版本库」两道关。
|
|
242
265
|
title('合并产物(hooks / mcp)');
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
const
|
|
266
|
+
|
|
267
|
+
// 判断整个交给 merge.js 的 checkMergeView——status 用的是同一份。两个只读命令
|
|
268
|
+
// 对同一份现场给出相反结论,是以前真出过的事(见 checkMergeAll 的注释)。
|
|
269
|
+
const view = checkMergeView({ projectRoot: cwd, config, record });
|
|
247
270
|
|
|
248
271
|
// 「目录读不出来」和「目录是空的」是两件事。前者我们根本不知道里面有什么,
|
|
249
272
|
// 打成「没有 hooks / mcp 内容,不需要合并」就是替它说一句我们没资格说的话。
|
|
250
|
-
for (const d of
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
warned += 1;
|
|
254
|
-
}
|
|
273
|
+
for (const d of view.unreadable) {
|
|
274
|
+
warn(`读不了 ${CONTENT_ROOT}/${d}/——里面有什么、能不能合,判断不了`);
|
|
275
|
+
warned += 1;
|
|
255
276
|
}
|
|
256
277
|
|
|
257
|
-
|
|
258
|
-
const anyUnreadable = MERGE_KINDS.some((d) => present[d] === null);
|
|
259
|
-
|
|
260
|
-
if (presentCount === 0 && !anyUnreadable && Object.keys(mergedRecord).length === 0) {
|
|
278
|
+
if (!view.hasWork) {
|
|
261
279
|
// 空项目不该因为「`.mcp.json` 不存在」就吃一笔警告,否则小结没人看
|
|
262
280
|
skip('没有 hooks / mcp 内容,不需要合并');
|
|
263
281
|
} else {
|
|
264
|
-
const
|
|
265
|
-
MERGE_KINDS.map((d) => [d, sourcesFromDisk(cwd, record, d)]),
|
|
266
|
-
);
|
|
267
|
-
/** @type {Record<string, string[]>} */
|
|
268
|
-
const keepByKind = Object.fromEntries(MERGE_KINDS.map((d) => [d, []]));
|
|
269
|
-
for (const key of config.protect) {
|
|
270
|
-
const colon = key.indexOf(':');
|
|
271
|
-
if (colon === -1) continue;
|
|
272
|
-
// 「条目类型 → 目录」这张表只在 ITEM_KINDS 里有一份。这里原先用
|
|
273
|
-
// `/^hook:/`、`/^mcp:/` 又写了一遍,sync / status 走的是表——加一类内容时
|
|
274
|
-
// 漏改一处,protect 就会在这里静默失效。
|
|
275
|
-
const dir = ITEM_KINDS[key.slice(0, colon)]?.dir;
|
|
276
|
-
if (dir && MERGE_KINDS.includes(dir)) keepByKind[dir].push(key.slice(colon + 1));
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
for (const r of checkMergeAll({
|
|
280
|
-
projectRoot: cwd,
|
|
281
|
-
tools: config.tools,
|
|
282
|
-
sourcesByKind,
|
|
283
|
-
keepByKind,
|
|
284
|
-
prevMerged: mergedRecord,
|
|
285
|
-
})) {
|
|
282
|
+
for (const r of view.rows) {
|
|
286
283
|
const label = `${TOOLS[r.tool].label} · ${r.rel}`.padEnd(28);
|
|
287
284
|
if (r.state === 'ok') {
|
|
288
285
|
ok(`${label} ${dim('和内容一致')}`);
|
|
@@ -311,30 +308,19 @@ export async function run({ cwd }) {
|
|
|
311
308
|
}
|
|
312
309
|
|
|
313
310
|
// 声明的工具里有没有承接不了这类内容的(codex 的 mcp 就是)
|
|
314
|
-
for (const
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
const n = present[dir]?.length ?? 0;
|
|
318
|
-
if (n > 0) {
|
|
319
|
-
warn(`${TOOLS[tool].label} 没有 ${dir} 的合并目标——这 ${n} 条装了不会生效`);
|
|
320
|
-
warned += 1;
|
|
321
|
-
}
|
|
322
|
-
}
|
|
311
|
+
for (const u of view.unsupported) {
|
|
312
|
+
warn(`${TOOLS[u.tool].label} 没有 ${u.dir} 的合并目标——这 ${u.count} 条装了不会生效`);
|
|
313
|
+
warned += 1;
|
|
323
314
|
}
|
|
324
315
|
|
|
325
316
|
// 没实证的那条路不能报得跟 Claude 那条一样肯定
|
|
326
|
-
for (const
|
|
327
|
-
|
|
328
|
-
const t = mergeTarget(tool, dir);
|
|
329
|
-
if (t && !t.verified) {
|
|
330
|
-
plain(dim(` ⚠️ ${TOOLS[tool].label} 的 ${t.rel} 未经实证(本机没装),可能不生效`));
|
|
331
|
-
}
|
|
332
|
-
}
|
|
317
|
+
for (const u of view.unverified) {
|
|
318
|
+
plain(dim(` ⚠️ ${TOOLS[u.tool].label} 的 ${u.rel} 未经实证(本机没装),可能不生效`));
|
|
333
319
|
}
|
|
334
320
|
|
|
335
321
|
// ---- 写进去了,生效了吗 ----
|
|
336
|
-
if (config.tools.includes('claude') && (present.mcp?.length ?? 0) > 0) {
|
|
337
|
-
const ap = claudeApproval(cwd, present.mcp ?? []);
|
|
322
|
+
if (config.tools.includes('claude') && (view.present.mcp?.length ?? 0) > 0) {
|
|
323
|
+
const ap = claudeApproval(cwd, view.present.mcp ?? []);
|
|
338
324
|
if (ap.state === 'ok') {
|
|
339
325
|
ok('Claude Code 已批准项目里的 MCP server');
|
|
340
326
|
} else if (ap.state === 'pending') {
|
|
@@ -385,7 +371,7 @@ export async function run({ cwd }) {
|
|
|
385
371
|
}
|
|
386
372
|
|
|
387
373
|
// ---- hook 在 Windows 上靠 sh ----
|
|
388
|
-
if ((present.hooks?.length ?? 0) > 0 && process.platform === 'win32') {
|
|
374
|
+
if ((view.present.hooks?.length ?? 0) > 0 && process.platform === 'win32') {
|
|
389
375
|
if (hasSh()) ok('sh 可用(Windows 上 hook 靠它执行)');
|
|
390
376
|
else {
|
|
391
377
|
warn('PATH 里找不到 sh —— hook 会静默不跑');
|
|
@@ -395,7 +381,7 @@ export async function run({ cwd }) {
|
|
|
395
381
|
}
|
|
396
382
|
}
|
|
397
383
|
|
|
398
|
-
// ----
|
|
384
|
+
// ---- 9. 各工具是否存在 ----
|
|
399
385
|
title('工具');
|
|
400
386
|
for (const name of TOOL_NAMES) {
|
|
401
387
|
const dir = path.resolve(cwd, toolDir(name));
|
package/lib/commands/link.js
CHANGED
|
@@ -130,7 +130,12 @@ function applyOne({ label, source, target, rel, srcLabel }, { dryRun, force }) {
|
|
|
130
130
|
|
|
131
131
|
if (before.status === STATUS.ELSEWHERE && !force) {
|
|
132
132
|
fail(`${label} 已是指向别处的链接 → ${before.actual}`);
|
|
133
|
-
|
|
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` };
|
|
134
139
|
}
|
|
135
140
|
|
|
136
141
|
// 走到这里只可能是 ABSENT / BROKEN / (ELSEWHERE + --force),
|
|
@@ -230,6 +235,16 @@ function runDirect({ cwd, src, dst, dryRun, force, prune }) {
|
|
|
230
235
|
}
|
|
231
236
|
|
|
232
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
|
+
}
|
|
233
248
|
if (dryRun) plain(dim('这是预演,未写盘。去掉 --dry-run 即可实际执行。'));
|
|
234
249
|
return 0;
|
|
235
250
|
}
|
package/lib/commands/status.js
CHANGED
|
@@ -4,30 +4,42 @@ import path from 'node:path';
|
|
|
4
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 {
|
|
9
|
-
import {
|
|
10
|
-
import { RECORD_REL, checkRecord, readRecord } from '../record.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';
|
|
11
10
|
import { findStaleLinks, findStaleMerges } from '../stale.js';
|
|
12
|
-
import {
|
|
13
|
-
CONTENT_DIRS,
|
|
14
|
-
CONTENT_ROOT,
|
|
15
|
-
MERGE_KINDS,
|
|
16
|
-
TOOLS,
|
|
17
|
-
TOOL_NAMES,
|
|
18
|
-
mergeTarget,
|
|
19
|
-
plannedLinks,
|
|
20
|
-
} from '../target.js';
|
|
11
|
+
import { CONTENT_DIRS, CONTENT_ROOT, TOOLS, TOOL_NAMES, plannedLinks } from '../target.js';
|
|
21
12
|
|
|
22
|
-
/**
|
|
23
|
-
|
|
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
|
+
}
|
|
24
28
|
|
|
25
|
-
/**
|
|
29
|
+
/**
|
|
30
|
+
* 统计目录下的条目数。
|
|
31
|
+
*
|
|
32
|
+
* 「读不了」和「不存在」分开报,理由见上面 `pathState`:「无此目录」说的是
|
|
33
|
+
* 「这儿没东西」,而读不到时我们唯一知道的是「这儿的东西我看不到」——
|
|
34
|
+
* 这两句话对用户的意义完全不同。
|
|
35
|
+
*
|
|
36
|
+
* @param {string} dir @returns {{state: 'ok', count: number} | {state: 'missing' | 'unreadable'}}
|
|
37
|
+
*/
|
|
26
38
|
function countEntries(dir) {
|
|
27
39
|
try {
|
|
28
|
-
return fs.readdirSync(dir).length;
|
|
29
|
-
} catch {
|
|
30
|
-
return
|
|
40
|
+
return { state: /** @type {const} */ ('ok'), count: fs.readdirSync(dir).length };
|
|
41
|
+
} catch (e) {
|
|
42
|
+
return { state: isMissingPath(e) ? 'missing' : 'unreadable' };
|
|
31
43
|
}
|
|
32
44
|
}
|
|
33
45
|
|
|
@@ -50,21 +62,32 @@ export async function run({ cwd }) {
|
|
|
50
62
|
// ---- 内容 ----
|
|
51
63
|
title(`内容(${CONTENT_ROOT}/)`);
|
|
52
64
|
const contentRoot = path.resolve(cwd, CONTENT_ROOT);
|
|
53
|
-
|
|
65
|
+
// `fs.existsSync` 在这里不能用:它对**任何**错误都返回 false,于是权限错会被
|
|
66
|
+
// 报成「还没有任何内容」——和下一层那个「无此目录」是同一个病,只是高了一层。
|
|
67
|
+
const rootState = pathState(contentRoot);
|
|
68
|
+
if (rootState === 'unreadable') {
|
|
69
|
+
warn(`${CONTENT_ROOT}/ 读不了——里面有什么,判断不了`);
|
|
70
|
+
} else if (rootState === 'missing') {
|
|
54
71
|
fail(`${CONTENT_ROOT}/ 不存在——还没有任何内容`);
|
|
55
72
|
} else {
|
|
56
73
|
// 遍历 CONTENT_DIRS 而不是 KINDS:hooks / mcp / scripts 不参与链接,
|
|
57
74
|
// 但它们同样是 sync 装下来的内容,只报四类会让人以为 sync 把后三类漏了。
|
|
58
75
|
// doctor 本来就列全部子目录,这里跟上,两条命令的口径才一致。
|
|
59
76
|
for (const kind of CONTENT_DIRS) {
|
|
60
|
-
const
|
|
77
|
+
const r = countEntries(path.resolve(contentRoot, kind));
|
|
61
78
|
const label = kind.padEnd(9);
|
|
62
|
-
|
|
63
|
-
|
|
79
|
+
// 空出数字列的宽度,好让这两行和上面的「N 项」对成一张表
|
|
80
|
+
if (r.state === 'missing') {
|
|
64
81
|
skip(`${label} 无此目录`);
|
|
65
82
|
continue;
|
|
66
83
|
}
|
|
67
|
-
|
|
84
|
+
if (r.state === 'unreadable') {
|
|
85
|
+
// **不算问题**——和下一节「合并」读不了时一个口径:判断不了的事如实说,
|
|
86
|
+
// 但不把它变成用户要去点一下的修复项(那会是个他不会采纳的修复)。
|
|
87
|
+
warn(`${label} 读不了——有多少条目判断不了`);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
ok(`${label} ${String(r.count).padStart(3)} 项`);
|
|
68
91
|
}
|
|
69
92
|
}
|
|
70
93
|
|
|
@@ -86,6 +109,13 @@ export async function run({ cwd }) {
|
|
|
86
109
|
if (config.protect.length > 0) {
|
|
87
110
|
plain(dim(` 另有 ${config.protect.length} 项被 protect 锁住,本工具不覆盖也不删`));
|
|
88
111
|
}
|
|
112
|
+
// 名字都解析不了的条目映射不到路径,只能跳过——但**跳过不等于没有这回事**:
|
|
113
|
+
// 清理功能会因此少管几项,而用户只看到「跟踪 N 项」莫名其妙变少了
|
|
114
|
+
const broken = unparsableKeys(record);
|
|
115
|
+
if (broken.length > 0) {
|
|
116
|
+
warn(`其中 ${broken.length} 项的名字解析不了:${broken.join('、')}`);
|
|
117
|
+
plain(dim(' 它们不会被清理,也不会被覆盖——多半是手改记录时改坏的。'));
|
|
118
|
+
}
|
|
89
119
|
}
|
|
90
120
|
|
|
91
121
|
// ---- 合并:hooks / mcp 有没有真的写进工具自己的配置 ----
|
|
@@ -93,50 +123,22 @@ export async function run({ cwd }) {
|
|
|
93
123
|
// 这两类不建链接,而是合并进 `.mcp.json` / `.claude/settings.json`。
|
|
94
124
|
// 五态各有各的说法,别混成一句「有问题」:
|
|
95
125
|
// 一致 / 还没生成 / 漂移 / 读不了 / 有本工具没动的地方
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
// 「该不该合」看记录(sync 只合选中的),「有没有东西合不了」看目录。
|
|
100
|
-
// 混成一个的话,还没写过记录的新项目就不会被告知「codex 没有 MCP 目标」。
|
|
101
|
-
const present = Object.fromEntries(MERGE_KINDS.map((d) => [d, presentIn(cwd, d)]));
|
|
102
|
-
const presentCount = MERGE_KINDS.reduce((n, d) => n + (present[d]?.length ?? 0), 0);
|
|
126
|
+
// 判断整个交给 merge.js 的 checkMergeView——doctor 用的是同一份。两个只读
|
|
127
|
+
// 命令对同一份现场给出相反结论,是以前真出过的事(见 checkMergeAll 的注释)。
|
|
128
|
+
const view = checkMergeView({ projectRoot: cwd, config, record });
|
|
103
129
|
|
|
104
130
|
// 「目录读不出来」和「目录是空的」是两件事,不能一声不吭当成后者:
|
|
105
131
|
// 前者我们根本不知道里面有什么,也就答不了「该合的合了没有」。
|
|
106
132
|
// 报一声,但**不算问题**——和下面「记录读不了」一个口径:判断不了的事
|
|
107
133
|
// 如实说,不把它变成用户要去点一下的修复项(那会是个他不会采纳的修复)。
|
|
108
|
-
for (const d of
|
|
109
|
-
|
|
110
|
-
warn(`${CONTENT_ROOT}/${d}/ 读不了——里面有什么、能不能合,判断不了`);
|
|
111
|
-
}
|
|
134
|
+
for (const d of view.unreadable) {
|
|
135
|
+
warn(`${CONTENT_ROOT}/${d}/ 读不了——里面有什么、能不能合,判断不了`);
|
|
112
136
|
}
|
|
113
137
|
|
|
114
|
-
|
|
115
|
-
// 实际上没人在管」——上面那声 warn 已经打了,这里让这一节照样进来
|
|
116
|
-
const anyUnreadable = MERGE_KINDS.some((d) => present[d] === null);
|
|
117
|
-
|
|
118
|
-
if (
|
|
119
|
-
config.tools.length > 0 &&
|
|
120
|
-
(presentCount > 0 || anyUnreadable || Object.keys(record.merged ?? {}).length > 0)
|
|
121
|
-
) {
|
|
138
|
+
if (config.tools.length > 0 && view.hasWork) {
|
|
122
139
|
title('合并(hooks / mcp)');
|
|
123
140
|
|
|
124
|
-
|
|
125
|
-
const keepByKind = Object.fromEntries(MERGE_KINDS.map((d) => [d, []]));
|
|
126
|
-
for (const key of config.protect) {
|
|
127
|
-
const colon = key.indexOf(':');
|
|
128
|
-
if (colon === -1) continue;
|
|
129
|
-
const dir = ITEM_KINDS[key.slice(0, colon)]?.dir;
|
|
130
|
-
if (dir && MERGE_KINDS.includes(dir)) keepByKind[dir].push(key.slice(colon + 1));
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
for (const r of checkMergeAll({
|
|
134
|
-
projectRoot: cwd,
|
|
135
|
-
tools: config.tools,
|
|
136
|
-
sourcesByKind,
|
|
137
|
-
keepByKind,
|
|
138
|
-
prevMerged: record.merged ?? {},
|
|
139
|
-
})) {
|
|
141
|
+
for (const r of view.rows) {
|
|
140
142
|
const label = `${TOOLS[r.tool].label} · ${r.rel}`.padEnd(30);
|
|
141
143
|
|
|
142
144
|
switch (r.state) {
|
|
@@ -173,17 +175,11 @@ export async function run({ cwd }) {
|
|
|
173
175
|
}
|
|
174
176
|
|
|
175
177
|
// 合并器做完之后,这是唯一还会为「装了没生效」说话的地方
|
|
176
|
-
for (const
|
|
177
|
-
|
|
178
|
-
if (mergeTarget(tool, dir)) continue;
|
|
179
|
-
const n = present[dir]?.length ?? 0;
|
|
180
|
-
if (n > 0) {
|
|
181
|
-
warn(`${TOOLS[tool].label} 没有 ${dir} 的合并目标——这 ${n} 条装了不会生效`);
|
|
182
|
-
}
|
|
183
|
-
}
|
|
178
|
+
for (const u of view.unsupported) {
|
|
179
|
+
warn(`${TOOLS[u.tool].label} 没有 ${u.dir} 的合并目标——这 ${u.count} 条装了不会生效`);
|
|
184
180
|
}
|
|
185
181
|
|
|
186
|
-
if (config.tools.includes('claude') && (present.mcp?.length ?? 0) > 0) {
|
|
182
|
+
if (config.tools.includes('claude') && (view.present.mcp?.length ?? 0) > 0) {
|
|
187
183
|
plain(
|
|
188
184
|
dim(' 注意:MCP server 还要在 Claude Code 里逐条批准才生效——批准记录存在本机,不随仓库共享。'),
|
|
189
185
|
);
|
|
@@ -211,20 +207,39 @@ export async function run({ cwd }) {
|
|
|
211
207
|
}
|
|
212
208
|
|
|
213
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
|
+
|
|
214
231
|
if (config.tools.length === 0) {
|
|
215
232
|
title('链接');
|
|
216
233
|
warn(`未声明任何工具——${CONFIG_FILENAME} 里没有 links`);
|
|
217
234
|
plain(dim(' 可选:' + TOOL_NAMES.join('、')));
|
|
218
235
|
plain(dim(' 写进配置里,或在终端里跑一次 agent-syncer link 让它问你要用哪些。'));
|
|
219
|
-
return
|
|
236
|
+
return finish();
|
|
220
237
|
}
|
|
221
238
|
if (!config.exists) {
|
|
222
239
|
plain(dim(`(未找到 agents.json:${config.warnings.join(';')})`));
|
|
223
240
|
}
|
|
224
241
|
|
|
225
242
|
const plan = plannedLinks(cwd, config.specs);
|
|
226
|
-
let healthy = 0;
|
|
227
|
-
let absent = 0;
|
|
228
243
|
|
|
229
244
|
// 按工具分组展示,读起来比一条长清单清楚
|
|
230
245
|
for (const tool of config.tools) {
|
|
@@ -296,22 +311,13 @@ export async function run({ cwd }) {
|
|
|
296
311
|
} else if (gi.missing.length > 0) {
|
|
297
312
|
warn(`托管段已存在,但缺少 ${gi.missing.length} 个条目,运行 link 会补齐`);
|
|
298
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}`));
|
|
299
318
|
} else {
|
|
300
319
|
ok('完整');
|
|
301
320
|
}
|
|
302
321
|
|
|
303
|
-
|
|
304
|
-
title('小结');
|
|
305
|
-
plain(` 健康 ${healthy} 未创建 ${absent}${problems.length ? ` 问题 ${problems.length}` : ''}`);
|
|
306
|
-
|
|
307
|
-
if (problems.length > 0) {
|
|
308
|
-
title('需要你处理');
|
|
309
|
-
for (const p of problems) plain(` · ${p}`);
|
|
310
|
-
return 1;
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
if (absent > 0) {
|
|
314
|
-
info(`有 ${absent} 个链接未创建,运行 ${dim('agent-syncer link')} 即可建好`);
|
|
315
|
-
}
|
|
316
|
-
return 0;
|
|
322
|
+
return finish();
|
|
317
323
|
}
|