dsh-plugin-manager-companion 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.
Files changed (76) hide show
  1. package/LICENSE +21 -0
  2. package/README.en.md +144 -0
  3. package/README.md +142 -0
  4. package/cordis.patch.yml +9 -0
  5. package/dist/about.d.ts +77 -0
  6. package/dist/about.js +179 -0
  7. package/dist/cli.d.ts +226 -0
  8. package/dist/cli.js +856 -0
  9. package/dist/client/AboutPage.d.ts +75 -0
  10. package/dist/client/ConsolePage.d.ts +79 -0
  11. package/dist/client/KindsPage.d.ts +21 -0
  12. package/dist/client/MarketplacePage.d.ts +36 -0
  13. package/dist/client/OfficialSlots.d.ts +35 -0
  14. package/dist/client/UpgradeRow.d.ts +108 -0
  15. package/dist/client/index.d.ts +26 -0
  16. package/dist/client/locales.d.ts +475 -0
  17. package/dist/client/pmSelect.d.ts +38 -0
  18. package/dist/client/shared.d.ts +928 -0
  19. package/dist/client/upgradeView.d.ts +278 -0
  20. package/dist/client/wire.d.ts +401 -0
  21. package/dist/client.js +9194 -0
  22. package/dist/diagnostics.d.ts +332 -0
  23. package/dist/diagnostics.js +2631 -0
  24. package/dist/envManager.d.ts +1047 -0
  25. package/dist/envManager.js +3214 -0
  26. package/dist/fix.d.ts +60 -0
  27. package/dist/fix.js +168 -0
  28. package/dist/guard.d.ts +133 -0
  29. package/dist/guard.js +232 -0
  30. package/dist/index.d.ts +121 -0
  31. package/dist/index.js +1150 -0
  32. package/dist/installSession.d.ts +111 -0
  33. package/dist/installSession.js +150 -0
  34. package/dist/kinds.d.ts +464 -0
  35. package/dist/kinds.js +1029 -0
  36. package/dist/marketView.d.ts +261 -0
  37. package/dist/marketView.js +406 -0
  38. package/dist/marketplace.d.ts +248 -0
  39. package/dist/marketplace.js +500 -0
  40. package/dist/match.d.ts +67 -0
  41. package/dist/match.js +203 -0
  42. package/dist/net.d.ts +108 -0
  43. package/dist/net.js +163 -0
  44. package/dist/official.d.ts +145 -0
  45. package/dist/official.js +205 -0
  46. package/dist/paths.d.ts +108 -0
  47. package/dist/paths.js +236 -0
  48. package/dist/presets.d.ts +299 -0
  49. package/dist/presets.js +578 -0
  50. package/dist/qualityGate.d.ts +66 -0
  51. package/dist/qualityGate.js +247 -0
  52. package/dist/rank.d.ts +88 -0
  53. package/dist/rank.js +164 -0
  54. package/dist/registry.d.ts +295 -0
  55. package/dist/registry.js +686 -0
  56. package/dist/rest.d.ts +122 -0
  57. package/dist/rest.js +219 -0
  58. package/dist/scan.d.ts +134 -0
  59. package/dist/scan.js +396 -0
  60. package/dist/settings.d.ts +447 -0
  61. package/dist/settings.js +263 -0
  62. package/dist/tags.d.ts +119 -0
  63. package/dist/tags.js +166 -0
  64. package/dist/tools.d.ts +131 -0
  65. package/dist/tools.js +377 -0
  66. package/dist/types.d.ts +651 -0
  67. package/dist/types.js +13 -0
  68. package/dist/upgrade.d.ts +428 -0
  69. package/dist/upgrade.js +1100 -0
  70. package/dist/upgradeView.d.ts +313 -0
  71. package/dist/upgradeView.js +273 -0
  72. package/docs/images/readme/01-console-health.png +0 -0
  73. package/docs/images/readme/02-console-envs.png +0 -0
  74. package/docs/images/readme/03-marketplace.png +0 -0
  75. package/docs/images/readme/04-official-plugin-page.png +0 -0
  76. package/package.json +104 -0
