agent-syncer 0.1.0

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 ADDED
@@ -0,0 +1,155 @@
1
+ # agent-syncer
2
+
3
+ 把一份 AI 资产分发到多个编码工具——**一处保存,处处可用**。
4
+
5
+ 部门里同时在用 Claude Code、Trae、Codex 的人越来越多,同一套规范要往三四个目录里各放一份,
6
+ 改一次就得同步好几次。`agent-syncer` 把这件事收敛成:内容只存在 `.agents/` 下**一份**,
7
+ 其余工具目录一律用链接指过来。
8
+
9
+ ```
10
+ .agents/skills/code-style/SKILL.md ← 唯一真源
11
+ ↑ ↑ ↑
12
+ .claude/skills .trae/skills .codex/skills
13
+ ```
14
+
15
+ ## 为什么不用 Claude Code 的插件机制
16
+
17
+ 试过,有硬伤(详见 `../HANDOFF.md`):
18
+
19
+ - 插件级 hook 的 `additionalContext` 有上游 bug(issue #16538),注入不了上下文
20
+ - 插件没有 `rules` 组件
21
+ - 跨工具完全无法复用
22
+ - `claude plugin marketplace remove` 会顺手清空项目里的声明
23
+
24
+ 而**项目级的 `.claude/rules/*.md` 是有效的**(本仓库实测,含嵌套子目录)——这正是走 CLI 路线的底气。
25
+
26
+ ## 快速开始
27
+
28
+ > ⚠️ **当前是 alpha 版,发布在 `next` tag 下。**
29
+ > 必须带 `@next`,写成 `npx agent-syncer` 会取不到(npm 默认只认 `latest`)。
30
+ > 等发稳定版后去掉 `@next` 即可。
31
+
32
+ ```bash
33
+ # 1. 只读检查环境
34
+ npx agent-syncer@next doctor
35
+
36
+ # 2. 看当前状态
37
+ npx agent-syncer@next status
38
+
39
+ # 3. 预演一遍(不写盘)
40
+ npx agent-syncer@next link --dry-run
41
+
42
+ # 4. 真正建好
43
+ npx agent-syncer@next link
44
+ ```
45
+
46
+ > **这一步之前,项目里得先有 `.agents/`。** `link` 只建链接、不拉内容;
47
+ > `.agents/` 不存在时会直接报错退出。内容从远端仓库拉取是第二阶段 `sync` 的事。
48
+
49
+ **第一次跑 `link` 时会让你勾选。** 项目根没有 `agents.json` 的情况下,它不会自作主张,
50
+ 而是把每一条链接目标列出来让你选:
51
+
52
+ ```
53
+ 没有找到 agents.json,请勾选要建立链接的目录:
54
+ ❯ ◉ Claude Code · .claude/skills
55
+ ◉ Claude Code · .claude/rules
56
+ ◯ Claude Code · .claude/agents (.agents/agents/ 暂无内容)
57
+ ◉ Trae · .trae/skills
58
+ ◉ Codex CLI · .codex/skills
59
+ ↑↓ 移动 · 空格 勾选 · a 全选 · 回车 确认 · Ctrl-C 取消
60
+ ```
61
+
62
+ 勾选结果会写进 `agents.json`,下次不再问:
63
+
64
+ ```json
65
+ {
66
+ "links": [
67
+ "claude/skills",
68
+ "claude/rules",
69
+ "trae/skills",
70
+ "codex/skills"
71
+ ]
72
+ }
73
+ ```
74
+
75
+ 想写成"这个工具的类型全要"也行,两种写法等价:
76
+
77
+ ```json
78
+ {
79
+ "tools": ["claude", "trae", "codex"]
80
+ }
81
+ ```
82
+
83
+ **非交互环境(CI、管道、`postinstall`)不会弹提示**——`link` 会退回到按已有目录推断,
84
+ 并明确打印推断了什么。脚本里想跳过询问,用 `--yes`。
85
+
86
+ ## 命令
87
+
88
+ | 命令 | 作用 | 写盘 |
89
+ | --- | --- | --- |
90
+ | `link` | 建好目录链接并维护 `.gitignore`;没有 `agents.json` 时先让你勾选 | 是(支持 `--dry-run`) |
91
+ | `status` | 每个链接是否健康、`.agents/` 下各有多少内容 | 否 |
92
+ | `doctor` | 运行环境、链接能力实测、`.gitignore`、各工具是否存在 | 否 |
93
+
94
+ 选项:`--dry-run` `--force` `--yes` `--no-save` `--cwd=<路径>` `--help`
95
+
96
+ ## 目录约定
97
+
98
+ ```
99
+ .agents/
100
+ ├── skills/<名字>/SKILL.md # 技能(目录形态,可含 references/ scripts/ 等)
101
+ ├── rules/<名字>.md # 规则
102
+ ├── commands/<名字>.md # 斜杠命令
103
+ └── agents/<名字>.md # 子代理
104
+ ```
105
+
106
+ 内容只在上面保存一份,各工具的实际位置由内置映射表决定:
107
+
108
+ | 内容 | Claude Code | Trae | Codex |
109
+ | --- | --- | --- | --- |
110
+ | skills | `.claude/skills` | `.trae/skills` | `.codex/skills` |
111
+ | rules | `.claude/rules` | `.trae/rules` | 合并进 `AGENTS.md` |
112
+ | commands | `.claude/commands` | `.trae/commands` | —— 未见项目级机制 |
113
+ | agents | `.claude/agents` | —— | —— |
114
+
115
+ > **关于 Codex 的规则**:它**有**规则机制,只是没有"规则目录"。
116
+ > 规则写在 `AGENTS.md` 里,优先级是 `AGENTS.override.md` > `AGENTS.md`
117
+ > > `project_doc_fallback_filenames` 里配置的名字;且按目录作用域生效——
118
+ > 子目录里的 `AGENTS.md` 只管它自己那棵子树。它也不支持 `@import`,
119
+ > 所以 rules 只能合并进 `AGENTS.md`,不能像 Claude / Trae 那样用目录链接分发。
120
+ > 注意默认有 `project_doc_max_bytes = 32768` 的大小上限。
121
+ >
122
+ > **commands 存疑**:在 codex.exe(0.154.0)里没找到项目级自定义命令的机制
123
+ > ——出现的 `prompts` 都是 MCP 协议自身的(`prompts/list`)。所以 `.codex/commands`
124
+ > 很可能和 `.codex/rules` 一样是空转的。这条**没有实测过**,只是没有证据支持它有效。
125
+
126
+ ## 几条设计取舍
127
+
128
+ **只链接目录,不链接文件。** Windows 上目录可以用 `junction`(不需要管理员权限),
129
+ 文件链接则必须管理员或开发者模式。为了所有人开箱即用,本工具干脆不支持文件链接——
130
+ 单文件的配置(如 `settings.json`)以后走「读-改-写合并」,不走链接。
131
+
132
+ **绝不删除实体目录。** 路径上已经有用户自己的目录或文件时,`link` 一律拒绝并报错,
133
+ 退出码非零。指向别处的链接需要显式 `--force` 才接管,且 `--force` 也只删链接、不删实体目录。
134
+
135
+ **`.gitignore` 托管段是必需的。** 链接记录的是**绝对路径**,提交上去在别人机器上全是错的;
136
+ 更危险的是 git 会**穿透链接**,把 `.agents/` 的内容原样再提交一份,仓库里出现两套内容。
137
+ `link` 会自动写入这段(`.claude/settings.json`、`.claude/hooks/` **不在**忽略之列,那是要进版本库的真实配置)。
138
+
139
+ **没有状态文件。** 状态全部从文件系统推出来。少存一份状态,就少一次不同步的机会。
140
+
141
+ ## 已知限制
142
+
143
+ - **Trae 的路径约定未经实证**——本机没装 Trae,映射表沿用既有项目里在用的配置
144
+ - **Codex 没有 rules / commands 目录概念**,它只有 `AGENTS.md` 和 `config.toml`。
145
+ 这两类内容将来要合并进 `AGENTS.md`,目前直接跳过
146
+ - **Windows 的 junction 不支持 UNC 网络路径**。工作目录在网络盘上时会明确报错
147
+ - 目前只做**链接**。从远端仓库拉取内容、合并配置文件属于第二阶段
148
+
149
+ ## 开发
150
+
151
+ ```bash
152
+ npm test # node --test,零依赖
153
+ ```
154
+
155
+ 代码约定与 `iqcs_web/scripts/` 保持一致:ESM、`node:` 前缀、手写参数解析、中文 + emoji 输出。
@@ -0,0 +1,104 @@
1
+ #!/usr/bin/env node
2
+ // @ts-check
3
+ import path from 'node:path';
4
+ import process from 'node:process';
5
+ import { bold, dim, fail, plain, title } from '../lib/log.js';
6
+ import { TOOL_NAMES } from '../lib/target.js';
7
+
8
+ const USAGE = `${bold('agent-syncer')} — 把 .agents/ 下的 AI 资产分发到各编码工具的配置目录
9
+
10
+ ${bold('用法')}
11
+ agent-syncer <命令> [选项]
12
+
13
+ ${bold('命令')}
14
+ link 建好目录链接并维护 .gitignore;没有 agents.json 时会先让你勾选
15
+ status 只读报告:每个链接当前是否健康,.agents/ 下各有多少内容
16
+ doctor 只读自检:运行环境、链接能力、.gitignore、各工具是否存在
17
+
18
+ ${bold('选项')}
19
+ --dry-run 只打印计划,不写盘(link 支持)
20
+ --force 覆盖指向别处的链接(绝不删除实体目录)
21
+ --yes 不询问,直接按已有目录推断(CI / postinstall 用)
22
+ --no-save 交互式勾选后不写入 agents.json
23
+ --cwd=<路径> 指定项目根,默认当前目录
24
+ --help 显示本帮助
25
+
26
+ ${bold('内容约定')}
27
+ 所有内容只在 ${bold('.agents/')} 下保存一份:
28
+ .agents/skills/<名字>/SKILL.md
29
+ .agents/rules/<名字>.md
30
+ .agents/commands/<名字>.md
31
+ .agents/agents/<名字>.md
32
+ 再由本工具在各工具目录建链接指过来,因此永远只有一份真源。
33
+
34
+ ${bold('可用工具')}
35
+ ${TOOL_NAMES.join('、')}
36
+
37
+ ${bold('示例')}
38
+ agent-syncer status
39
+ agent-syncer link # 没有配置时会让你勾选要建立哪些链接
40
+ agent-syncer link --dry-run
41
+ agent-syncer link --force
42
+ agent-syncer link --yes # 不询问,适合脚本
43
+ `;
44
+
45
+ /** 手写参数解析,与 iqcs_web/scripts 下的既有风格一致(不引第三方依赖) */
46
+ function parseArgs(argv) {
47
+ /** @type {Record<string, string|boolean> & {_: string[]}} */
48
+ const flags = { _: [] };
49
+ for (const a of argv) {
50
+ if (a.startsWith('--')) {
51
+ const body = a.slice(2);
52
+ const eq = body.indexOf('=');
53
+ if (eq === -1) flags[body] = true;
54
+ else flags[body.slice(0, eq)] = body.slice(eq + 1);
55
+ } else {
56
+ flags._.push(a);
57
+ }
58
+ }
59
+ return flags;
60
+ }
61
+
62
+ const COMMANDS = /** @type {const} */ ({
63
+ link: '../lib/commands/link.js',
64
+ status: '../lib/commands/status.js',
65
+ doctor: '../lib/commands/doctor.js',
66
+ });
67
+
68
+ async function main() {
69
+ const args = parseArgs(process.argv.slice(2));
70
+
71
+ if (args.version === true) {
72
+ const { createRequire } = await import('node:module');
73
+ const require = createRequire(import.meta.url);
74
+ plain(require('../package.json').version);
75
+ return 0;
76
+ }
77
+
78
+ const cmd = args._[0];
79
+
80
+ if (!cmd || args.help === true || cmd === 'help') {
81
+ plain(USAGE);
82
+ return 0;
83
+ }
84
+
85
+ const loader = /** @type {Record<string, string>} */ (COMMANDS)[cmd];
86
+ if (!loader) {
87
+ fail(`未知命令:${cmd}`);
88
+ plain(`\n可用命令:${Object.keys(COMMANDS).join('、')}。用 --help 查看全部用法。`);
89
+ return 1;
90
+ }
91
+
92
+ const cwd = typeof args.cwd === 'string' ? path.resolve(args.cwd) : process.cwd();
93
+ const mod = await import(loader);
94
+ return (await mod.run({ cwd, flags: args })) ?? 0;
95
+ }
96
+
97
+ main()
98
+ .then((code) => process.exit(code))
99
+ .catch((err) => {
100
+ fail(err?.message ?? String(err));
101
+ if (process.env.AGENT_SYNC_DEBUG) console.error(err);
102
+ else plain(dim('(加 AGENT_SYNC_DEBUG=1 可看到完整堆栈)'));
103
+ process.exit(1);
104
+ });
@@ -0,0 +1,132 @@
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 } from '../config.js';
6
+ import { checkBlock } from '../gitignore.js';
7
+ import { probeLinkCapability } from '../link.js';
8
+ import { dim, fail, ok, plain, skip, title, warn } from '../log.js';
9
+ import { CONTENT_ROOT, TOOLS, TOOL_NAMES } from '../target.js';
10
+
11
+ /** @type {string[]} */
12
+ const PACKAGE_TOOL_HINTS = {
13
+ claude: '.claude',
14
+ trae: '.trae',
15
+ codex: '.codex',
16
+ };
17
+
18
+ /**
19
+ * agent-syncer doctor —— 纯只读的环境自检。
20
+ * 目的:在出问题之前,把「环境支不支持」讲清楚。
21
+ *
22
+ * @param {{cwd: string, flags: Record<string, any>}} ctx
23
+ */
24
+ export async function run({ cwd }) {
25
+ title('agent-syncer doctor');
26
+ plain(dim(`项目根:${cwd}`));
27
+ plain(dim(`平台:${process.platform} Node:${process.version}`));
28
+
29
+ let fatal = 0;
30
+ let warned = 0;
31
+
32
+ // ---- 1. Node 版本 ----
33
+ title('运行环境');
34
+ const major = Number(process.versions.node.split('.')[0]);
35
+ const minor = Number(process.versions.node.split('.')[1]);
36
+ if (major > 20 || (major === 20 && minor >= 11)) {
37
+ ok(`Node ${process.version}`);
38
+ } else {
39
+ fail(`Node ${process.version} 过低,需要 >= 20.11.0`);
40
+ fatal += 1;
41
+ }
42
+
43
+ // ---- 2. 链接能力实测 ----
44
+ const probe = probeLinkCapability();
45
+ if (probe.ok) {
46
+ ok(`可创建目录链接(${probe.type})`);
47
+ plain(dim(` 实测解析到:${probe.resolved}`));
48
+ } else {
49
+ fail(`无法创建目录链接:${probe.message ?? '未知原因'}`);
50
+ plain(dim(' Windows 上应为 junction,无需管理员权限;失败常见于目录在网络驱动器上'));
51
+ fatal += 1;
52
+ }
53
+
54
+ // ---- 3. 内容目录 ----
55
+ title('内容');
56
+ const contentRoot = path.resolve(cwd, CONTENT_ROOT);
57
+ if (fs.existsSync(contentRoot)) {
58
+ const kinds = fs
59
+ .readdirSync(contentRoot, { withFileTypes: true })
60
+ .filter((e) => e.isDirectory())
61
+ .map((e) => e.name);
62
+ ok(`${CONTENT_ROOT}/ 存在`);
63
+ if (kinds.length > 0) plain(dim(` 子目录:${kinds.join('、')}`));
64
+ else warn(`${CONTENT_ROOT}/ 是空的,还没有任何内容`);
65
+ } else {
66
+ warn(`${CONTENT_ROOT}/ 不存在——第二步的 sync 命令会创建并拉取内容`);
67
+ warned += 1;
68
+ }
69
+
70
+ // ---- 4. 项目配置 ----
71
+ title('项目配置');
72
+ const config = loadConfig(cwd);
73
+ if (config.exists) {
74
+ ok(`${CONFIG_FILENAME} 存在`);
75
+ if (config.links.length > 0) plain(dim(` links:${config.links.join('、')}`));
76
+ else warn('links 为空');
77
+ } else {
78
+ skip(`${CONFIG_FILENAME} 不存在(link 会交互式询问,或按已有目录推断)`);
79
+ if (config.links.length > 0) plain(dim(` 推断结果:${config.links.join('、')}`));
80
+ else plain(dim(' 没有可推断的项——需要交互式勾选'));
81
+ }
82
+ for (const w of config.warnings) warn(w);
83
+
84
+ // ---- 5. .gitignore ----
85
+ title('.gitignore');
86
+ const gi = checkBlock(cwd, config.links);
87
+ if (!gi.present) {
88
+ warn('未找到 agent-syncer 托管段,运行 link 会写入');
89
+ warned += 1;
90
+ } else if (gi.missing.length > 0) {
91
+ warn(`托管段缺少 ${gi.missing.length} 个条目,运行 link 会补齐`);
92
+ for (const m of gi.missing) plain(dim(` · ${m}`));
93
+ warned += 1;
94
+ } else {
95
+ ok('托管段完整');
96
+ }
97
+ plain(
98
+ dim(
99
+ ' 该段忽略各工具的链接目录:链接记录绝对路径,提交后对别人无效,' +
100
+ '且 git 会穿透链接重复提交内容',
101
+ ),
102
+ );
103
+
104
+ // ---- 6. 各工具是否存在 ----
105
+ title('工具');
106
+ for (const name of TOOL_NAMES) {
107
+ const dir = path.resolve(cwd, /** @type {Record<string,string>} */ (PACKAGE_TOOL_HINTS)[name]);
108
+ const onDisk = fs.existsSync(dir);
109
+ const declared = config.tools.includes(name);
110
+ const kinds = Object.keys(TOOLS[name].links).join('/');
111
+ if (onDisk && declared) ok(`${TOOLS[name].label.padEnd(12)} 目录存在,已声明 ${dim(kinds)}`);
112
+ else if (onDisk && !declared) warn(`${TOOLS[name].label.padEnd(12)} 目录存在,但未在 tools 里声明`);
113
+ else if (!onDisk && declared) {
114
+ const how = config.inferred ? '默认使用' : '已声明';
115
+ warn(`${TOOLS[name].label.padEnd(12)} ${how},但项目里没有 ${'.' + name} 目录`);
116
+ }
117
+ else skip(`${TOOLS[name].label.padEnd(12)} 未使用`);
118
+ }
119
+
120
+ // ---- 小结 ----
121
+ title('小结');
122
+ if (fatal > 0) {
123
+ fail(`发现 ${fatal} 个致命问题,agent-syncer link 无法正常工作`);
124
+ return 1;
125
+ }
126
+ if (warned > 0) {
127
+ warn(`环境可用,但有 ${warned} 项提示(多数会在运行 link / sync 后消失)`);
128
+ return 0;
129
+ }
130
+ ok('一切正常');
131
+ return 0;
132
+ }
@@ -0,0 +1,215 @@
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, saveConfig } from '../config.js';
6
+ import { writeBlock } from '../gitignore.js';
7
+ import { STATUS, createLink, inspect, removeLink } from '../link.js';
8
+ import { dim, fail, info, ok, plain, skip, title, warn } from '../log.js';
9
+ import { checkbox, isInteractive } from '../prompt.js';
10
+ import { CONTENT_ROOT, TOOLS, specOf, plannedLinks, kindsOf, TOOL_NAMES } from '../target.js';
11
+
12
+ /**
13
+ * 构造勾选项。
14
+ *
15
+ * 列出的是**链接目标**(如 .claude/skills),因为用户要判断的正是
16
+ * 「哪些目录可以被这份内容接管」,而不是抽象的"用哪个工具"。
17
+ *
18
+ * 与「怎么问」分开,是为了这一层能单独测试——渲染和按键由 prompt.js 负责。
19
+ *
20
+ * @param {string} cwd @param {string[]} preselected
21
+ */
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}/ 暂无内容`);
33
+ }
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
+ }
43
+ }
44
+ return choices;
45
+ }
46
+
47
+ /**
48
+ * agent-syncer link —— 建好所有目录链接,并维护 .gitignore 的托管段。
49
+ *
50
+ * 安全底线:**绝不删除实体目录或实体文件**。
51
+ * 路径上已有非链接的东西时一律拒绝并报错,交由用户自己处置。
52
+ *
53
+ * 输入输出流可注入,生产环境用默认值,测试时可传替身来跑完整交互链路。
54
+ *
55
+ * @param {{cwd: string, flags: Record<string, any>, input?: NodeJS.ReadStream, output?: NodeJS.WriteStream}} ctx
56
+ */
57
+ export async function run({ cwd, flags, input = process.stdin, output = process.stdout }) {
58
+ const dryRun = Boolean(flags['dry-run']);
59
+ const force = Boolean(flags.force);
60
+ const assumeYes = Boolean(flags.yes);
61
+ const noSave = Boolean(flags['no-save']);
62
+
63
+ title(`agent-syncer link${dryRun ? dim('(--dry-run,不会写盘)') : ''}`);
64
+ plain(dim(`项目根:${cwd}`));
65
+
66
+ const contentRoot = path.resolve(cwd, CONTENT_ROOT);
67
+ if (!fs.existsSync(contentRoot)) {
68
+ fail(`内容目录不存在:${contentRoot}`);
69
+ plain(' 请先建好 .agents/ 并放入内容(第二步的 sync 命令会自动拉取)。');
70
+ return 1;
71
+ }
72
+
73
+ let config = loadConfig(cwd);
74
+
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
+ }
98
+
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 };
106
+ }
107
+ }
108
+
109
+ for (const w of config.warnings) warn(w);
110
+
111
+ if (config.links.length === 0) {
112
+ warn('没有任何要建立的链接,无事可做');
113
+ return 0;
114
+ }
115
+
116
+ const plan = plannedLinks(cwd, config.links);
117
+ let created = 0;
118
+ let alreadyOk = 0;
119
+ let skipped = 0;
120
+ /** @type {string[]} */
121
+ const problems = [];
122
+
123
+ for (const item of plan) {
124
+ const label = `${TOOLS[item.tool].label} ${dim('·')} ${item.rel}`;
125
+
126
+ if (!fs.existsSync(item.source)) {
127
+ skip(`${label} ${dim(`内容目录 ${CONTENT_ROOT}/${item.kind}/ 不存在,跳过`)}`);
128
+ skipped += 1;
129
+ continue;
130
+ }
131
+
132
+ const before = inspect(item.target, item.source);
133
+
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
+ }
145
+
146
+ if (before.status === STATUS.ELSEWHERE && !force) {
147
+ fail(`${label} 已是指向别处的链接 → ${before.actual}`);
148
+ problems.push(`${item.rel} 指向别处,确认无误后加 --force 覆盖`);
149
+ continue;
150
+ }
151
+
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);
159
+ if (!removed.ok) {
160
+ fail(`${label} 移除旧链接失败:${removed.message}`);
161
+ problems.push(`${item.rel}:${removed.message}`);
162
+ continue;
163
+ }
164
+ }
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
+
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}:创建后校验未通过`);
184
+ }
185
+ }
186
+ }
187
+
188
+ // .gitignore:junction 记录绝对路径,必须忽略;否则 git 会穿透链接重复提交内容
189
+ const gi = writeBlock(cwd, config.links, { dryRun });
190
+ if (gi.changed) {
191
+ if (dryRun) info(`${dim('.gitignore')} 将写入托管段`);
192
+ else ok(`${dim('.gitignore')} 托管段已更新`);
193
+ } else {
194
+ ok(`${dim('.gitignore')} 托管段已是最新`);
195
+ }
196
+
197
+ title('小结');
198
+ plain(
199
+ ` 新创建 ${created} 已存在 ${alreadyOk} 跳过 ${skipped}` +
200
+ `${problems.length ? ` 问题 ${problems.length}` : ''}`,
201
+ );
202
+
203
+ if (problems.length > 0) {
204
+ title('需要你处理');
205
+ for (const p of problems) plain(` · ${p}`);
206
+ return 1;
207
+ }
208
+
209
+ if (dryRun) {
210
+ plain(dim('\n这是预演,未写盘。去掉 --dry-run 即可实际执行。'));
211
+ } else if (created > 0) {
212
+ plain(dim('\n提示:这些链接已加入 .gitignore,别把它们提交上去。'));
213
+ }
214
+ return 0;
215
+ }