@michengai/dsh-skills-manager 1.1.19 → 1.1.21

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.
@@ -1,10 +1,12 @@
1
1
  import { promises as fs } from "node:fs";
2
- import { join } from "node:path";
2
+ import { join, resolve } from "node:path";
3
3
  import { createHash, randomUUID } from "node:crypto";
4
4
  import { unzipSync } from "fflate";
5
- import { managerHomePath, resolveWorkspaceTarget, repositoryDestinations, trashRootPath, listTrash, restoreTrash as restoreSkillTrash, parseSkillDoc, importUploadedSkill, deleteSkill, setSkillEnabled, state, createWorkspaceIndex } from "./core.js";
5
+ import { managerHomePath, resolveWorkspaceTarget, repositoryDestinations, trashRootPath, listTrash, restoreTrash as restoreSkillTrash, permanentlyDeleteTrash, parseSkillDoc, importUploadedSkill, deleteSkill, setSkillEnabled, readManagerState, state, createWorkspaceIndex } from "./core.js";
6
6
  import { createRepositoryUpdater, fileIndex, signature, readSkillTree, repositoryInstallRoot, recordInstallRoot, selectInstallRecord } from "./repository-updates.js";
7
7
  const LIMIT = 32 << 20;
8
+ const STATE_LIMIT = 16 << 20;
9
+ const STATE_READ_LIMIT = 64 << 20;
8
10
  const hash = (bytes) => createHash("sha256").update(bytes).digest("hex");
9
11
  function failure(message, code = "error.repo.invalid", params) {
10
12
  return Object.assign(new Error(message), { code, statusCode: 400, ...params && typeof params === "object" && !Array.isArray(params) ? { params } : {} });
@@ -42,6 +44,34 @@ function validateStoredState(input) {
42
44
  safePath(entry.path);
43
45
  }
44
46
  }
47
+ if (value.catalogInstalls !== void 0) {
48
+ if (!Array.isArray(value.catalogInstalls)) throw failure("\u76EE\u5F55\u5B89\u88C5\u72B6\u6001\u683C\u5F0F\u65E0\u6548");
49
+ for (const item of value.catalogInstalls) {
50
+ if (typeof item === "string") {
51
+ if (!/^(?:clawhub|skillsh):[A-Za-z0-9][A-Za-z0-9._-]{0,127}(?:\/[A-Za-z0-9][A-Za-z0-9._-]{0,127}){1,2}$/.test(item)) throw failure("\u76EE\u5F55\u5B89\u88C5\u8BB0\u5F55\u683C\u5F0F\u65E0\u6548");
52
+ } else if (item && typeof item === "object") {
53
+ if (typeof item.id !== "string" || !/^(?:clawhub|skillsh):[A-Za-z0-9][A-Za-z0-9._-]{0,127}(?:\/[A-Za-z0-9][A-Za-z0-9._-]{0,127}){1,2}$/.test(item.id)) throw failure("\u76EE\u5F55\u5B89\u88C5\u8BB0\u5F55\u683C\u5F0F\u65E0\u6548");
54
+ if (typeof item.name !== "string" || !item.name.trim() || item.name.length > 128) throw failure("\u76EE\u5F55\u5B89\u88C5\u6280\u80FD\u540D\u79F0\u65E0\u6548");
55
+ safePath(item.name);
56
+ if (item.name.includes("/")) throw failure("\u76EE\u5F55\u5B89\u88C5\u6280\u80FD\u540D\u79F0\u65E0\u6548");
57
+ if (item.root !== void 0) repositoryInstallRoot(item.root);
58
+ if (item.workspace !== void 0 && (item.root !== "dsh" || typeof item.workspace !== "string" || !item.workspace || item.workspace.length > 1024)) throw failure("\u76EE\u5F55\u5B89\u88C5\u5DE5\u4F5C\u533A\u65E0\u6548");
59
+ if (item.trashId !== void 0 && (typeof item.trashId !== "string" || !/^\d+-[a-f0-9-]{36}$/.test(item.trashId))) throw failure("\u76EE\u5F55\u56DE\u6536\u8BB0\u5F55\u65E0\u6548");
60
+ if (item.restoreFingerprint !== void 0 && (!item.trashId || !/^[a-f0-9]{64}$/.test(item.restoreFingerprint))) throw failure("\u76EE\u5F55\u6062\u590D\u8BB0\u5F55\u65E0\u6548");
61
+ if (item.complete !== void 0 && typeof item.complete !== "boolean") throw failure("\u76EE\u5F55\u5B89\u88C5\u786E\u8BA4\u72B6\u6001\u65E0\u6548");
62
+ if (item.complete === false && (!item.root || !item.files?.length)) throw failure("\u76EE\u5F55\u5B89\u88C5\u610F\u56FE\u65E0\u6548");
63
+ if (item.files !== void 0) {
64
+ if (!Array.isArray(item.files) || !item.files.length || item.files.length > 1e3) throw failure("\u76EE\u5F55\u5B89\u88C5\u6458\u8981\u65E0\u6548");
65
+ for (const file of item.files) {
66
+ if (!file || !/^[a-f0-9]{64}$/.test(file.hash)) throw failure("\u76EE\u5F55\u5B89\u88C5\u6458\u8981\u65E0\u6548");
67
+ safePath(file.path);
68
+ }
69
+ }
70
+ } else {
71
+ throw failure("\u76EE\u5F55\u5B89\u88C5\u8BB0\u5F55\u683C\u5F0F\u65E0\u6548");
72
+ }
73
+ }
74
+ }
45
75
  }
