@dog_world/dak 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 zyZ
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,142 @@
1
+ # dak — Dog Agents Kit CLI
2
+
3
+ ## 这是什么 / 做什么用
4
+
5
+ `dak` 是一个本机命令行小工具,帮你**把同一批 skills / hooks / agents 同时接到多个 AI 工具(Codex、Claude Code 等)上,还不用每个工具都复制一份**。
6
+
7
+ **怎么做到的**:你把 skill / hook / agent 的源文件集中放进一个文件夹(store)。dak 在各工具目录里建「软链接」(快捷方式)指回 store 里的源文件,不复制实体文件。于是:
8
+
9
+ - 一份源文件,多个工具共用;
10
+ - 改 store 里这一份,所有工具立刻看到新内容;
11
+ - 新加工具,跑一下 `dak link` 就接上,不用再复制。
12
+
13
+ **解决的痛点**:同一 skill 想给 Codex 和 Claude Code 用,以前得复制两份(`~/.codex/skills/foo` 和 `~/.claude/skills/foo`),改了 foo 得记着改两处,漏一处就不同步。用 dak 只存一份,两边都生效。
14
+
15
+ ## 安装
16
+
17
+ ```bash
18
+ git clone <repo> && cd dog-agents-kit
19
+ npm install
20
+ npm run build # 产物在 dist/
21
+ npm link # 注册全局命令 dak(或直接 node dist/cli.js)
22
+ ```
23
+
24
+ ## 创建的目录与结构
25
+
26
+ `dak init` 后在 `~/.dog-agents-kit/`(默认 store)生成:
27
+
28
+ ```
29
+ ~/.dog-agents-kit/
30
+ ├── dak.config.json # 配置:store 路径 + 目标工具映射
31
+ ├── .dak-state.json # 链接状态(dak 自维护,别手改)
32
+ ├── skills/ # 你的 skills 源文件
33
+ ├── hooks/ # 你的 hooks 源文件
34
+ └── agents/ # 你的 agents 源文件
35
+ ```
36
+
37
+ 默认配置两个目标工具(`dak init` 自动写入):
38
+
39
+ | 目标名 | 工具根目录 |
40
+ |--------|-----------|
41
+ | `codex` | `~/.codex` |
42
+ | `claudecode` | `~/.claude` |
43
+
44
+ > 目标名是自己起的 key,不是写死的。想加 Cursor/Windsurf 等工具,直接编辑 `dak.config.json` 的 `targets` 加一行即可。换 store 位置用 `dak init --store <path>`,多 store 并存互不干扰。
45
+
46
+ ## 指令大集合
47
+
48
+ ### 用法示例
49
+
50
+ | 指令 | 示例 |
51
+ |------|------|
52
+ | 初始化 store | `dak init` |
53
+ | 初始化到自定义位置 | `dak init --store ~/my-dak-store` |
54
+ | 列出 store 资源 | `dak list` |
55
+ | 列出指定 store | `dak list --store ~/my-dak-store` |
56
+ | 链接到 Codex | `dak link codex` |
57
+ | 链接到 Claude Code | `dak link claudecode` |
58
+ | 一次性链全部目标 | `dak link all` |
59
+ | 只链 hooks | `dak link codex -r hooks` |
60
+ | 只链 skills | `dak link codex -r skills` |
61
+ | 只链 agents | `dak link codex -r agents` |
62
+ | 冲突时备份旧文件再链 | `dak link codex --on-conflict backup` |
63
+ | 冲突时直接覆盖 | `dak link all --on-conflict overwrite` |
64
+ | 查看链接状态 | `dak status` |
65
+ | 只看 skills 状态 | `dak status -r skills` |
66
+ | 同步:补新链、清失效链 | `dak update` |
67
+ | 只同步 hooks | `dak update -r hooks` |
68
+ | 同步时冲突先备份 | `dak update --on-conflict backup` |
69
+ | 取消 Codex 全部链接 | `dak unlink codex` |
70
+ | 取消所有目标链接 | `dak unlink all` |
71
+ | 只取消 agents 链接 | `dak unlink codex -r agents` |
72
+ | 取消所有目标的 hooks | `dak unlink all -r hooks` |
73
+
74
+ ### 指令解释
75
+
76
+ | 指令 | 解释 |
77
+ |------|------|
78
+ | `init` | 首次使用跑。建 store 目录、生成默认配置 `dak.config.json` 和空状态文件。配置已存在则不覆盖。 |
79
+ | `list` | 列出 store 里现有的 skills / hooks / agents(标注 `[dir]` 文件夹、`[link]` 软链)。不碰任何链接。 |
80
+ | `link <目标>` | 把 store 资源软链接到指定目标工具目录。`all` = 配置里所有目标。冲突按 `--on-conflict` 处理。 |
81
+ | `status` | 查看每个资源在目标里的链接状态(`linked`/`missing`/`stale`/`broken`/`conflict`)。只读,不改东西。 |
82
+ | `update` | 对齐 store 与已链接目标:store 新增的资源自动补链、store 删掉的资源自动清链。 |
83
+ | `unlink <目标>` | 取消链接,只删目标里的软链,**不删 store 源文件**。`all` = 取消所有目标。 |
84
+
85
+ ### 通用参数
86
+
87
+ | 参数 | 说明 | 适用命令 |
88
+ |------|------|---------|
89
+ | `--store <path>` | 指定 store 路径(须与配置里 `store` 一致,否则报 `config store mismatch`) | 全部 |
90
+ | `-r` / `--resource <类型>` | 只处理某一类资源(`skills`/`hooks`/`agents` 或 config 声明的自定义类型),不传则三类全处理 | `link` / `unlink` / `status` / `update` |
91
+ | `--on-conflict <skip\|backup\|overwrite>` | 冲突处理策略:跳过 / 备份旧的 / 覆盖旧的 | `link` / `update` |
92
+
93
+ > 冲突策略没传时:交互式终端(TTY)里 dak 逐个问你 `[s]kip/[b]ackup/[o]verwrite`(默认 skip);非交互(脚本/管道)直接 skip。
94
+ > **保护规则**:真实文件、其他 store 的链接、手动改过的链接,在 `update`/`unlink` 里永远受保护(`conflict` 状态),传 `--on-conflict overwrite` 也不会删。
95
+
96
+ ## 快速上手
97
+
98
+ ```bash
99
+ dak init # 1. 建默认 store
100
+ # 2. 把你的 skill/hook/agent 源文件丢进 ~/.dog-agents-kit/{skills,hooks,agents}/
101
+ dak link all # 3. 一键链到 codex + claudecode
102
+ dak status # 4. 看状态确认
103
+ # 之后改了 store 里的源文件 → 所有工具立即可见;
104
+ # 新增/删除资源后跑 dak update 对齐。
105
+ ```
106
+
107
+ ## 配置文件
108
+
109
+ `<store>/dak.config.json`,结构:
110
+
111
+ ```jsonc
112
+ {
113
+ "store": "~/.dog-agents-kit", // store 路径,须与 --store 解析后一致
114
+ "resourceTypes": ["skills", "hooks", "agents"], // 受管理资源类型,可加自定义
115
+ "targets": {
116
+ "codex": {
117
+ "path": "~/.codex", // 工具根目录(支持 ~ 或绝对路径)
118
+ "resources": { // 各资源在目标里的子路径;缺哪类就跳过哪类
119
+ "skills": "skills",
120
+ "hooks": "hooks",
121
+ "agents": "agents"
122
+ }
123
+ },
124
+ "claudecode": { "path": "~/.claude", "resources": { "skills": "skills", "hooks": "hooks", "agents": "agents" } }
125
+ // 加新工具:在这加一行 "cursor": { "path": "...", "resources": { "skills": "skills" } }
126
+ }
127
+ }
128
+ ```
129
+
130
+ 改完配置后跑 `dak link` / `dak update` 生效(`init` 不会覆盖已存在的配置)。
131
+
132
+ ## 状态字段(`status` / `unlink` 输出)
133
+
134
+ | 状态 | 含义 |
135
+ |------|------|
136
+ | `linked` | 软链存在且指向当前 store 源 |
137
+ | `created` / `backed-up` / `overwritten` | 本次新建 / 备份后新建 / 覆盖后新建 |
138
+ | `missing` | store 有此资源,但目标路径不存在 |
139
+ | `stale` | state 有记录,但 store 源已删(`update` 会清掉) |
140
+ | `broken` | 软链指向不存在的目标 |
141
+ | `conflict` | 目标位置是真实文件或外部软链(受保护,不删) |
142
+ | `deleted` | 本次安全删除链接 |
package/dist/cli.js ADDED
@@ -0,0 +1,150 @@
1
+ #!/usr/bin/env node
2
+ /** dak CLI 入口 */
3
+ import { runInit, runList, runLink, runStatus, runUpdate, runUnlink } from './commands.js';
4
+ import { createInterface } from 'node:readline/promises';
5
+ import { stdin as input, stdout as output } from 'node:process';
6
+ import { assertSafeItemName } from './paths.js';
7
+ /** 解析命令行参数(不引入额外依赖) */
8
+ export function parseArgv(argv) {
9
+ const result = { command: 'list' };
10
+ for (let i = 0; i < argv.length; i++) {
11
+ const arg = argv[i];
12
+ if (arg === '--store') {
13
+ const val = argv[i + 1];
14
+ if (!val)
15
+ throw new Error('--store requires a value');
16
+ result.store = val;
17
+ i++;
18
+ continue;
19
+ }
20
+ if (arg === '--on-conflict') {
21
+ const val = argv[i + 1];
22
+ if (!val)
23
+ throw new Error('--on-conflict requires a value');
24
+ if (!['skip', 'backup', 'overwrite'].includes(val)) {
25
+ throw new Error('Invalid conflict policy');
26
+ }
27
+ result.onConflict = val;
28
+ i++;
29
+ continue;
30
+ }
31
+ // 限定单资源类型:-r / --resource <name>(自定义类型需在 config.resourceTypes 声明)
32
+ if (arg === '--resource' || arg === '-r') {
33
+ const val = argv[i + 1];
34
+ if (!val)
35
+ throw new Error('--resource requires a value');
36
+ try {
37
+ assertSafeItemName(val);
38
+ }
39
+ catch {
40
+ throw new Error('Invalid resource type');
41
+ }
42
+ result.resource = val;
43
+ i++;
44
+ continue;
45
+ }
46
+ // 第一个非 flag 参数为命令或 target
47
+ if (!arg.startsWith('--')) {
48
+ const cmd = arg;
49
+ if (['init', 'list', 'link', 'status', 'update', 'unlink'].includes(cmd)) {
50
+ result.command = cmd;
51
+ // link/unlink 后跟 target
52
+ if (cmd === 'link' || cmd === 'unlink') {
53
+ const next = argv[i + 1];
54
+ if (!next || next.startsWith('--')) {
55
+ throw new Error(`${cmd} target is required`);
56
+ }
57
+ result.target = next;
58
+ i++;
59
+ }
60
+ }
61
+ else {
62
+ throw new Error(`Unknown command: ${arg}`);
63
+ }
64
+ }
65
+ }
66
+ // link/unlink 必须有 target
67
+ if ((result.command === 'link' || result.command === 'unlink') && !result.target) {
68
+ throw new Error(`${result.command} target is required`);
69
+ }
70
+ return result;
71
+ }
72
+ /**
73
+ * 交互式询问冲突策略(readline/promises)。
74
+ * 仅在 TTY 下由 main 注入。输入无效时默认 skip。
75
+ */
76
+ async function chooseConflictInteractive(target, resource, item) {
77
+ const rl = createInterface({ input, output });
78
+ try {
79
+ const prompt = `Conflict at ${target} ${resource}/${item}. Choose [s]kip/[b]ackup/[o]verwrite (default skip): `;
80
+ const answer = (await rl.question(prompt)).trim().toLowerCase();
81
+ if (answer === 'b' || answer === 'backup')
82
+ return 'backup';
83
+ if (answer === 'o' || answer === 'overwrite')
84
+ return 'overwrite';
85
+ return 'skip';
86
+ }
87
+ finally {
88
+ rl.close();
89
+ }
90
+ }
91
+ /** 主入口 */
92
+ export async function main(args = process.argv.slice(2)) {
93
+ const homeDir = process.env.HOME ?? '';
94
+ const interactive = process.stdin.isTTY && process.stdout.isTTY;
95
+ try {
96
+ const parsed = parseArgv(args);
97
+ const opts = { store: parsed.store, homeDir };
98
+ if (parsed.resource)
99
+ opts.resource = parsed.resource;
100
+ // 冲突策略
101
+ if (parsed.onConflict) {
102
+ opts.conflictPolicy = parsed.onConflict;
103
+ }
104
+ else if (interactive) {
105
+ opts.interactive = true;
106
+ opts.chooseConflict = (target, resource, item) => {
107
+ return chooseConflictInteractive(target, resource, item);
108
+ };
109
+ }
110
+ switch (parsed.command) {
111
+ case 'init':
112
+ console.log(await runInit(opts));
113
+ break;
114
+ case 'list':
115
+ console.log(await runList(opts));
116
+ break;
117
+ case 'link':
118
+ console.log(await runLink(parsed.target, opts));
119
+ break;
120
+ case 'status':
121
+ console.log(await runStatus(opts));
122
+ break;
123
+ case 'update':
124
+ console.log(await runUpdate(opts));
125
+ break;
126
+ case 'unlink':
127
+ console.log(await runUnlink(parsed.target, opts));
128
+ break;
129
+ }
130
+ return 0;
131
+ }
132
+ catch (e) {
133
+ console.error(`Error: ${e.message}`);
134
+ return 1;
135
+ }
136
+ }
137
+ // 直接运行时执行 main
138
+ import { fileURLToPath } from 'node:url';
139
+ import { realpathSync } from 'node:fs';
140
+ const __filename = fileURLToPath(import.meta.url);
141
+ // process.argv[1] 在通过 bin 软链接调用时仍是软链接路径,
142
+ // 而 import.meta.url 会被 Node 解析为真实路径。
143
+ // 用 realpathSync 把 argv[1] 也解析到真身后再比较,否则软链接调用时 main() 永不执行。
144
+ const invokedFrom = process.argv[1] ? realpathSync(process.argv[1]) : '';
145
+ if (invokedFrom === __filename) {
146
+ (async () => {
147
+ const code = await main();
148
+ process.exit(code);
149
+ })();
150
+ }
@@ -0,0 +1,298 @@
1
+ /** 命令工作流 */
2
+ import { existsSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { readConfig, createDefaultConfig, validateConfigStore, writeConfigIfMissing, resolveStorePath } from './config.js';
5
+ import { readState, writeState, writeStateIfMissing, upsertLinkRecord, removeLinkRecord, removeTargetState } from './state.js';
6
+ import { scanResources, formatResourceList, ensureStoreLayout } from './resources.js';
7
+ import { classifyTarget, linkItem, safeDeleteManagedLink } from './linker.js';
8
+ import { declaredResourceTypes } from './config.js';
9
+ /**
10
+ * 加载上下文(store/config/state)。
11
+ */
12
+ async function loadContext(opts) {
13
+ const homeDir = opts.homeDir ?? process.env.HOME ?? '';
14
+ const storePath = resolveStorePath(opts.store, homeDir);
15
+ const config = await readConfig(storePath);
16
+ validateConfigStore(config, storePath, homeDir);
17
+ const state = await readState(storePath);
18
+ return { storePath, config, state, homeDir };
19
+ }
20
+ /**
21
+ * 解析目标名称(支持 all)。
22
+ */
23
+ function resolveTargetNames(target, config) {
24
+ if (target === 'all') {
25
+ return Object.keys(config.targets ?? {});
26
+ }
27
+ if (!config.targets?.[target]) {
28
+ throw new Error(`unknown target: ${target}`);
29
+ }
30
+ return [target];
31
+ }
32
+ /**
33
+ * 本次命令要处理的资源类型集合。
34
+ * 指定 --resource 时只跑该类型,否则跑 config 声明的全部资源类型。
35
+ */
36
+ function effectiveResourceTypes(opts, config) {
37
+ if (opts.resource) {
38
+ const declared = declaredResourceTypes(config);
39
+ if (!declared.includes(opts.resource)) {
40
+ throw new Error(`unknown resource type: ${opts.resource}`);
41
+ }
42
+ return [opts.resource];
43
+ }
44
+ return declaredResourceTypes(config);
45
+ }
46
+ /**
47
+ * 获取目标 resource 路径(未配置则返回 null)。
48
+ */
49
+ function targetResourcePath(config, targetName, resourceType) {
50
+ const t = config.targets?.[targetName];
51
+ if (!t?.resources?.[resourceType])
52
+ return null;
53
+ return join(t.path, t.resources[resourceType]);
54
+ }
55
+ /**
56
+ * 构造冲突解析器(仅真实 conflict/broken 时由 linkItem 回调)。
57
+ * 有静态 --on-conflict 时不问;否则 interactive 模式下委托 chooseConflict。
58
+ * 返回 undefined 表示无 resolver,linkItem 回退到 policy(默认 skip)。
59
+ */
60
+ function resolveConflictFor(opts, target, resource, item) {
61
+ if (opts.conflictPolicy)
62
+ return undefined;
63
+ if (opts.interactive && opts.chooseConflict) {
64
+ return () => opts.chooseConflict(target, resource, item);
65
+ }
66
+ return undefined;
67
+ }
68
+ /** ─── Commands ─── **/
69
+ /** 初始化 store */
70
+ export async function runInit(opts) {
71
+ const homeDir = opts.homeDir ?? process.env.HOME ?? '';
72
+ const storePath = resolveStorePath(opts.store, homeDir);
73
+ const config = createDefaultConfig(storePath, homeDir);
74
+ await ensureStoreLayout(storePath, config);
75
+ await writeConfigIfMissing(storePath, config);
76
+ await writeStateIfMissing(storePath);
77
+ return `Initialized dak store at ${storePath}`;
78
+ }
79
+ /** 列出资源 */
80
+ export async function runList(opts) {
81
+ const { storePath, config } = await loadContext(opts);
82
+ const resources = await scanResources(storePath, config);
83
+ return formatResourceList(resources);
84
+ }
85
+ /** 链接资源到目标 */
86
+ export async function runLink(targetArg, opts) {
87
+ const { storePath, config, state } = await loadContext(opts);
88
+ const targetNames = resolveTargetNames(targetArg, config);
89
+ const lines = [];
90
+ let linked = 0, created = 0, conflicts = 0;
91
+ const items = await scanResources(storePath, config);
92
+ for (const tName of targetNames) {
93
+ for (const resourceType of effectiveResourceTypes(opts, config)) {
94
+ const targetPath = targetResourcePath(config, tName, resourceType);
95
+ if (!targetPath)
96
+ continue; // target 未配置此 resource,跳过
97
+ for (const item of items[resourceType]) {
98
+ const itemTargetPath = join(targetPath, item.name);
99
+ const outcome = await linkItem({
100
+ sourcePath: item.path,
101
+ targetPath: itemTargetPath,
102
+ resourceType,
103
+ itemName: item.name,
104
+ storePath,
105
+ policy: opts.conflictPolicy,
106
+ resolveConflict: resolveConflictFor(opts, tName, resourceType, item.name),
107
+ now: opts.now,
108
+ });
109
+ // 写 state
110
+ if (outcome.record) {
111
+ upsertLinkRecord(state, tName, resourceType, item.name, outcome.record);
112
+ }
113
+ let status = '';
114
+ switch (outcome.status) {
115
+ case 'linked':
116
+ status = 'linked';
117
+ linked++;
118
+ break;
119
+ case 'created':
120
+ status = 'created';
121
+ created++;
122
+ break;
123
+ case 'backed-up':
124
+ status = 'backed-up';
125
+ created++;
126
+ break;
127
+ case 'overwritten':
128
+ status = 'overwritten';
129
+ created++;
130
+ break;
131
+ case 'conflict':
132
+ status = 'conflict';
133
+ conflicts++;
134
+ break;
135
+ }
136
+ lines.push(`${tName} ${resourceType}/${item.name} ${status} ${itemTargetPath}`);
137
+ }
138
+ }
139
+ }
140
+ await writeState(storePath, state);
141
+ lines.push(`Summary: linked=${linked} created=${created} conflicts=${conflicts}`);
142
+ return lines.join('\n');
143
+ }
144
+ /** 状态检查 */
145
+ export async function runStatus(opts) {
146
+ const { storePath, config, state } = await loadContext(opts);
147
+ const storeItems = await scanResources(storePath, config);
148
+ const lines = [];
149
+ for (const [tName, tState] of Object.entries(state.targets)) {
150
+ for (const resourceType of effectiveResourceTypes(opts, config)) {
151
+ const targetPath = targetResourcePath(config, tName, resourceType);
152
+ if (!targetPath)
153
+ continue;
154
+ const records = tState[resourceType] ?? {};
155
+ const storeItemsForType = storeItems[resourceType] ?? [];
156
+ const storeItemMap = new Map(storeItemsForType.map(i => [i.name, i]));
157
+ // 当前 store 中的 items
158
+ for (const item of storeItemsForType) {
159
+ const itemTargetPath = join(targetPath, item.name);
160
+ const category = await classifyTarget(itemTargetPath, item.path);
161
+ lines.push(`${tName} ${resourceType}/${item.name} ${category} ${itemTargetPath}`);
162
+ }
163
+ // state 中已存在但 store 已删除(stale)
164
+ for (const [itemName, record] of Object.entries(records)) {
165
+ if (storeItemMap.has(itemName))
166
+ continue;
167
+ const itemTargetPath = record.target;
168
+ lines.push(`${tName} ${resourceType}/${itemName} stale ${itemTargetPath}`);
169
+ }
170
+ }
171
+ }
172
+ return lines.join('\n');
173
+ }
174
+ /** 更新(补链接 + 清理 stale) */
175
+ export async function runUpdate(opts) {
176
+ const { storePath, config, state } = await loadContext(opts);
177
+ const lines = [];
178
+ let created = 0, deleted = 0, missingCount = 0, skipped = 0, conflicts = 0;
179
+ // target 集合只能来自 state.targets(已 link 过的 targets)
180
+ const targetNames = Object.keys(state.targets);
181
+ const storeItems = await scanResources(storePath, config);
182
+ for (const tName of targetNames) {
183
+ const tState = state.targets[tName];
184
+ for (const resourceType of effectiveResourceTypes(opts, config)) {
185
+ const targetPath = targetResourcePath(config, tName, resourceType);
186
+ if (!targetPath)
187
+ continue;
188
+ const records = { ...(tState[resourceType] ?? {}) };
189
+ // 1. 清理 stale
190
+ for (const [itemName, record] of Object.entries(records)) {
191
+ const sourcePath = record.source;
192
+ if (!existsSync(sourcePath)) {
193
+ // stale:state 中有记录但 store 已无源
194
+ const result = await safeDeleteManagedLink(record, storePath);
195
+ switch (result) {
196
+ case 'deleted':
197
+ removeLinkRecord(state, tName, resourceType, itemName);
198
+ deleted++;
199
+ lines.push(`${tName} ${resourceType}/${itemName} deleted ${record.target}`);
200
+ break;
201
+ case 'missing':
202
+ removeLinkRecord(state, tName, resourceType, itemName);
203
+ missingCount++;
204
+ lines.push(`${tName} ${resourceType}/${itemName} missing ${record.target}`);
205
+ break;
206
+ case 'conflict':
207
+ conflicts++;
208
+ lines.push(`${tName} ${resourceType}/${itemName} conflict ${record.target}`);
209
+ break;
210
+ }
211
+ }
212
+ }
213
+ // 2. 为当前 store item 补链接
214
+ for (const item of storeItems[resourceType]) {
215
+ const itemTargetPath = join(targetPath, item.name);
216
+ const outcome = await linkItem({
217
+ sourcePath: item.path,
218
+ targetPath: itemTargetPath,
219
+ resourceType,
220
+ itemName: item.name,
221
+ storePath,
222
+ policy: opts.conflictPolicy,
223
+ resolveConflict: resolveConflictFor(opts, tName, resourceType, item.name),
224
+ now: opts.now,
225
+ });
226
+ if (outcome.record) {
227
+ upsertLinkRecord(state, tName, resourceType, item.name, outcome.record);
228
+ }
229
+ switch (outcome.status) {
230
+ case 'linked':
231
+ skipped++;
232
+ lines.push(`${tName} ${resourceType}/${item.name} linked ${itemTargetPath}`);
233
+ break;
234
+ case 'created':
235
+ created++;
236
+ lines.push(`${tName} ${resourceType}/${item.name} created ${itemTargetPath}`);
237
+ break;
238
+ case 'backed-up':
239
+ created++;
240
+ lines.push(`${tName} ${resourceType}/${item.name} backed-up ${itemTargetPath}`);
241
+ break;
242
+ case 'overwritten':
243
+ created++;
244
+ lines.push(`${tName} ${resourceType}/${item.name} overwritten ${itemTargetPath}`);
245
+ break;
246
+ case 'conflict':
247
+ conflicts++;
248
+ lines.push(`${tName} ${resourceType}/${item.name} conflict ${itemTargetPath}`);
249
+ break;
250
+ }
251
+ }
252
+ }
253
+ }
254
+ await writeState(storePath, state);
255
+ lines.push(`Summary: created=${created} deleted=${deleted} missing=${missingCount} skipped=${skipped} conflicts=${conflicts}`);
256
+ return lines.join('\n');
257
+ }
258
+ /** 取消链接 */
259
+ export async function runUnlink(targetArg, opts) {
260
+ const { storePath, config, state } = await loadContext(opts);
261
+ const targetNames = resolveTargetNames(targetArg, config);
262
+ const lines = [];
263
+ let deleted = 0, missingCount = 0, conflicts = 0;
264
+ for (const tName of targetNames) {
265
+ const tState = state.targets[tName];
266
+ if (!tState)
267
+ continue;
268
+ for (const resourceType of effectiveResourceTypes(opts, config)) {
269
+ const records = { ...(tState[resourceType] ?? {}) };
270
+ for (const [itemName, record] of Object.entries(records)) {
271
+ const result = await safeDeleteManagedLink(record, storePath);
272
+ switch (result) {
273
+ case 'deleted':
274
+ removeLinkRecord(state, tName, resourceType, itemName);
275
+ deleted++;
276
+ lines.push(`${tName} ${resourceType}/${itemName} deleted ${record.target}`);
277
+ break;
278
+ case 'missing':
279
+ removeLinkRecord(state, tName, resourceType, itemName);
280
+ missingCount++;
281
+ lines.push(`${tName} ${resourceType}/${itemName} missing ${record.target}`);
282
+ break;
283
+ case 'conflict':
284
+ conflicts++;
285
+ lines.push(`${tName} ${resourceType}/${itemName} conflict ${record.target}`);
286
+ break;
287
+ }
288
+ }
289
+ }
290
+ // 如果 target 下所有 records 都已删完,移除 target state
291
+ if (tState && Object.values(tState).every(m => Object.keys(m).length === 0)) {
292
+ removeTargetState(state, tName);
293
+ }
294
+ }
295
+ await writeState(storePath, state);
296
+ lines.push(`Summary: deleted=${deleted} missing=${missingCount} conflicts=${conflicts}`);
297
+ return lines.join('\n');
298
+ }