package/dist/kinds.js ADDED
@@ -0,0 +1,1029 @@
1
+ /**
2
+ * 多类型安装:skill / agent 预设的检测、直装,以及安装记录与屏蔽名单。
3
+ *
4
+ * 归属:A 类·重写(写文件、复制目录、管持久化状态;旧 src/kinds.ts 仅作意图参考,未复制代码)。
5
+ * 旧实现参考:dsh-web-plugin-manager/src/kinds.ts(536 行:分层检测、SKILL.md 发现、
6
+ * installed-kinds.json 记录、blocked-repos.json 屏蔽名单、isUnderRoot 守卫、
7
+ * rmRetry/renameRetry 的 Windows 占用重试)。
8
+ * 官方复用:官方 skill-filesystem 扫描 <dshHome>/skills(默认 chokidar 热加载),
9
+ * 官方 agent-presets 每次读取都重扫 <dshHome>/.agent-presets 且以 agent.cordis.yml
10
+ * 为组合文件——本模块只往这两个官方根里落文件,**不复制官方发现逻辑、
11
+ * 不注册任何服务、不动 cordis.patch.yml**。路径基座复用 src/paths.ts 的 dshHome()。
12
+ * 前提检查:仍然成立且这正是我们的作业面——官方 README 明说 "loading plain plugin
13
+ * modules stays a file operation",skill 与 agent 预设没有安装器。旧实现的前提
14
+ * (官方只有只读清单、必须自建写路径)对 cordis 插件已消失,所以这里**不做**
15
+ * cordis 插件的安装:那种仓库交给官方 plugin_manager / CLI 的 pnpm 通道。
16
+ * 本模块对 cordis-plugin 只做"识别并指引"。
17
+ *
18
+ * 四条硬约束:
19
+ * 1. 每个落地目录都必须校验在目标根之内(isUnderRoot)——SKILL.md frontmatter
20
+ * 里的 name 是第三方内容,可能写成 ../.. 或绝对路径。
21
+ * 2. 删除/改名带重试:Windows 上 AV 扫描器与编辑器会短暂占用句柄。
22
+ * 3. 记录与屏蔽名单的读改写串行化,且落盘是"临时文件 + 改名"的原子写。
23
+ * 4. 永不执行第三方脚本:仓库里的 install.sh 只被扫描(见 scan.ts),不被运行。
24
+ */
25
+ import { cp, mkdir, readdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
26
+ import { existsSync } from 'node:fs';
27
+ import { join, resolve, sep } from 'node:path';
28
+ import { dshHome as officialDshHome, enqueueMutation } from "./paths.js";
29
+ import { writeOwnerMarker } from "./presets.js";
30
+ /** 官方预设组合文件:一个目录里有它就构成一个 agent 预设(官方单文件判定)。 */
31
+ export const PRESET_COMPOSITION_FILE = 'agent.cordis.yml';
32
+ /** 官方技能清单文件(大小写不敏感,见 findSkillRoots)。 */
33
+ export const SKILL_MANIFEST_FILE = 'SKILL.md';
34
+ /** 本模块记录文件的 schema 版本。 */
35
+ const RECORD_FORMAT = 1;
36
+ /** 技能根发现的默认上限:5 层 / 200 个。 */
37
+ export const DEFAULT_ROOT_LIMITS = { maxDepth: 5, limit: 200 };
38
+ /** 插件根发现的默认上限(比技能浅:packages/* 这种布局三层足够)。 */
39
+ export const DEFAULT_PLUGIN_LIMITS = { maxDepth: 3, limit: 50 };
40
+ /** 预设根发现的默认上限。 */
41
+ export const DEFAULT_PRESET_LIMITS = { maxDepth: 3, limit: 50 };
42
+ /**
43
+ * Harness home 覆盖(仅测试用)。
44
+ *
45
+ * 生产路径一律走 src/paths.ts 的 dshHome()。测试需要把落地根指向临时目录,
46
+ * 又不能忘了清缓存——两者绑在同一个函数里,防止只清一半。
47
+ */
48
+ let homeOverride = null;
49
+ /** 进程内缓存声明放在文件前部:__setHomeForTests 要在任何读写点之前引用它们。 */
50
+ let kindCache = null;
51
+ let blockedCache = null;
52
+ /** 当前解析到的 Harness home(测试可覆盖)。 */
53
+ function dshHome() {
54
+ return homeOverride ?? officialDshHome();
55
+ }
56
+ /**
57
+ * 覆盖 Harness home 并清掉全部进程内缓存(仅测试用)。
58
+ * @param home - 临时 home;传 null 恢复真实 home。
59
+ */
60
+ export function __setHomeForTests(home) {
61
+ homeOverride = home;
62
+ kindCache = null;
63
+ blockedCache = null;
64
+ }
65
+ /**
66
+ * 当前生效的 Harness home(含测试覆盖)。
67
+ *
68
+ * 兄弟模块(presets.ts)必须用这个而不是直接调 paths.dshHome():否则测试里
69
+ * 覆盖过的 home 只有一半模块看得见,落地根与归档根会分叉。
70
+ * @returns 绝对路径。
71
+ */
72
+ export function resolvedHome() {
73
+ return dshHome();
74
+ }
75
+ /** 本插件管辖的缓存目录(与旧仓库共用同一目录名,但文件名不同,见下)。 */
76
+ export function cacheRoot() {
77
+ return join(dshHome(), 'plugin-manager-cache');
78
+ }
79
+ /**
80
+ * 安装记录文件。
81
+ *
82
+ * 与旧仓库的 installed-kinds.json **不同文件**:那个文件由已停止维护的旧包读写,
83
+ * 两边并发写会互相覆盖。读取端做兼容(见 loadKindRecords),写入端只碰自己的
84
+ * 文件,因此两个包共存期间各自的数据都完好。
85
+ */
86
+ export function kindRecordsFile() {
87
+ return join(cacheRoot(), 'companion-kinds.json');
88
+ }
89
+ /** 旧包的记录文件(只读兼容)。 */
90
+ function legacyKindRecordsFile() {
91
+ return join(cacheRoot(), 'installed-kinds.json');
92
+ }
93
+ /** 屏蔽名单文件(同样是独立文件,避免与旧包并发写)。 */
94
+ export function blockedReposFile() {
95
+ return join(cacheRoot(), 'companion-blocked-repos.json');
96
+ }
97
+ /** 官方技能落地根。 */
98
+ export function skillsRoot() {
99
+ return join(dshHome(), 'skills');
100
+ }
101
+ /** 官方 agent 预设落地根。 */
102
+ export function presetsRoot() {
103
+ return join(dshHome(), '.agent-presets');
104
+ }
105
+ /**
106
+ * 路径包含判定:target 必须位于 root 之内(不等于 root)。
107
+ *
108
+ * 用途是删除/移动前的最后一道守卫:SKILL.md 的 frontmatter name、预设目录名
109
+ * 都可能包含 ../ 或绝对路径(第三方内容),一旦漏判就会删到用户的主目录
110
+ * (旧仓库记录过"删掉整个 Harness home"的事故)。大小写按平台语义处理:
111
+ * Windows/macOS 的文件系统大小写不敏感,用大小写精确比较会误拒合法路径
112
+ * (旧仓库审计 W2)。
113
+ *
114
+ * @param target - 待校验路径。
115
+ * @param root - 允许的根。
116
+ * @returns 是否严格位于根之内。
117
+ */
118
+ export function isUnderRoot(target, root) {
119
+ const normalizedRoot = resolve(root).replace(/[\\/]+$/, '');
120
+ const normalizedTarget = resolve(target);
121
+ if (isCaseInsensitivePlatform()) {
122
+ const lowerRoot = normalizedRoot.toLowerCase();
123
+ const lowerTarget = normalizedTarget.toLowerCase();
124
+ return lowerTarget !== lowerRoot && lowerTarget.startsWith(lowerRoot + sep);
125
+ }
126
+ return normalizedTarget !== normalizedRoot && normalizedTarget.startsWith(normalizedRoot + sep);
127
+ }
128
+ /** 平台文件名是否大小写不敏感(win32 与 darwin 都按不敏感处理)。 */
129
+ function isCaseInsensitivePlatform() {
130
+ return process.platform === 'win32' || process.platform === 'darwin';
131
+ }
132
+ /** 重试间隔(毫秒)。 */
133
+ const RETRY_DELAY_MS = 120;
134
+ /** 重试次数上限(首次 + 3 次重试)。 */
135
+ const RETRY_ATTEMPTS = 3;
136
+ /** 退避等待。 */
137
+ function retryDelay() {
138
+ return new Promise(resolveDelay => { setTimeout(resolveDelay, RETRY_DELAY_MS); });
139
+ }
140
+ /**
141
+ * 删除一棵目录树,带短暂重试。
142
+ *
143
+ * Windows 上 AV 扫描器/编辑器会短暂持有句柄,第一次 rm 常以 EPERM/EBUSY 失败;
144
+ * rm 的 force 只容忍"不存在",不容忍"被占用"。退避用定时器而不是忙循环:
145
+ * 调用点都在 async 路径上,同步自旋会冻住整个事件循环。
146
+ *
147
+ * @param target - 要删除的路径。
148
+ * @throws 重试耗尽后抛出最后一次错误。
149
+ */
150
+ export async function rmRetry(target) {
151
+ for (let attempt = 0;; attempt += 1) {
152
+ try {
153
+ await rm(target, { recursive: true, force: true });
154
+ return;
155
+ }
156
+ catch (error) {
157
+ if (attempt >= RETRY_ATTEMPTS)
158
+ throw error;
159
+ await retryDelay();
160
+ }
161
+ }
162
+ }
163
+ /**
164
+ * 改名(移动),带短暂重试。归档/恢复预设目录时目标可能被占用。
165
+ *
166
+ * @param from - 源路径。
167
+ * @param to - 目标路径。
168
+ * @throws 重试耗尽后抛出最后一次错误。
169
+ */
170
+ export async function renameRetry(from, to) {
171
+ for (let attempt = 0;; attempt += 1) {
172
+ try {
173
+ await rename(from, to);
174
+ return;
175
+ }
176
+ catch (error) {
177
+ if (attempt >= RETRY_ATTEMPTS)
178
+ throw error;
179
+ await retryDelay();
180
+ }
181
+ }
182
+ }
183
+ /** 生成文件系统安全的目录名:小写、非字母数字折叠成 '-'。 */
184
+ export function slugDirName(name) {
185
+ const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
186
+ return slug === '' ? 'plugin' : slug;
187
+ }
188
+ /** Windows 保留设备名:CON/NUL/COM1… 作为目录名会 EINVAL。 */
189
+ const WINDOWS_RESERVED = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i;
190
+ /**
191
+ * 平台安全的目录名:Windows 保留名加后缀。
192
+ * @param name - 期望的目录名。
193
+ * @returns 可直接 mkdir 的名字。
194
+ */
195
+ export function safeDirName(name) {
196
+ const trimmed = name.trim();
197
+ const base = trimmed === '' ? 'plugin' : trimmed;
198
+ return WINDOWS_RESERVED.test(base) ? base + '-skill' : base;
199
+ }
200
+ /** 取路径最后一段。 */
201
+ function lastSegment(path) {
202
+ const parts = path.split(/[\\/]+/).filter(part => part !== '');
203
+ return parts.length === 0 ? '' : parts[parts.length - 1];
204
+ }
205
+ /**
206
+ * 归一化仓库引用为小写 owner/repo。
207
+ *
208
+ * 支持 github:owner/repo、git+https://…、https://github.com/owner/repo、
209
+ * git@github.com:owner/repo、末尾 .git 与 #ref 片段。非 GitHub 形态(本地路径)
210
+ * 返回 null——调用方必须自己决定用什么做键,而不是被塞一个被改写的路径。
211
+ *
212
+ * @param value - 用户输入的仓库引用。
213
+ * @returns owner/repo 小写形式;无法识别的形态返回 null。
214
+ */
215
+ export function normalizeRepoRef(value) {
216
+ let text = value.trim();
217
+ if (text === '')
218
+ return null;
219
+ text = text.split('#')[0];
220
+ text = text.replace(/^git\+/i, '');
221
+ text = text.replace(/^git@github\.com:/i, '');
222
+ text = text.replace(/^github:/i, '');
223
+ text = text.replace(/^https?:\/\/(?:www\.)?github\.com\//i, '');
224
+ text = text.replace(/\.git$/i, '');
225
+ text = text.replace(/\/+$/, '');
226
+ if (text === '' || text.startsWith('/') || text.startsWith('.') || /^[A-Za-z]:[\\/]/.test(text))
227
+ return null;
228
+ if (!/^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/.test(text))
229
+ return null;
230
+ return text.toLowerCase();
231
+ }
232
+ /** 读目录项,失败返回空数组(不存在/无权限都不应抛给调用方)。 */
233
+ async function readEntries(dir) {
234
+ try {
235
+ return await readdir(dir, { withFileTypes: true });
236
+ }
237
+ catch {
238
+ return [];
239
+ }
240
+ }
241
+ /** 大小写不敏感地找一个目录下的文件;返回真实文件名或 null。 */
242
+ async function findFile(dir, name) {
243
+ const wanted = name.toLowerCase();
244
+ for (const entry of await readEntries(dir)) {
245
+ if (entry.isFile() && entry.name.toLowerCase() === wanted)
246
+ return entry.name;
247
+ }
248
+ return null;
249
+ }
250
+ /** vendored 目录惯例命名:里面的 SKILL.md 不属于本仓库分发。 */
251
+ const VENDORED_DIR_NAMES = new Set(['upstream', 'vendor', 'vendored', 'third_party', 'third-party', 'external', 'deps']);
252
+ /** 遍历时跳过的目录。 */
253
+ function isSkippedDir(name) {
254
+ if (name.startsWith('.'))
255
+ return true;
256
+ if (name === 'node_modules')
257
+ return true;
258
+ return VENDORED_DIR_NAMES.has(name.toLowerCase());
259
+ }
260
+ /**
261
+ * 判断一个 package.json 是否声明了 DSH 能力。
262
+ *
263
+ * 判据(与旧实现意图一致,重写实现):有 dsh 对象字段,或依赖/peerDependencies 里
264
+ * 出现 @deepseek-ai/cordis、@deepseek-ai/dsh、@deepseek-ai/dsh-*。仅带 package.json
265
+ * 的聚合页/桌面壳/普通 npm 项目返回 false——那种仓库不该被当成插件强装。
266
+ *
267
+ * @param manifest - 解析后的 package.json 内容(任意 JSON)。
268
+ * @returns true/false;不是对象时返回 null(无法判定)。
269
+ */
270
+ export function looksLikeDshPlugin(manifest) {
271
+ if (manifest === null || typeof manifest !== 'object')
272
+ return null;
273
+ const record = manifest;
274
+ if (record['dsh'] !== null && typeof record['dsh'] === 'object')
275
+ return true;
276
+ const merge = (section) => section !== null && typeof section === 'object' ? section : {};
277
+ const names = [
278
+ ...Object.keys(merge(record['dependencies'])),
279
+ ...Object.keys(merge(record['peerDependencies'])),
280
+ ];
281
+ if (names.includes('@deepseek-ai/cordis') || names.includes('@deepseek-ai/dsh'))
282
+ return true;
283
+ return names.some(name => name.startsWith('@deepseek-ai/dsh-'));
284
+ }
285
+ /** 读取并解析一个 package.json;失败返回 null(坏 JSON 不是错误,只是"不是插件")。 */
286
+ async function readManifest(dir) {
287
+ const name = await findFile(dir, 'package.json');
288
+ if (name === null)
289
+ return null;
290
+ try {
291
+ return JSON.parse(await readFile(join(dir, name), 'utf8'));
292
+ }
293
+ catch {
294
+ return null;
295
+ }
296
+ }
297
+ /**
298
+ * 找出仓库里的 SKILL.md 根(单技能仓库与技能集合仓库)。
299
+ *
300
+ * 一个目录里有 SKILL.md 就不再往下走:技能目录内部的子目录属于该技能自己的
301
+ * 结构(脚本、资源),把其中的 SKILL.md 当成第二个技能会装出重复内容。
302
+ *
303
+ * @param root - 仓库根目录。
304
+ * @param options - 层数与数量上限(默认 5 层 / 200 个)。
305
+ * @returns 技能根目录的绝对路径列表。
306
+ */
307
+ export async function findSkillRoots(root, options = {}) {
308
+ const maxDepth = options.maxDepth ?? DEFAULT_ROOT_LIMITS.maxDepth;
309
+ const limit = options.limit ?? DEFAULT_ROOT_LIMITS.limit;
310
+ const roots = [];
311
+ const walk = async (dir, depth) => {
312
+ if (roots.length >= limit)
313
+ return;
314
+ if (await findFile(dir, SKILL_MANIFEST_FILE) !== null) {
315
+ roots.push(dir);
316
+ return;
317
+ }
318
+ if (depth >= maxDepth)
319
+ return;
320
+ for (const entry of await readEntries(dir)) {
321
+ if (roots.length >= limit)
322
+ return;
323
+ if (!entry.isDirectory() || isSkippedDir(entry.name))
324
+ continue;
325
+ await walk(join(dir, entry.name), depth + 1);
326
+ }
327
+ };
328
+ await walk(root, 0);
329
+ return roots;
330
+ }
331
+ /**
332
+ * 找出仓库里的 agent 预设根(根预设与预设集合都覆盖)。
333
+ *
334
+ * 判定只看官方组合文件 agent.cordis.yml 是否存在——preset.yml 只是可选展示元数据,
335
+ * 不是构成条件(官方 discovery 的口径)。
336
+ *
337
+ * @param root - 仓库根目录。
338
+ * @param options - 层数与数量上限(默认 3 层 / 50 个)。
339
+ * @returns 预设根目录的绝对路径列表。
340
+ */
341
+ export async function findPresetRoots(root, options = {}) {
342
+ const maxDepth = options.maxDepth ?? DEFAULT_PRESET_LIMITS.maxDepth;
343
+ const limit = options.limit ?? DEFAULT_PRESET_LIMITS.limit;
344
+ const roots = [];
345
+ const walk = async (dir, depth) => {
346
+ if (roots.length >= limit)
347
+ return;
348
+ if (await findFile(dir, PRESET_COMPOSITION_FILE) !== null) {
349
+ roots.push(dir);
350
+ return;
351
+ }
352
+ if (depth >= maxDepth)
353
+ return;
354
+ for (const entry of await readEntries(dir)) {
355
+ if (roots.length >= limit)
356
+ return;
357
+ if (!entry.isDirectory() || isSkippedDir(entry.name))
358
+ continue;
359
+ await walk(join(dir, entry.name), depth + 1);
360
+ }
361
+ };
362
+ await walk(root, 0);
363
+ return roots;
364
+ }
365
+ /**
366
+ * 找出仓库里的 DSH 插件包根(monorepo / 多包仓库)。
367
+ *
368
+ * @param root - 仓库根目录。
369
+ * @param options - 层数与数量上限(默认 3 层 / 50 个)。
370
+ * @returns 插件包目录列表。
371
+ */
372
+ export async function findPluginRoots(root, options = {}) {
373
+ const maxDepth = options.maxDepth ?? DEFAULT_PLUGIN_LIMITS.maxDepth;
374
+ const limit = options.limit ?? DEFAULT_PLUGIN_LIMITS.limit;
375
+ const roots = [];
376
+ const walk = async (dir, depth) => {
377
+ if (roots.length >= limit)
378
+ return;
379
+ if (looksLikeDshPlugin(await readManifest(dir)) === true) {
380
+ roots.push(dir);
381
+ return;
382
+ }
383
+ if (depth >= maxDepth)
384
+ return;
385
+ for (const entry of await readEntries(dir)) {
386
+ if (roots.length >= limit)
387
+ return;
388
+ if (!entry.isDirectory() || isSkippedDir(entry.name))
389
+ continue;
390
+ await walk(join(dir, entry.name), depth + 1);
391
+ }
392
+ };
393
+ await walk(root, 0);
394
+ return roots;
395
+ }
396
+ /**
397
+ * 分层类型检测。
398
+ *
399
+ * 顺序(旧实现的顺序保留,重写实现):
400
+ * 1. 根目录就是预设(agent.cordis.yml 在根)→ agent-preset;
401
+ * 2. 根 package.json 声明了 DSH 能力 → cordis-plugin;
402
+ * 3. 根有 SKILL.md → skill(工具链 package.json 在纯技能仓库上很常见,
403
+ * 不能因为"有 package.json"就判成插件);
404
+ * 4. 嵌套检测**统一放最后一层**:预设 → 插件 → 技能,三层只要有一层命中就
405
+ * 返回该类型;这样"技能集合里夹带一个预设目录"和"预设集合里夹带技能"
406
+ * 都按预设优先(预设是更具体的形态);
407
+ * 5. 都不命中 → unknown(调用方据此拒绝安装并加入屏蔽名单)。
408
+ *
409
+ * 与旧实现的差别:旧实现有第四类 'instructions'(带 install.sh 的仓库)。
410
+ * 'unknown' 已经覆盖那个语义(拒绝安装 + 指引用户自己看仓库),少一个类型名
411
+ * 少一处分支。**任何仓库的 install.sh 都不会被自动执行**,这一点没有变。
412
+ *
413
+ * @param root - 仓库根目录。
414
+ * @returns 检测到的类型;无法识别时为 'unknown'。
415
+ */
416
+ export async function detectRepoType(root) {
417
+ if ((await findPresetRoots(root, { maxDepth: 0, limit: 1 })).length > 0)
418
+ return 'agent-preset';
419
+ const manifest = await readManifest(root);
420
+ if (manifest !== null && looksLikeDshPlugin(manifest) === true)
421
+ return 'cordis-plugin';
422
+ if ((await findSkillRoots(root, { maxDepth: 0, limit: 1 })).length > 0)
423
+ return 'skill';
424
+ if ((await findPresetRoots(root)).length > 0)
425
+ return 'agent-preset';
426
+ if ((await findPluginRoots(root)).length > 0)
427
+ return 'cordis-plugin';
428
+ if ((await findSkillRoots(root, { maxDepth: 5, limit: 1 })).length > 0)
429
+ return 'skill';
430
+ return 'unknown';
431
+ }
432
+ /**
433
+ * 读取 SKILL.md 的 YAML frontmatter 里的 name。
434
+ *
435
+ * 只做一件小事:'---' 块里的 name: 行。不引入 YAML 解析器——技能清单的
436
+ * frontmatter 由官方解析,我们只需要拿它当**建议目录名**,解析失败就回退到
437
+ * 仓库名/目录名,不影响技能本身能否被官方加载。
438
+ *
439
+ * @param skillDir - 技能根目录。
440
+ * @returns 合法的技能名;没有或非法时返回 null。
441
+ */
442
+ export async function skillDisplayName(skillDir) {
443
+ const name = await findFile(skillDir, SKILL_MANIFEST_FILE);
444
+ if (name === null)
445
+ return null;
446
+ let text;
447
+ try {
448
+ text = await readFile(join(skillDir, name), 'utf8');
449
+ }
450
+ catch {
451
+ return null;
452
+ }
453
+ const frontmatter = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text);
454
+ if (frontmatter === null)
455
+ return null;
456
+ const match = /^name:\s*"?([a-z0-9][a-z0-9-]*)"?\s*$/m.exec(frontmatter[1] ?? '');
457
+ return match?.[1] ?? null;
458
+ }
459
+ /** 复制过滤器:不复制 .git 与 node_modules(技能/预设都是纯文件)。 */
460
+ function copyFilter(src) {
461
+ const parts = src.split(/[\\/]+/);
462
+ return !parts.includes('.git') && !parts.includes('node_modules');
463
+ }
464
+ /** 校验一个待落地目录,不通过即抛出可直接展示的原因。 */
465
+ function assertInstallTarget(root, name) {
466
+ const dest = join(root, name);
467
+ if (!isUnderRoot(dest, root)) {
468
+ throw new Error('refusing to install outside the target root: ' + JSON.stringify(dest));
469
+ }
470
+ return dest;
471
+ }
472
+ /**
473
+ * 安装技能仓库到 <dshHome>/skills。
474
+ *
475
+ * 单技能仓库与技能集合仓库都覆盖:每个 SKILL.md 根装成一个目录,目录名优先取
476
+ * frontmatter 的 name,其次单根时取仓库名、多根时取该根自己的目录名。
477
+ *
478
+ * 落地前会 rm 掉同名目录(这是"重装同一技能"的语义),但**先校验包含关系**:
479
+ * 名字来自第三方 frontmatter,必须落在技能根之内。名字已被另一条安装记录占用时
480
+ * 抛错而不是覆盖——静默覆盖等于让用户丢掉另一个技能。
481
+ *
482
+ * @param repoRoot - 仓库根目录(已就绪的克隆或本地目录)。
483
+ * @param repoName - 仓库展示名(owner/repo 或本地目录名)。
484
+ * @param options - 已占用名字集合与根覆盖。
485
+ * @returns 落地结果。
486
+ * @throws 没有 SKILL.md、名字冲突、越界时。
487
+ */
488
+ export async function installSkill(repoRoot, repoName, options = {}) {
489
+ const roots = await findSkillRoots(repoRoot);
490
+ if (roots.length === 0)
491
+ throw new Error('no SKILL.md found in the repository');
492
+ const destRoot = options.root ?? skillsRoot();
493
+ await mkdir(destRoot, { recursive: true, mode: 0o700 });
494
+ const repoSlug = slugDirName(lastSegment(repoName) || repoName);
495
+ const names = [];
496
+ const dirs = [];
497
+ for (const skillRoot of roots) {
498
+ const fallback = roots.length === 1 ? repoSlug : slugDirName(lastSegment(skillRoot));
499
+ const name = safeDirName((await skillDisplayName(skillRoot)) ?? fallback);
500
+ if (options.occupied?.has(name) === true) {
501
+ throw new Error('skill "' + name + '" is already installed from another repository — '
502
+ + 'rename the SKILL.md frontmatter name, or uninstall the other skill first');
503
+ }
504
+ const dest = assertInstallTarget(destRoot, name);
505
+ await rmRetry(dest);
506
+ // dereference:技能是**纯文件**(不是包),源里的符号链接要展开成真实文件。
507
+ // 不展开的后果有两个:装出一堆指向别处的链接(用户改到的是别人),以及 Windows 上
508
+ // 创建链接需要特权、普通用户直接 EPERM 失败(平台审计 W-15)。
509
+ await cp(skillRoot, dest, { recursive: true, filter: copyFilter, dereference: true });
510
+ names.push(name);
511
+ dirs.push(dest);
512
+ }
513
+ const outcome = { name: summaryName(names, 'skills'), names, location: destRoot, dirs };
514
+ if (options.record !== false)
515
+ await saveKindRecord(repoName, kindRecordOf('skill', repoName, outcome));
516
+ return outcome;
517
+ }
518
+ /**
519
+ * 安装 agent 预设仓库到 <dshHome>/.agent-presets。
520
+ *
521
+ * 目录名即 preset id(官方语义)。嵌套预设立于子目录时用子目录名做 id;名字是
522
+ * 惯例的 preset 或根预设时回退到仓库名——这样常见布局 repo/preset/agent.cordis.yml
523
+ * 装出来是仓库名而不是一堆同名 preset。
524
+ *
525
+ * 每个落地目录写一份**中立标准归属标记**(.dsh-preset-owner.json),让插件卸载时
526
+ * 能分清"这个预设是谁装的、用户改过没有"(见 presets.ts)。已有标记不覆盖。
527
+ *
528
+ * @param repoRoot - 仓库根目录。
529
+ * @param repoName - 仓库展示名(owner/repo 或本地目录名)。
530
+ * @param options - 已占用 id 集合与根覆盖。
531
+ * @returns 落地结果。
532
+ * @throws 没有 agent.cordis.yml、id 冲突、越界时。
533
+ */
534
+ export async function installPreset(repoRoot, repoName, options = {}) {
535
+ const roots = await findPresetRoots(repoRoot);
536
+ if (roots.length === 0)
537
+ throw new Error('no ' + PRESET_COMPOSITION_FILE + ' found in the repository');
538
+ const destRoot = options.root ?? presetsRoot();
539
+ await mkdir(destRoot, { recursive: true, mode: 0o700 });
540
+ const repoSlug = slugDirName(lastSegment(repoName) || repoName);
541
+ const names = [];
542
+ const dirs = [];
543
+ for (const presetRoot of roots) {
544
+ const isRootPreset = resolve(presetRoot) === resolve(repoRoot);
545
+ const base = isRootPreset ? '' : lastSegment(presetRoot);
546
+ const id = safeDirName(base === '' || base === 'preset' ? repoSlug : slugDirName(base));
547
+ if (options.occupied?.has(id) === true) {
548
+ throw new Error('preset id "' + id + '" is already installed from another repository — '
549
+ + 'rename the preset directory, or uninstall the other preset first');
550
+ }
551
+ const dest = assertInstallTarget(destRoot, id);
552
+ await rmRetry(dest);
553
+ // 同上:预设也是纯文件,展开链接(W-15)。
554
+ await cp(presetRoot, dest, { recursive: true, filter: copyFilter, dereference: true });
555
+ await writeOwnerMarker(dest, [repoName]);
556
+ names.push(id);
557
+ dirs.push(dest);
558
+ }
559
+ const outcome = { name: summaryName(names, 'presets'), names, location: destRoot, dirs };
560
+ if (options.record !== false)
561
+ await saveKindRecord(repoName, kindRecordOf('agent-preset', repoName, outcome));
562
+ return outcome;
563
+ }
564
+ /** 单个落地时用名字,多个时用 N-kind 汇总名。 */
565
+ function summaryName(names, suffix) {
566
+ if (names.length === 0)
567
+ return '0-' + suffix;
568
+ return names.length === 1 ? names[0] : String(names.length) + '-' + suffix;
569
+ }
570
+ /**
571
+ * 删除一个已安装的 kind 目录(越界即拒绝)。
572
+ *
573
+ * @param root - 允许的根(技能根或预设根)。
574
+ * @param dir - 待删除目录。
575
+ * @throws 越界时(不删除任何东西)。
576
+ */
577
+ export async function removeKindDir(root, dir) {
578
+ if (!isUnderRoot(dir, root))
579
+ throw new Error('refusing to delete outside the target root: ' + JSON.stringify(dir));
580
+ await rmRetry(dir);
581
+ }
582
+ // ── 安装记录(companion-kinds.json)─────────────────────────────────────────
583
+ /**
584
+ * 把任意仓库拼写收敛成记录表的规范键。
585
+ *
586
+ * 记录表的键一律是 normalizeRepoRef 的结果(小写 owner/repo);本地路径这类
587
+ * normalizeRepoRef 认不出的形态按原样当键。
588
+ *
589
+ * @param ref - 任意仓库拼写(owner/repo、URL、github:、客户端回传的展示名…)。
590
+ * @returns 规范键。
591
+ */
592
+ export function canonicalKindKey(ref) {
593
+ return normalizeRepoRef(ref) ?? ref;
594
+ }
595
+ /**
596
+ * 安装记录表。
597
+ *
598
+ * 键是 canonicalKindKey 归一化后的仓库引用,**查询接口对任何等价拼写宽容**:
599
+ * 市场索引里的 repo 是原样大小写(WriteAudit/Probe-Skill-Mixed),客户端把记录体里
600
+ * 的 repo 原样回传给卸载 op,而表键是小写——两者不相等。修复做在**查表侧**而不是
601
+ * 写入侧,因为记录体的 repo 是给用户看的展示名,把它小写化会让 UI 丢掉原始大小写。
602
+ *
603
+ * 宽容只作用在 get/has/delete 的入参上;遍历(entries/values/keys)与落盘仍是每个记录
604
+ * 一条,所以不会出现"同一个记录在列表里显示两次"。
605
+ */
606
+ export class KindRecordMap extends Map {
607
+ /**
608
+ * 按任意等价拼写取记录。
609
+ *
610
+ * 顺序:规范键 → 原样键 → 逐条比对(键与记录体 repo 都过一遍归一化)。
611
+ * 逐条比对是最后的兜底:旧包写的记录可能以 URL 为键、以 owner/repo 为体。
612
+ *
613
+ * @param ref - 仓库拼写。
614
+ * @returns 记录;不存在时 undefined。
615
+ */
616
+ get(ref) {
617
+ const canonical = canonicalKindKey(ref);
618
+ const direct = super.get(canonical);
619
+ if (direct !== undefined)
620
+ return direct;
621
+ const raw = super.get(ref);
622
+ if (raw !== undefined)
623
+ return raw;
624
+ const wanted = normalizeRepoRef(ref);
625
+ if (wanted === null)
626
+ return undefined;
627
+ for (const [key, record] of this) {
628
+ if (canonicalKindKey(key) === wanted)
629
+ return record;
630
+ if (typeof record.repo === 'string' && canonicalKindKey(record.repo) === wanted)
631
+ return record;
632
+ }
633
+ return undefined;
634
+ }
635
+ /**
636
+ * 是否存在等价拼写的记录。
637
+ * @param ref - 仓库拼写。
638
+ * @returns 是否存在。
639
+ */
640
+ has(ref) {
641
+ return this.get(ref) !== undefined;
642
+ }
643
+ /**
644
+ * 按任意等价拼写删除记录(删的是它真正存放时用的那个键)。
645
+ * @param ref - 仓库拼写。
646
+ * @returns 是否真的删掉了一条。
647
+ */
648
+ delete(ref) {
649
+ const key = this.keyOf(ref);
650
+ return key === undefined ? false : super.delete(key);
651
+ }
652
+ /**
653
+ * 找出某个仓库拼写真正对应的键。
654
+ * @param ref - 仓库拼写。
655
+ * @returns 表中的键;不存在时 undefined。
656
+ */
657
+ keyOf(ref) {
658
+ const canonical = canonicalKindKey(ref);
659
+ if (super.has(canonical))
660
+ return canonical;
661
+ if (super.has(ref))
662
+ return ref;
663
+ const wanted = normalizeRepoRef(ref);
664
+ if (wanted === null)
665
+ return undefined;
666
+ for (const [key, record] of this) {
667
+ if (canonicalKindKey(key) === wanted)
668
+ return key;
669
+ if (typeof record.repo === 'string' && canonicalKindKey(record.repo) === wanted)
670
+ return key;
671
+ }
672
+ return undefined;
673
+ }
674
+ }
675
+ /**
676
+ * 组装一条安装记录(把落地结果 + 仓库身份收敛成落盘形态)。
677
+ *
678
+ * @param kind - 资源类型。
679
+ * @param repo - 仓库展示名(原样大小写,UI 直接展示)。
680
+ * @param outcome - 直装结果。
681
+ * @returns 可直接落盘的记录。
682
+ */
683
+ export function kindRecordOf(kind, repo, outcome) {
684
+ return {
685
+ kind,
686
+ repo,
687
+ // 单目录时记录那个目录,多目录时记录根——两者都能被幽灵判定与卸载路径使用。
688
+ dir: outcome.dirs.length === 1 ? outcome.dirs[0] : outcome.location,
689
+ installedAt: new Date().toISOString(),
690
+ dirs: [...outcome.dirs],
691
+ names: [...outcome.names],
692
+ };
693
+ }
694
+ /** 记录代数:每次落盘自增。 */
695
+ let kindGeneration = 0;
696
+ /** 当前记录代数(0 表示本进程还没读过记录)。 */
697
+ export function kindRecordsGeneration() {
698
+ return kindGeneration;
699
+ }
700
+ /** 原子写 JSON:先写临时文件再改名,避免崩溃/并发留下截断的 JSON。 */
701
+ async function writeJsonAtomic(path, payload) {
702
+ await mkdir(cacheRoot(), { recursive: true, mode: 0o700 });
703
+ const tmp = path + '.' + String(process.pid) + '.tmp';
704
+ await writeFile(tmp, JSON.stringify(payload, undefined, 2) + String.fromCharCode(10), { encoding: 'utf8', mode: 0o600 });
705
+ await rename(tmp, path);
706
+ kindGeneration += 1;
707
+ }
708
+ /** 解析记录文件,容错(坏文件读成空表)。 */
709
+ function parseRecords(text) {
710
+ const map = new KindRecordMap();
711
+ try {
712
+ const data = JSON.parse(text);
713
+ for (const [key, value] of Object.entries(data.records ?? {})) {
714
+ if (value === null || typeof value !== 'object')
715
+ continue;
716
+ map.set(key, value);
717
+ }
718
+ }
719
+ catch {
720
+ // 坏文件当成空表:调用方随后写入会重建它。
721
+ }
722
+ return map;
723
+ }
724
+ /**
725
+ * 读取安装记录(带进程内缓存)。
726
+ *
727
+ * 兼容读取旧包的 installed-kinds.json:两个包可能共存一段时间,用户已经装好的
728
+ * 技能/预设不该在新插件里"消失"。合并规则是本文件优先(同键时以我们自己的为准),
729
+ * 写入永远只写本文件。
730
+ *
731
+ * @returns 记录表(副本;调用方改它不影响缓存)。
732
+ */
733
+ export async function loadKindRecords() {
734
+ if (kindCache !== null)
735
+ return new KindRecordMap(kindCache);
736
+ const map = new KindRecordMap();
737
+ for (const file of [legacyKindRecordsFile(), kindRecordsFile()]) {
738
+ if (!existsSync(file))
739
+ continue;
740
+ try {
741
+ for (const [key, value] of parseRecords(await readFile(file, 'utf8')))
742
+ map.set(key, value);
743
+ }
744
+ catch {
745
+ // 文件不可读只意味着"没有可继承的记录",不是错误。
746
+ }
747
+ }
748
+ kindCache = map;
749
+ kindGeneration += 1;
750
+ return new KindRecordMap(map);
751
+ }
752
+ /**
753
+ * 写入一条记录(串行读改写)。
754
+ *
755
+ * 键走 canonicalKindKey(小写 owner/repo),记录体里的 repo 原样保留——展示名不能被
756
+ * 键的归一化污染,查询侧由 KindRecordMap 承担宽容。
757
+ *
758
+ * @param repoKey - 仓库引用(会被归一化)。
759
+ * @param record - 记录内容(可以是带 dirs/names 的落盘形态)。
760
+ */
761
+ export async function saveKindRecord(repoKey, record) {
762
+ const key = canonicalKindKey(repoKey);
763
+ await enqueueMutation(async () => {
764
+ const records = await loadKindRecords();
765
+ records.set(key, record);
766
+ kindCache = records;
767
+ await writeJsonAtomic(kindRecordsFile(), { version: RECORD_FORMAT, records: Object.fromEntries(records) });
768
+ });
769
+ }
770
+ /**
771
+ * 删除一条记录(串行读改写)。
772
+ * @param repoKey - 仓库引用(会被归一化)。
773
+ * @returns 是否真的存在并被删除。
774
+ */
775
+ export async function removeKindRecord(repoKey) {
776
+ const key = canonicalKindKey(repoKey);
777
+ return await enqueueMutation(async () => {
778
+ const records = await loadKindRecords();
779
+ const existed = records.delete(key);
780
+ if (existed) {
781
+ kindCache = records;
782
+ await writeJsonAtomic(kindRecordsFile(), { version: RECORD_FORMAT, records: Object.fromEntries(records) });
783
+ }
784
+ return existed;
785
+ });
786
+ }
787
+ /**
788
+ * 按任意等价拼写查一条安装记录(含它真正存放时用的键)。
789
+ *
790
+ * 这是写入侧与卸载侧应该用的显式入口:调用方拿到的是 `{ key, record }`,卸载时用 `key`
791
+ * 精确删除、用 `record.dirs` 精确清理目录,不再依赖"查表键恰好等于客户端回传字符串"。
792
+ *
793
+ * @param repoRef - 任意仓库拼写(owner/repo、URL、github:、展示名)。
794
+ * @returns 记录与其键;不存在时 undefined。
795
+ */
796
+ export async function findKindRecord(repoRef) {
797
+ const records = await loadKindRecords();
798
+ const record = records.get(repoRef);
799
+ if (record === undefined)
800
+ return undefined;
801
+ const key = records.keyOf(repoRef);
802
+ return { key: key ?? canonicalKindKey(repoRef), record: record };
803
+ }
804
+ /**
805
+ * 一条记录应当清理的目录(越界的不返回)。
806
+ *
807
+ * 优先用落盘形态的 `dirs`(多目录安装也精确);旧记录退回 `dir`。返回的目录已经过
808
+ * isUnderRoot 过滤——卸载是破坏性操作,宁可少删也不越界删。
809
+ *
810
+ * @param record - 安装记录。
811
+ * @param root - 允许的根(技能根或预设根)。
812
+ * @returns 应当删除的绝对目录列表(去重、保持记录顺序)。
813
+ */
814
+ export function kindDirsOf(record, root) {
815
+ const stored = record;
816
+ const candidates = Array.isArray(stored.dirs) && stored.dirs.length > 0
817
+ ? stored.dirs
818
+ : record.dir === '' ? [] : [record.dir];
819
+ const out = [];
820
+ for (const dir of candidates) {
821
+ if (typeof dir !== 'string' || dir === '')
822
+ continue;
823
+ if (!isUnderRoot(dir, root))
824
+ continue;
825
+ if (!out.includes(dir))
826
+ out.push(dir);
827
+ }
828
+ return out;
829
+ }
830
+ /**
831
+ * 扫描技能根与预设根,找出**没有任何安装记录认领**的目录。
832
+ *
833
+ * 为什么要有它:卸载失败、用户手工放置、外部工具清理记录都会留下目录残留,而契约
834
+ * (KindListResult.orphans)与客户端字典都承诺展示这一节。发布一个恒为空的区块等于
835
+ * 让用户以为"磁盘干净",所以这里给出真实扫描。
836
+ *
837
+ * 扫描语义(刻意保守,宁可少报也不误报):
838
+ * - 只扫根的**下一层**目录:安装永远落在根下第一层,更深层属于技能/预设自己的内容,
839
+ * 把它们当成"未登记目录"没有意义;
840
+ * - 跳过点目录(`.dsh-preset-owner.json` 之类的标记是文件,点目录不是安装产物);
841
+ * - 认领判定:任一记录的 `dirs` 或 `dir` 等于该目录,或者(旧记录没有 `dirs`、且 `dir`
842
+ * 等于根本身时)该目录名等于仓库末段的 slug——即单根安装的命名规则。
843
+ * 旧的多目录记录无法精确归属其余子目录,按"宁可少报"处理。
844
+ *
845
+ * @param options - 根覆盖(测试用)。
846
+ * @returns 未登记目录的绝对路径(排序后)。
847
+ */
848
+ export async function findOrphanKindDirs(options = {}) {
849
+ const roots = [
850
+ { root: options.skillsRootDir ?? skillsRoot(), kind: 'skill' },
851
+ { root: options.presetsRootDir ?? presetsRoot(), kind: 'agent-preset' },
852
+ ];
853
+ const records = await loadKindRecords();
854
+ const claimed = new Set();
855
+ for (const record of records.values()) {
856
+ const stored = record;
857
+ if (record.dir !== '')
858
+ claimed.add(resolve(record.dir));
859
+ if (Array.isArray(stored.dirs)) {
860
+ for (const dir of stored.dirs) {
861
+ if (typeof dir === 'string' && dir !== '')
862
+ claimed.add(resolve(dir));
863
+ }
864
+ }
865
+ for (const { root, kind } of roots) {
866
+ if (record.kind !== kind || resolve(record.dir) !== resolve(root))
867
+ continue;
868
+ claimed.add(resolve(join(root, slugDirName(lastSegment(record.repo)))));
869
+ }
870
+ }
871
+ const orphans = [];
872
+ for (const { root } of roots) {
873
+ for (const entry of await readEntries(root)) {
874
+ if (!entry.isDirectory() || entry.name.startsWith('.'))
875
+ continue;
876
+ const dir = join(root, entry.name);
877
+ if (claimed.has(resolve(dir)))
878
+ continue;
879
+ orphans.push(dir);
880
+ }
881
+ }
882
+ return orphans.sort();
883
+ }
884
+ /**
885
+ * 一条记录在磁盘上是否还有对应目录。
886
+ *
887
+ * 判定顺序(先精确后启发,宁少清不误清):
888
+ * 1. 落盘形态的 `dirs` 里**任一目录**还在 → 存活。多目录安装必须走这一条:
889
+ * 它们的 `dir` 是根,用根去判会把活着的记录当幽灵删掉(真机实测过——
890
+ * listKinds 先 pruneGhostRecords,记录一被删,随后的卸载就只能报"没有安装记录");
891
+ * 2. `dir` 是一个具体子目录(不是根)→ 看它还在不在;
892
+ * 3. 旧记录(没有 `dirs`)里 `dir` 等于根 → 用仓库末段 slug 猜一个主目录名。
893
+ *
894
+ * @param record - 安装记录。
895
+ * @returns 是否仍有对应目录。
896
+ */
897
+ function recordAlive(record) {
898
+ const stored = record;
899
+ if (Array.isArray(stored.dirs) && stored.dirs.length > 0) {
900
+ return stored.dirs.some(dir => typeof dir === 'string' && dir !== '' && existsSync(dir));
901
+ }
902
+ if (record.dir !== '' && record.dir !== skillsRoot() && record.dir !== presetsRoot()) {
903
+ return existsSync(record.dir);
904
+ }
905
+ if (record.kind === 'cordis-plugin')
906
+ return record.dir !== '' && existsSync(record.dir);
907
+ // 旧记录的多目录安装只记了根:至少得有一个由仓库名派生的子目录还在。
908
+ const slug = slugDirName(lastSegment(record.repo));
909
+ const root = record.kind === 'skill' ? skillsRoot() : presetsRoot();
910
+ return existsSync(join(root, slug));
911
+ }
912
+ /**
913
+ * 清理幽灵记录:目录被插件外部删掉(手工 rm、别的工具、临时目录清理)的记录。
914
+ *
915
+ * 进程内缓存看不到外部删除,所以每个读取面(列表、市场已安装标记)都要先调它,
916
+ * 否则已删的技能会永远显示"已安装"。
917
+ *
918
+ * @returns 被清掉的记录键。
919
+ */
920
+ export async function pruneGhostRecords() {
921
+ return await enqueueMutation(async () => {
922
+ const records = await loadKindRecords();
923
+ const dropped = [];
924
+ for (const [key, record] of records) {
925
+ if (recordAlive(record))
926
+ continue;
927
+ records.delete(key);
928
+ dropped.push(key);
929
+ }
930
+ if (dropped.length > 0) {
931
+ kindCache = records;
932
+ await writeJsonAtomic(kindRecordsFile(), { version: RECORD_FORMAT, records: Object.fromEntries(records) });
933
+ }
934
+ return dropped;
935
+ });
936
+ }
937
+ /** 清空记录与屏蔽名单缓存(测试用;让下一次读取重新读盘)。 */
938
+ export function __resetKindCacheForTests() {
939
+ kindCache = null;
940
+ blockedCache = null;
941
+ }
942
+ // ── 屏蔽名单(检测为非插件/技能/预设的仓库)───────────────────────────────
943
+ /**
944
+ * 读取屏蔽名单(带缓存,兼容旧包的 blocked-repos.json)。
945
+ * @returns 归一化后的仓库引用集合(副本)。
946
+ */
947
+ export async function loadBlockedRepos() {
948
+ if (blockedCache !== null)
949
+ return new Set(blockedCache);
950
+ const set = new Set();
951
+ const legacy = join(cacheRoot(), 'blocked-repos.json');
952
+ for (const file of [legacy, blockedReposFile()]) {
953
+ if (!existsSync(file))
954
+ continue;
955
+ try {
956
+ const data = JSON.parse(await readFile(file, 'utf8'));
957
+ if (!Array.isArray(data.repos))
958
+ continue;
959
+ for (const repo of data.repos) {
960
+ if (typeof repo !== 'string')
961
+ continue;
962
+ set.add(normalizeRepoRef(repo) ?? repo);
963
+ }
964
+ }
965
+ catch {
966
+ // 坏文件跳过:屏蔽名单少一个条目是可恢复状态,不该让调用方失败。
967
+ }
968
+ }
969
+ blockedCache = set;
970
+ return new Set(set);
971
+ }
972
+ /** 落盘屏蔽名单。 */
973
+ async function writeBlockedRepos(set) {
974
+ await writeJsonAtomic(blockedReposFile(), { version: RECORD_FORMAT, repos: [...set].sort() });
975
+ }
976
+ /**
977
+ * 加入屏蔽名单(检测为非三类的仓库)。
978
+ * @param repoKey - 仓库引用。
979
+ * @returns 归一化后的键。
980
+ */
981
+ export async function addBlockedRepo(repoKey) {
982
+ const key = normalizeRepoRef(repoKey) ?? repoKey;
983
+ await enqueueMutation(async () => {
984
+ const set = await loadBlockedRepos();
985
+ set.add(key);
986
+ blockedCache = set;
987
+ await writeBlockedRepos(set);
988
+ });
989
+ return key;
990
+ }
991
+ /**
992
+ * 移出屏蔽名单(市场页"解除屏蔽")。
993
+ * @param repoKey - 仓库引用。
994
+ * @returns 是否真的存在并被移除。
995
+ */
996
+ export async function removeBlockedRepo(repoKey) {
997
+ const key = normalizeRepoRef(repoKey) ?? repoKey;
998
+ return await enqueueMutation(async () => {
999
+ const set = await loadBlockedRepos();
1000
+ const existed = set.delete(key);
1001
+ if (existed) {
1002
+ blockedCache = set;
1003
+ await writeBlockedRepos(set);
1004
+ }
1005
+ return existed;
1006
+ });
1007
+ }
1008
+ /**
1009
+ * 判断仓库是否被屏蔽。
1010
+ * @param repoKey - 仓库引用。
1011
+ * @returns 是否在屏蔽名单里。
1012
+ */
1013
+ export async function isBlockedRepo(repoKey) {
1014
+ const key = normalizeRepoRef(repoKey) ?? repoKey;
1015
+ return (await loadBlockedRepos()).has(key);
1016
+ }
1017
+ /**
1018
+ * 目录是否存在且是目录(供 UI/CLI 汇报"记录是否还活着")。
1019
+ * @param path - 绝对路径。
1020
+ * @returns 是否为存在的目录。
1021
+ */
1022
+ export async function isDirectory(path) {
1023
+ try {
1024
+ return (await stat(path)).isDirectory();
1025
+ }
1026
+ catch {
1027
+ return false;
1028
+ }
1029
+ }