46
76
  function parseRepositoryInput(input = {}) {
47
77
  if (!input || typeof input !== "object" || Array.isArray(input)) throw failure("\u4ED3\u5E93\u53C2\u6570\u5FC5\u987B\u662F\u5BF9\u8C61");
@@ -108,6 +138,7 @@ async function readResponse(response, limit) {
108
138
  function createRepositoryManager({ fetchImpl = globalThis.fetch, log, registeredWorkspaces, onWorkspaceIndexBuild } = {}) {
109
139
  const file = join(managerHomePath(), "repositories.json");
110
140
  let queue = Promise.resolve();
141
+ let catalogWarnings = [];
111
142
  let activeIndex;
112
143
  const serialize = (task, recover = true) => {
113
144
  const run = async () => {
@@ -116,7 +147,7 @@ function createRepositoryManager({ fetchImpl = globalThis.fetch, log, registered
116
147
  activeIndex = index;
117
148
  try {
118
149
  if (recover) await recoverPending();
119
- return task();
150
+ return await task();
120
151
  } finally {
121
152
  activeIndex = previous;
122
153
  }
@@ -137,7 +168,7 @@ function createRepositoryManager({ fetchImpl = globalThis.fetch, log, registered
137
168
  const homeInfo = await fs.lstat(managerHomePath());
138
169
  if (homeInfo.isSymbolicLink() || !homeInfo.isDirectory()) throw failure("\u4ED3\u5E93\u72B6\u6001\u76EE\u5F55\u4E0D\u5B89\u5168");
139
170
  const info = await fs.lstat(file);
140
- if (info.isSymbolicLink() || !info.isFile() || info.size > 16 << 20) throw failure("\u4ED3\u5E93\u72B6\u6001\u6587\u4EF6\u4E0D\u5B89\u5168");
171
+ if (info.isSymbolicLink() || !info.isFile() || info.size > STATE_READ_LIMIT) throw failure("\u4ED3\u5E93\u72B6\u6001\u6587\u4EF6\u4E0D\u5B89\u5168");
141
172
  const value = JSON.parse(await fs.readFile(file, "utf8"));
142
173
  validateStoredState(value);
143
174
  return value;
@@ -147,10 +178,11 @@ function createRepositoryManager({ fetchImpl = globalThis.fetch, log, registered
147
178
  throw failure("\u4ED3\u5E93\u72B6\u6001\u6587\u4EF6\u635F\u574F\u6216\u65E0\u6CD5\u8BFB\u53D6\uFF0C\u8BF7\u4FDD\u7559\u6587\u4EF6\u5E76\u4FEE\u590D\u540E\u91CD\u8BD5", "error.repo.state");
148
179
  }
149
180
  }
150
- async function write(value) {
181
+ async function write(value, allowShrink = false) {
151
182
  validateStoredState(value);
152
183
  const text = JSON.stringify(value, null, 2);
153
- if (Buffer.byteLength(text, "utf8") > 16 << 20) throw failure("\u4ED3\u5E93\u72B6\u6001\u8D85\u8FC7\u5BB9\u91CF\u9650\u5236\uFF0C\u8BF7\u51CF\u5C11\u6765\u6E90");
184
+ const bytes = Buffer.byteLength(text, "utf8");
185
+ if (bytes > STATE_READ_LIMIT || bytes > STATE_LIMIT && !(allowShrink && bytes < await currentStateBytes())) throw failure("\u5B89\u88C5\u6765\u6E90\u72B6\u6001\u8D85\u8FC7 16 MiB\u3002\u8BF7\u5148\u6E05\u7406\u4E0D\u9700\u8981\u7684\u56DE\u6536\u8BB0\u5F55\u6216\u65E7\u8EAB\u4EFD\uFF0C\u4ECD\u6709\u6548\u7684\u5B89\u88C5\u526F\u672C\u4F1A\u4FDD\u7559\u3002", "error.repo.capacity");
154
186
  await fs.mkdir(managerHomePath(), { recursive: true });
155
187
  if ((await fs.lstat(managerHomePath())).isSymbolicLink()) throw failure("\u4ED3\u5E93\u72B6\u6001\u76EE\u5F55\u4E0D\u80FD\u662F\u94FE\u63A5");
156
188
  const temporary = `${file}.${randomUUID()}.tmp`;
@@ -161,6 +193,13 @@ function createRepositoryManager({ fetchImpl = globalThis.fetch, log, registered
161
193
  await fs.rm(temporary, { force: true });
162
194
  }
163
195
  }
196
+ async function currentStateBytes() {
197
+ try {
198
+ return (await fs.lstat(file)).size;
199
+ } catch {
200
+ return 0;
201
+ }
202
+ }
164
203
  function repository(data, id) {
165
204
  const repo = data.repositories.find((r) => r.id === id);
166
205
  if (!repo) throw failure("\u4ED3\u5E93\u4E0D\u5B58\u5728");
@@ -242,20 +281,24 @@ function createRepositoryManager({ fetchImpl = globalThis.fetch, log, registered
242
281
  const directory = await fs.lstat(receipt), file2 = await fs.lstat(join(receipt, "metadata.json"));
243
282
  if (!directory.isDirectory() || directory.isSymbolicLink() || !file2.isFile() || file2.isSymbolicLink() || file2.size > 32 << 10) return false;
244
283
  const metadata = JSON.parse(await fs.readFile(join(receipt, "metadata.json"), "utf8"));
245
- return metadata.id === record.trashId && metadata.name === record.name && (record.workspace ? metadata.root?.scope === "project" && metadata.root.projectRoot === record.workspace : metadata.root?.scope === "user" && metadata.root.key === (record.root || "dsh")) && metadata.repository?.id === record.id && metadata.repository.path === record.path && metadata.entries?.length === 1 && metadata.entries[0] === record.name;
284
+ return metadata.id === record.trashId && metadata.name === record.name && (record.workspace ? metadata.root?.scope === "project" && metadata.root.projectRoot === record.workspace : metadata.root?.scope === "user" && metadata.root.key === (record.root || "dsh")) && ("path" in record ? metadata.repository?.id === record.id && metadata.repository.path === record.path : Array.isArray(metadata.catalog) && metadata.catalog.includes(record.id)) && metadata.entries?.length === 1 && metadata.entries[0] === record.name;
246
285
  } catch {
247
286
  return false;
248
287
  }
249
288
  }
250
289
  async function recoverPending() {
290
+ catalogWarnings = [];
251
291
  const data = await read();
252
292
  let recovered = false;
293
+ const recoveredCatalogIds = [];
294
+ const recoveredInstallIds = [];
253
295
  const restoredIds = [];
254
296
  const registered = requestIndex().registered;
255
297
  for (const record of data.installs.filter((record2) => !record2.complete && !record2.trashId)) {
256
298
  if (await matches(record, signature(record.files), registered, await indexedFor(record))) {
257
299
  record.complete = true;
258
300
  recovered = true;
301
+ recoveredInstallIds.push(`${record.id}:${record.path}`);
259
302
  }
260
303
  }
261
304
  for (const record of data.installs.filter((record2) => record2.trashId && record2.restoreFingerprint)) {
@@ -266,11 +309,35 @@ function createRepositoryManager({ fetchImpl = globalThis.fetch, log, registered
266
309
  recovered = true;
267
310
  }
268
311
  }
312
+ for (const record of catalogRecords(data)) {
313
+ if (record.complete === false && !record.trashId && record.files?.length && record.root) {
314
+ if (await catalogMatches(record, signature(record.files))) {
315
+ try {
316
+ const root = await recordInstallRoot(record, registered, await indexedFor(record));
317
+ await enableCatalog(root, record.name, true);
318
+ record.complete = true;
319
+ recovered = true;
320
+ recoveredCatalogIds.push(record.id);
321
+ } catch {
322
+ catalogWarnings.push({ id: record.id, code: "error.repo.installState", error: "\u6280\u80FD\u6587\u4EF6\u548C\u5B89\u88C5\u610F\u56FE\u5DF2\u4FDD\u7559\uFF0C\u542F\u7528\u7B56\u7565\u6682\u65F6\u4E0D\u53EF\u5199\uFF1B\u4FEE\u590D\u7B56\u7565\u540E\u91CD\u8BD5" });
323
+ }
324
+ }
325
+ }
326
+ }
327
+ for (const record of catalogRecords(data).filter((record2) => record2.root && record2.trashId && record2.restoreFingerprint)) {
328
+ if (await hasRestoreReceipt(record) && await catalogMatches(record, record.restoreFingerprint)) {
329
+ restoredIds.push(record.trashId);
330
+ delete record.trashId;
331
+ delete record.restoreFingerprint;
332
+ recovered = true;
333
+ }
334
+ }
269
335
  if (recovered) {
270
336
  try {
271
- await write(data);
337
+ await write(data, true);
272
338
  } catch {
273
- throw failure("\u6280\u80FD\u6587\u4EF6\u5DF2\u4FDD\u7559\uFF0C\u4F46\u5B89\u88C5\u6765\u6E90\u72B6\u6001\u4ECD\u65E0\u6CD5\u5199\u5165\uFF0C\u8BF7\u6062\u590D\u78C1\u76D8\u5199\u5165\u540E\u91CD\u8BD5", "error.repo.installState");
339
+ for (const id of [.../* @__PURE__ */ new Set([...recoveredCatalogIds, ...restoredIds, ...recoveredInstallIds])]) catalogWarnings.push({ id, code: "error.repo.installState", error: "\u6280\u80FD\u6587\u4EF6\u5DF2\u4FDD\u7559\uFF0C\u6062\u590D\u786E\u8BA4\u6682\u65F6\u65E0\u6CD5\u5199\u5165\uFF1B\u6062\u590D\u78C1\u76D8\u5199\u5165\u540E\u91CD\u8BD5" });
340
+ return;
274
341
  }
275
342
  for (const id of restoredIds) await fs.rm(join(trashRootPath(), `.restored-${id}`), { recursive: true, force: true }).catch(() => void 0);
276
343
  }
@@ -290,38 +357,255 @@ function createRepositoryManager({ fetchImpl = globalThis.fetch, log, registered
290
357
  } catch {
291
358
  }
292
359
  }
293
- return { installRoots: await repositoryDestinations(), repositories: data.repositories.map((repo) => ({ ...repo, skills: repo.skills.map((skill) => {
294
- const found = skills.some((s) => s.name.toLowerCase() === skill.name.toLowerCase() || s.declaredName?.toLowerCase() === skill.name.toLowerCase());
295
- const { body, ...summary } = skill;
296
- const installations = data.installs.filter((i) => i.id === repo.id && i.path === skill.path && i.name === skill.name && i.complete && !i.trashId).map((record) => {
297
- const tracked = present.has(record);
298
- const updateAvailable = tracked && !!skill.files && signature(record.files) !== signature(skill.files);
360
+ const activeCatalogIds = [];
361
+ const catalogCopies2 = [];
362
+ for (const record of catalogRecords(data).filter((record2) => record2.root && record2.complete !== false && !record2.trashId)) {
363
+ try {
364
+ const root = await recordInstallRoot(record, registered, await indexedFor(record));
365
+ const files = await readSkillTree(join(root.path, record.name));
366
+ if (files["SKILL.md"]) {
367
+ if (!activeCatalogIds.includes(record.id)) activeCatalogIds.push(record.id);
368
+ catalogCopies2.push({ id: record.id, name: record.name, installedRoot: record.root || "dsh", ...record.workspace !== void 0 ? { installedWorkspace: record.workspace } : {}, canUninstall: true });
369
+ }
370
+ } catch {
371
+ }
372
+ }
373
+ return {
374
+ installRoots: await repositoryDestinations(),
375
+ catalogInstalls: activeCatalogIds,
376
+ catalogCopies: catalogCopies2,
377
+ catalogUnknownIds: [...new Set((data.catalogInstalls || []).flatMap((record) => typeof record === "string" ? [record] : !record.root && !record.trashId ? [record.id] : []))].filter((id) => !activeCatalogIds.includes(id)),
378
+ catalogWarnings: [...catalogWarnings],
379
+ repositories: data.repositories.map((repo) => ({ ...repo, skills: repo.skills.map((skill) => {
380
+ const found = skills.some((s) => s.name.toLowerCase() === skill.name.toLowerCase() || s.declaredName?.toLowerCase() === skill.name.toLowerCase());
381
+ const { body, ...summary } = skill;
382
+ const installations = data.installs.filter((i) => i.id === repo.id && i.path === skill.path && i.name === skill.name && i.complete && !i.trashId).flatMap((record) => {
383
+ const tracked = present.has(record);
384
+ if (!tracked) return [];
385
+ const updateAvailable = !!skill.files && signature(record.files) !== signature(skill.files);
386
+ return [{
387
+ installedRoot: record.root || "dsh",
388
+ ...record.workspace !== void 0 ? { installedWorkspace: record.workspace } : {},
389
+ tracked,
390
+ updateAvailable,
391
+ canRollback: !!record.backup,
392
+ canUninstall: true,
393
+ status: !skill.valid ? "invalid" : matched.get(record) ? updateAvailable ? "update" : "installed" : "conflict"
394
+ }];
395
+ });
396
+ const legacy = installations.find((item) => item.tracked);
299
397
  return {
300
- installedRoot: record.root || "dsh",
301
- ...record.workspace !== void 0 ? { installedWorkspace: record.workspace } : {},
302
- tracked,
303
- updateAvailable,
304
- canRollback: tracked && !!record.backup,
305
- canUninstall: tracked,
306
- status: !skill.valid ? "invalid" : tracked ? matched.get(record) ? updateAvailable ? "update" : "installed" : "conflict" : "available"
398
+ ...summary,
399
+ installations,
400
+ updateAvailable: legacy?.updateAvailable ?? false,
401
+ canRollback: legacy?.canRollback ?? false,
402
+ tracked: !!legacy,
403
+ installedRoot: legacy?.installedRoot ?? null,
404
+ installedWorkspace: legacy?.installedWorkspace ?? null,
405
+ canUninstall: !!legacy,
406
+ status: !skill.valid ? "invalid" : legacy?.status ?? (found ? "conflict" : "available")
307
407
  };
308
- });
309
- const legacy = installations.find((item) => item.tracked);
310
- return {
311
- ...summary,
312
- installations,
313
- updateAvailable: legacy?.updateAvailable ?? false,
314
- canRollback: legacy?.canRollback ?? false,
315
- tracked: !!legacy,
316
- installedRoot: legacy?.installedRoot ?? null,
317
- installedWorkspace: legacy?.installedWorkspace ?? null,
318
- canUninstall: !!legacy,
319
- status: !skill.valid ? "invalid" : legacy?.status ?? (found ? "conflict" : "available")
320
- };
321
- }) })) };
408
+ }) }))
409
+ };
410
+ }
411
+ function catalogRecords(data) {
412
+ return (data.catalogInstalls || []).filter((record) => typeof record !== "string");
413
+ }
414
+ const locationKey = (record) => JSON.stringify([
415
+ record.root,
416
+ record.workspace === void 0 ? null : process.platform === "win32" ? resolve(record.workspace).toLowerCase() : resolve(record.workspace),
417
+ record.name
418
+ ]);
419
+ async function catalogLocation(input = {}) {
420
+ if (input.workspace !== void 0) {
421
+ if (input.root !== void 0 && input.root !== "dsh") throw failure("\u5DE5\u4F5C\u533A\u53EA\u80FD\u5B89\u88C5\u5230\u9879\u76EE DSH skills");
422
+ const target = await resolveWorkspaceTarget(input.workspace, requestIndex().registered);
423
+ if ("ok" in target && target.ok === false) throw failure(target.error, target.code, target.params);
424
+ return { root: "dsh", workspace: target.root.projectRoot };
425
+ }
426
+ return { root: repositoryInstallRoot(input.root).key };
427
+ }
428
+ async function enableCatalog(root, name, respectDisabled = false) {
429
+ try {
430
+ if (respectDisabled) {
431
+ const current = await readManagerState();
432
+ if (!current.writable) throw new Error("\u542F\u7528\u7B56\u7565\u4E0D\u53EF\u5199");
433
+ if (current.state.disabledSkills[root.key]?.includes(name)) return;
434
+ }
435
+ const result = await setSkillEnabled(root, name, true, log);
436
+ if (result && "ok" in result && result.ok === false) throw new Error(result.error);
437
+ } catch {
438
+ throw Object.assign(failure("\u6280\u80FD\u6587\u4EF6\u548C\u5B89\u88C5\u610F\u56FE\u5DF2\u4FDD\u7559\uFF0C\u542F\u7528\u72B6\u6001\u65E0\u6CD5\u5199\u5165\uFF1B\u6062\u590D\u78C1\u76D8\u5199\u5165\u540E\u91CD\u65B0\u6253\u5F00\u53D1\u73B0\u9875", "error.repo.installState"), { statusCode: 500 });
439
+ }
440
+ }
441
+ async function catalogMatches(record, fingerprint) {
442
+ try {
443
+ const root = await recordInstallRoot(record, requestIndex().registered, await indexedFor(record));
444
+ return signature(fileIndex(await readSkillTree(join(root.path, record.name)))) === fingerprint;
445
+ } catch {
446
+ return false;
447
+ }
448
+ }
449
+ function catalogCopies(data, root, name) {
450
+ const target = { root: root.scope === "project" ? "dsh" : root.key, workspace: root.projectRoot, name };
451
+ return catalogRecords(data).filter((record) => record.root && !record.trashId && locationKey(record) === locationKey(target));
452
+ }
453
+ async function restoreCatalogTrash(id, options) {
454
+ return serialize(async () => {
455
+ const metadata = (await listTrash()).find((item) => item.id === id);
456
+ const data = await read();
457
+ const records = catalogRecords(data).filter((record) => record.trashId === id && metadata?.catalog?.includes(record.id));
458
+ if (!records.length) return restoreSkillTrash(id, log, options);
459
+ for (const record of records) {
460
+ if (metadata.name !== record.name || (record.workspace ? metadata.root?.projectRoot !== record.workspace || metadata.root.scope !== "project" : metadata.root?.scope !== "user" || metadata.root.key !== record.root)) throw failure("\u56DE\u6536\u7AD9\u6765\u6E90\u4E0E\u76EE\u5F55\u8EAB\u4EFD\u4E0D\u4E00\u81F4");
461
+ record.restoreFingerprint = signature(fileIndex(await readSkillTree(join(trashRootPath(), id, record.name))));
462
+ }
463
+ await write(data);
464
+ const result = await restoreSkillTrash(id, log, { ...options, repositoryRestore: true });
465
+ if ("ok" in result && result.ok === false) {
466
+ for (const record of records) delete record.restoreFingerprint;
467
+ await write(data);
468
+ return result;
469
+ }
470
+ for (const record of records) {
471
+ delete record.trashId;
472
+ delete record.restoreFingerprint;
473
+ }
474
+ try {
475
+ await write(data);
476
+ } catch {
477
+ throw failure("\u6280\u80FD\u5DF2\u6062\u590D\uFF0C\u76EE\u5F55\u8EAB\u4EFD\u7B49\u5F85\u6821\u9A8C\uFF1B\u6062\u590D\u78C1\u76D8\u5199\u5165\u540E\u91CD\u65B0\u6253\u5F00\u53D1\u73B0\u9875", "error.repo.installState");
478
+ }
479
+ await fs.rm(join(trashRootPath(), `.restored-${id}`), { recursive: true, force: true });
480
+ return result;
481
+ }, false);
322
482
  }
483
+ const refreshVersions = /* @__PURE__ */ new Map();
323
484
  return {
485
+ installCatalog: (id, pack, target = {}) => serialize(async () => {
486
+ const location = await catalogLocation(target);
487
+ const root = await recordInstallRoot(location, requestIndex().registered, await indexedFor(location));
488
+ const data = await read();
489
+ const previousCatalog = data.catalogInstalls;
490
+ let intent;
491
+ let reusedLegacy = false;
492
+ await recordInstallRoot(location, requestIndex().registered);
493
+ const imported = await importUploadedSkill(pack, log, {
494
+ conflict: "skip",
495
+ root,
496
+ beforeImport: async (pending, conflicts, failed) => {
497
+ const legacy = (data.catalogInstalls || []).some((record) => typeof record === "string" ? record === id : record.id === id && !record.root);
498
+ const candidate = pending.length === 1 && !conflicts.length ? pending[0] : !pending.length && conflicts.length === 1 && legacy ? { ...conflicts[0], isDir: conflicts[0].isDir } : void 0;
499
+ if (!candidate?.isDir || failed.length) throw failure("\u76EE\u5F55\u5B89\u88C5\u5FC5\u987B\u662F\u5355\u4E2A\u5B8C\u6574\u6280\u80FD\uFF0C\u4E14\u4E0D\u80FD\u8986\u76D6\u5DF2\u6709\u526F\u672C", "error.repo.conflict");
500
+ const files = fileIndex(await readSkillTree(candidate.source));
501
+ if (!files.some((file2) => file2.path === "SKILL.md")) throw failure("\u76EE\u5F55\u5B89\u88C5\u5305\u7F3A\u5C11 SKILL.md", "error.catalog.response");
502
+ if (conflicts.length) {
503
+ const local = fileIndex(await readSkillTree(join(root.path, candidate.name)));
504
+ if (signature(local) !== signature(files)) throw failure("\u65E7\u5B89\u88C5\u5185\u5BB9\u4E0E\u76EE\u5F55\u5305\u4E0D\u4E00\u81F4\uFF0C\u4E0D\u4F1A\u8986\u76D6\u6216\u731C\u6D4B\u8EAB\u4EFD", "error.repo.conflict");
505
+ reusedLegacy = true;
506
+ }
507
+ intent = { id, name: candidate.name, ...location, complete: false, files };
508
+ data.catalogInstalls = (data.catalogInstalls || []).filter((record) => typeof record === "string" ? record !== id : record.trashId || (record.root ? locationKey(record) !== locationKey(intent) : record.id !== id));
509
+ data.catalogInstalls.push(intent);
510
+ try {
511
+ await write(data);
512
+ } catch (caught) {
513
+ if (caught.code === "error.repo.capacity") throw caught;
514
+ throw failure("\u5B89\u88C5\u610F\u56FE\u65E0\u6CD5\u5199\u5165\uFF0C\u672A\u590D\u5236\u6280\u80FD\u6587\u4EF6", "error.repo.installState");
515
+ }
516
+ }
517
+ });
518
+ const completedName = reusedLegacy ? imported.skipped?.length === 1 ? imported.skipped[0].name : void 0 : imported.imported?.length === 1 ? imported.imported[0].name : void 0;
519
+ if (imported.ok === false || !intent || completedName !== intent.name) {
520
+ if (intent) {
521
+ data.catalogInstalls = previousCatalog;
522
+ try {
523
+ await write(data);
524
+ } catch {
525
+ throw failure("\u5B89\u88C5\u672A\u5B8C\u6210\u4E14\u8EAB\u4EFD\u610F\u56FE\u65E0\u6CD5\u64A4\u9500\uFF0C\u8BF7\u6062\u590D\u78C1\u76D8\u5199\u5165\u540E\u91CD\u8BD5", "error.repo.installState");
526
+ }
527
+ }
528
+ throw Object.assign(failure(imported.error || "\u540C\u540D\u6280\u80FD\u5DF2\u5B58\u5728\uFF0C\u4E0D\u4F1A\u8986\u76D6\u672C\u5730\u6587\u4EF6", imported.code || "error.repo.conflict", imported.params), { statusCode: imported.code === "error.repo.installState" ? 500 : imported.code === "error.repo.capacity" ? 413 : 409 });
529
+ }
530
+ await enableCatalog(root, intent.name);
531
+ intent.complete = true;
532
+ try {
533
+ await write(data);
534
+ } catch {
535
+ throw Object.assign(failure("\u6280\u80FD\u6587\u4EF6\u5DF2\u5B89\u88C5\u4F46\u8EAB\u4EFD\u672A\u786E\u8BA4\uFF1B\u6062\u590D\u78C1\u76D8\u5199\u5165\u540E\u91CD\u65B0\u6253\u5F00\u53D1\u73B0\u9875", "error.repo.installState"), { statusCode: 500 });
536
+ }
537
+ return { name: intent.name, root: root.path };
538
+ }),
324
539
  list: () => serialize(list),
540
+ trashSkill: (root, name) => serialize(async () => {
541
+ const data = await read();
542
+ const records = root ? catalogCopies(data, root, name) : [];
543
+ if (!records.length) return deleteSkill(root, name, log);
544
+ const id = `${Date.now()}-${randomUUID()}`;
545
+ for (const record of records) record.trashId = id;
546
+ await write(data);
547
+ try {
548
+ const result = await deleteSkill(root, name, log, { catalogTrash: { id, ids: records.map((record) => record.id) } });
549
+ if ("ok" in result && result.ok === false) {
550
+ for (const record of records) delete record.trashId;
551
+ await write(data);
552
+ }
553
+ return result;
554
+ } catch (error) {
555
+ if (!(await listTrash()).some((item) => item.id === id)) {
556
+ for (const record of records) delete record.trashId;
557
+ await write(data);
558
+ }
559
+ throw error;
560
+ }
561
+ }, false),
562
+ permanentlyDeleteTrash: (id) => serialize(async () => {
563
+ const data = await read();
564
+ const before = data.catalogInstalls || [];
565
+ const next = before.filter((record) => typeof record === "string" || record.trashId !== id);
566
+ if (next.length !== before.length) {
567
+ data.catalogInstalls = next;
568
+ await write(data, true);
569
+ }
570
+ try {
571
+ const result = await permanentlyDeleteTrash(id, log);
572
+ if ("ok" in result && result.ok === false) {
573
+ data.catalogInstalls = before;
574
+ if (next.length !== before.length) await write(data, true);
575
+ return result;
576
+ }
577
+ return result;
578
+ } catch (error) {
579
+ data.catalogInstalls = before;
580
+ if (next.length !== before.length) await write(data, true);
581
+ throw error;
582
+ }
583
+ }, false),
584
+ rememberCatalogInstall: (id, name, target = {}) => serialize(async () => {
585
+ const location = await catalogLocation(target);
586
+ const record = { id, name, ...location };
587
+ const data = await read();
588
+ data.catalogInstalls = (data.catalogInstalls || []).filter((item) => typeof item === "string" || item.trashId || item.id !== id || locationKey(item) !== locationKey(record));
589
+ data.catalogInstalls.push(record);
590
+ await write(data);
591
+ return { catalogInstalls: catalogRecords(data).map((record2) => record2.id) };
592
+ }, false),
593
+ forgetCatalogInstall: (target) => serialize(async () => {
594
+ if (!target || typeof target !== "object") throw failure("\u6E05\u7406\u76EE\u5F55\u8EAB\u4EFD\u5FC5\u987B\u6307\u5B9A\u5B89\u88C5\u526F\u672C\u6216\u65E7\u8EAB\u4EFD ID");
595
+ if ("legacyId" in target) {
596
+ if (typeof target.legacyId !== "string" || !/^(?:clawhub|skillsh):[A-Za-z0-9][A-Za-z0-9._-]{0,127}(?:\/[A-Za-z0-9][A-Za-z0-9._-]{0,127}){1,2}$/.test(target.legacyId)) throw failure("\u6E05\u7406\u65E7\u76EE\u5F55\u8EAB\u4EFD\u5FC5\u987B\u6307\u5B9A\u6709\u6548 ID");
597
+ const data2 = await read();
598
+ data2.catalogInstalls = (data2.catalogInstalls || []).filter((record) => typeof record === "string" ? record !== target.legacyId : record.root || record.trashId || record.id !== target.legacyId);
599
+ await write(data2, true);
600
+ return { catalogInstalls: catalogRecords(data2).map((record) => record.id) };
601
+ }
602
+ if (typeof target.name !== "string") throw failure("\u6E05\u7406\u76EE\u5F55\u8EAB\u4EFD\u5FC5\u987B\u6307\u5B9A\u5B89\u88C5\u526F\u672C");
603
+ const location = await catalogLocation(target);
604
+ const data = await read();
605
+ data.catalogInstalls = (data.catalogInstalls || []).filter((record) => typeof record === "string" || record.trashId || !record.root || locationKey(record) !== locationKey({ ...location, name: target.name }) || target.id !== void 0 && record.id !== target.id);
606
+ await write(data, true);
607
+ return { catalogInstalls: catalogRecords(data).map((record) => record.id) };
608
+ }, false),
325
609
  ...createRepositoryUpdater({ read, write, repository, download, serialize, parseRepositoryInput, registeredWorkspaces, onWorkspaceIndexBuild }),
326
610
  add: (input) => serialize(async () => {
327
611
  const source = parseRepositoryInput(input), data = await read();
@@ -339,14 +623,22 @@ function createRepositoryManager({ fetchImpl = globalThis.fetch, log, registered
339
623
  for (const record of data.installs.filter((i) => i.id === id)) record.source ||= { owner: repo.owner, name: repo.name, ref: repo.ref, subdirectory: repo.subdirectory };
340
624
  data.repositories = data.repositories.filter((r) => r.id !== id);
341
625
  await write(data);
626
+ refreshVersions.set(id, (refreshVersions.get(id) || 0) + 1);
342
627
  return { id };
343
628
  }, false),
344
- refresh: ({ id }) => serialize(async () => {
345
- const data = await read(), repo = repository(data, id);
629
+ refresh: async ({ id }) => {
630
+ const { repo, version } = await serialize(async () => {
631
+ const repo2 = { ...repository(await read(), id) };
632
+ const version2 = (refreshVersions.get(id) || 0) + 1;
633
+ refreshVersions.set(id, version2);
634
+ return { repo: repo2, version: version2 };
635
+ }, false);
636
+ let bytes;
637
+ let skills = [];
638
+ let scanError = null;
346
639
  try {
347
- const bytes = await fetchBranch(repo);
640
+ bytes = await fetchBranch(repo);
348
641
  const entries = decodeRepositoryArchive(bytes);
349
- const skills = [];
350
642
  for (const [path, bytes2] of Object.entries(entries)) {
351
643
  if (path !== "SKILL.md" && !path.endsWith("/SKILL.md")) continue;
352
644
  const directory = path === "SKILL.md" ? "" : path.slice(0, -9);
@@ -360,24 +652,32 @@ function createRepositoryManager({ fetchImpl = globalThis.fetch, log, registered
360
652
  if (files.length > 1e3) throw failure("\u6280\u80FD\u6587\u4EF6\u6570\u91CF\u8D85\u9650");
361
653
  skills.push({ path: directory, files, name: name || directory.split("/").pop() || repo.name, description, body: parsed.body.trim(), documentHash: hash(bytes2), valid: parsed.hasFrontmatter && /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name) && !/^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i.test(name) && name.length <= 100 && !!description });
362
654
  }
363
- const snapshot = await cacheArchive(repo, bytes);
364
- Object.assign(repo, { skills, commit: snapshot, refreshedAt: (/* @__PURE__ */ new Date()).toISOString(), error: null });
365
655
  } catch (caught) {
366
656
  const error = caught;
367
- repo.error = { code: error.code || "error.repo.invalid", error: error.message };
657
+ scanError = { code: error.code || "error.repo.invalid", error: error.message };
368
658
  }
369
- await write(data);
370
- if (!repo.error) {
371
- try {
372
- await pruneCache(repo);
373
- } catch (caught) {
374
- const error = caught;
375
- if (log) await log("repository.cache.cleanup.failed", { repository: repo.id, error: error.message });
376
- else console.warn("\u4ED3\u5E93\u65E7\u7F13\u5B58\u672A\u6E05\u7406\uFF1A" + error.message);
659
+ return serialize(async () => {
660
+ const data = await read(), current = repository(data, id);
661
+ if (refreshVersions.get(id) !== version) return current;
662
+ if (["owner", "name", "ref", "subdirectory"].some((key) => current[key] !== repo[key])) throw failure("\u4ED3\u5E93\u6765\u6E90\u5DF2\u53D8\u5316\uFF0C\u8BF7\u91CD\u65B0\u5237\u65B0");
663
+ if (scanError) current.error = scanError;
664
+ else {
665
+ const snapshot = await cacheArchive(current, bytes);
666
+ Object.assign(current, { skills, commit: snapshot, refreshedAt: (/* @__PURE__ */ new Date()).toISOString(), error: null });
377
667
  }
378
- }
379
- return repo;
380
- }, false),
668
+ await write(data);
669
+ if (!current.error) {
670
+ try {
671
+ await pruneCache(current);
672
+ } catch (caught) {
673
+ const error = caught;
674
+ if (log) await log("repository.cache.cleanup.failed", { repository: current.id, error: error.message });
675
+ else console.warn("\u4ED3\u5E93\u65E7\u7F13\u5B58\u672A\u6E05\u7406\uFF1A" + error.message);
676
+ }
677
+ }
678
+ return current;
679
+ }, false);
680
+ },
381
681
  detail: async ({ id, path }) => {
382
682
  const repo = repository(await read(), id), skill = repo.skills.find((s) => s.path === path);
383
683
  if (!skill) throw failure("\u6280\u80FD\u4E0D\u5728\u4ED3\u5E93\u5217\u8868\u4E2D");
@@ -385,6 +685,7 @@ function createRepositoryManager({ fetchImpl = globalThis.fetch, log, registered
385
685
  },
386
686
  restoreTrash: async (id, options = {}) => {
387
687
  const metadata = (await listTrash()).find((item) => item.id === id);
688
+ if (metadata?.catalog?.length && !metadata.repository) return restoreCatalogTrash(id, options);
388
689
  if (!metadata?.repository) return restoreSkillTrash(id, log, options);
389
690
  return serialize(async () => {
390
691
  const metadata2 = (await listTrash()).find((item) => item.id === id);
@@ -394,15 +695,22 @@ function createRepositoryManager({ fetchImpl = globalThis.fetch, log, registered
394
695
  if (!record) return restoreSkillTrash(id, log, options);
395
696
  if (metadata2.name !== record.name || (record.workspace ? metadata2.root?.scope !== "project" || metadata2.root.projectRoot !== record.workspace : metadata2.root?.scope !== "user" || metadata2.root.key !== (record.root || "dsh")) || metadata2.entries.length !== 1 || metadata2.entries[0] !== record.name) throw failure("\u56DE\u6536\u7AD9\u6765\u6E90\u4E0E\u5B89\u88C5\u8BB0\u5F55\u4E0D\u4E00\u81F4");
396
697
  record.restoreFingerprint = signature(fileIndex(await readSkillTree(join(trashRootPath(), id, record.name))));
698
+ const catalog = catalogRecords(data).filter((item) => item.trashId === id && metadata2.catalog?.includes(item.id));
699
+ for (const item of catalog) item.restoreFingerprint = record.restoreFingerprint;
397
700
  await write(data);
398
701
  const result = await restoreSkillTrash(id, log, { ...options, repositoryRestore: true });
399
702
  if ("ok" in result && result.ok === false) {
400
703
  delete record.restoreFingerprint;
704
+ for (const item of catalog) delete item.restoreFingerprint;
401
705
  await write(data);
402
706
  return result;
403
707
  }
404
708
  delete record.trashId;
405
709
  delete record.restoreFingerprint;
710
+ for (const item of catalog) {
711
+ delete item.trashId;
712
+ delete item.restoreFingerprint;
713
+ }
406
714
  try {
407
715
  await write(data);
408
716
  } catch {
@@ -421,14 +729,18 @@ function createRepositoryManager({ fetchImpl = globalThis.fetch, log, registered
421
729
  const fingerprint = signature(fileIndex(await readSkillTree(join(root.path, record.name))));
422
730
  const trashId = `${Date.now()}-${randomUUID()}`;
423
731
  record.trashId = trashId;
732
+ const catalog = catalogCopies(data, root, record.name);
733
+ for (const item of catalog) item.trashId = trashId;
424
734
  await write(data);
425
735
  try {
426
- const result = await deleteSkill(root, record.name, log, { allowRepositoryRoot: !record.workspace, repositoryEntryOnly: true, repositoryTrash: { id: trashId, repository: { id, path } } });
736
+ await recordInstallRoot(record, index.registered, await indexedFor(record));
737
+ const result = await deleteSkill(root, record.name, log, { repositoryEntryOnly: true, repositoryTrash: { id: trashId, repository: { id, path } }, ...catalog.length ? { catalogTrash: { id: trashId, ids: catalog.map((item) => item.id) } } : {} });
427
738
  if ("ok" in result && result.ok === false) throw failure(result.error, typeof result.code === "string" ? result.code : "error.repo.invalid", result.params);
428
739
  return result;
429
740
  } catch (error) {
430
741
  if (await matches(record, fingerprint)) {
431
742
  delete record.trashId;
743
+ for (const item of catalog) delete item.trashId;
432
744
  await write(data);
433
745
  }
434
746
  throw error;
@@ -442,7 +754,7 @@ function createRepositoryManager({ fetchImpl = globalThis.fetch, log, registered
442
754
  if ("ok" in target && target.ok === false) throw failure(target.error, target.code, target.params);
443
755
  root = target.root;
444
756
  workspaceRoot = root.projectRoot;
445
- } else root = repositoryInstallRoot(requestedRoot);
757
+ } else root = await recordInstallRoot({ root: requestedRoot });
446
758
  const data = await read(), repo = repository(data, id);
447
759
  const skill = repo.skills.find((s) => s.path === path);
448
760
  if (!skill || !skill.valid) throw failure("\u6280\u80FD\u4E0D\u5B58\u5728\u6216\u683C\u5F0F\u65E0\u6548");
@@ -460,7 +772,8 @@ function createRepositoryManager({ fetchImpl = globalThis.fetch, log, registered
460
772
  await write(data);
461
773
  let copied = false;
462
774
  try {
463
- const result = await importUploadedSkill({ name: skill.name, entries }, log, { conflict: "skip", root, allowRepositoryRoot: !workspaceRoot });
775
+ await recordInstallRoot({ root: requestedRoot, workspace: workspaceRoot }, registered);
776
+ const result = await importUploadedSkill({ name: skill.name, entries }, log, { conflict: "skip", root });
464
777
  if (result.ok === false || !result.imported?.length) throw failure(result.error || "\u6280\u80FD\u5B89\u88C5\u5931\u8D25\u6216\u9047\u5230\u540C\u540D\u51B2\u7A81", typeof result.code === "string" ? result.code : "error.repo.conflict", result.params);
465
778
  copied = true;
466
779
  const enabled = await setSkillEnabled(root, skill.name, true, log);
@@ -1,7 +1,7 @@
1
1
  import { promises as fs } from "node:fs";
2
2
  import { join, dirname, resolve } from "node:path";
3
3
  import { createHash, randomUUID } from "node:crypto";
4
- import { managerHomePath, userRoots, projectRootForRecord, resolveWorkspaceTarget, createWorkspaceIndex } from "./core.js";
4
+ import { managerHomePath, userRoots, checkedRepositoryRootDefinition, projectRootForRecord, resolveWorkspaceTarget, createWorkspaceIndex } from "./core.js";
5
5
  const hash = (bytes) => createHash("sha256").update(bytes).digest("hex");
6
6
  const repositoryInstallKeys = /* @__PURE__ */ new Set(["dsh", "agents"]);
7
7
  function repositoryInstallRoot(key = "dsh") {
@@ -12,7 +12,13 @@ function repositoryInstallRoot(key = "dsh") {
12
12
  return { ...root, key: root.key };
13
13
  }
14
14
  async function recordInstallRoot(record, registered, indexed) {
15
- if (record.workspace === void 0) return repositoryInstallRoot(record.root);
15
+ if (record.workspace === void 0) {
16
+ const root = repositoryInstallRoot(record.root);
17
+ const checked = await checkedRepositoryRootDefinition(root);
18
+ if (!checked) throw failure("\u6280\u80FD\u5B89\u88C5\u4F4D\u7F6E\u4E0D\u53EF\u7528", "error.root.unsafe");
19
+ if (checked.ok === false) throw Object.assign(failure(checked.error, checked.code || "error.root.unsafe"), { params: checked.params });
20
+ return root;
21
+ }
16
22
  const resolved = await projectRootForRecord(record.workspace, registered, indexed);
17
23
  if ("ok" in resolved && resolved.ok === false) throw Object.assign(failure(resolved.error, resolved.code || "error.workspace.unavailable"), { params: resolved.params });
18
24
  return resolved.root;