dsh-skill-hub 0.2.6 → 0.3.1

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 (41) hide show
  1. package/README.md +68 -229
  2. package/README.zh.md +67 -203
  3. package/lib/client.js +1955 -413
  4. package/lib/client.js.map +1 -1
  5. package/lib/index.js +441 -84
  6. package/lib/types/client/SkillHubSettingsCard.d.ts +1 -1
  7. package/lib/types/client/api.d.ts +12 -2
  8. package/lib/types/client/index.d.ts +6 -1
  9. package/lib/types/client/locales.d.ts +13 -0
  10. package/lib/types/client/panel/ScenesView.d.ts +1 -1
  11. package/lib/types/client/panel/SourcesView.d.ts +1 -1
  12. package/lib/types/client/panel/useSkillHub.d.ts +20 -2
  13. package/lib/types/client/settings-form.d.ts +1 -1
  14. package/lib/types/client/slash-dots.d.ts +2 -1
  15. package/lib/types/index.d.ts +2 -1
  16. package/lib/types/protocol.d.ts +93 -6
  17. package/lib/types/repo.d.ts +18 -11
  18. package/lib/types/store.d.ts +14 -2
  19. package/package.json +38 -29
  20. package/src/client/SkillHubSettingsCard.tsx +1 -1
  21. package/src/client/api.ts +61 -1
  22. package/src/client/index.tsx +10 -1
  23. package/src/client/locales.ts +26 -0
  24. package/src/client/panel/MarketView.tsx +201 -47
  25. package/src/client/panel/ScenesView.tsx +27 -2
  26. package/src/client/panel/SkillHubPanel.tsx +18 -19
  27. package/src/client/panel/SkillRow.tsx +8 -8
  28. package/src/client/panel/SourcesView.tsx +203 -26
  29. package/src/client/panel/panel.module.css +17 -6
  30. package/src/client/panel/useSkillHub.ts +219 -12
  31. package/src/client/settings-form.ts +2 -1
  32. package/src/client/slash-dots.test.ts +24 -6
  33. package/src/client/slash-dots.tsx +10 -1
  34. package/src/index.ts +20 -2
  35. package/src/protocol.ts +89 -6
  36. package/src/repo.test.ts +61 -19
  37. package/src/repo.ts +86 -23
  38. package/src/routes.test.ts +18 -4
  39. package/src/routes.ts +269 -47
  40. package/src/skillfs.ts +17 -4
  41. package/src/store.ts +72 -4
package/lib/index.js CHANGED
@@ -1,11 +1,11 @@
1
1
  import { createRequire } from "node:module";
2
- import { settingsNamespace } from "@deepseek-ai/dsh-settings";
3
2
  import z from "schemastery";
4
3
  import { mkdir, mkdtemp, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
5
- import { basename, dirname, join, resolve } from "node:path";
4
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
6
5
  import { homedir } from "node:os";
7
6
  import { dump, load } from "js-yaml";
8
7
  import { isSkillName } from "@deepseek-ai/dsh-skill";
8
+ import { randomUUID } from "node:crypto";
9
9
  //#region src/protocol.ts
10
10
  /**
11
11
  * Shared API contract for dsh-skill-hub: the route paths and payload shapes
@@ -31,11 +31,16 @@ const SKILL_HUB_API = {
31
31
  marketSync: "/api/skill-hub/market/source/sync",
32
32
  repo: "/api/skill-hub/repo",
33
33
  repoImport: "/api/skill-hub/repo/import",
34
+ repoImportProgress: "/api/skill-hub/repo/import/progress",
35
+ repoImportCancel: "/api/skill-hub/repo/import/cancel",
34
36
  update: "/api/skill-hub/update",
35
37
  groups: "/api/skill-hub/groups",
36
38
  tag: "/api/skill-hub/tag",
37
39
  tagDelete: "/api/skill-hub/tag/delete",
38
40
  tagMembers: "/api/skill-hub/tag/members",
41
+ tagReorder: "/api/skill-hub/tag/reorder",
42
+ collectionReorder: "/api/skill-hub/collections/reorder",
43
+ sourceGroupReorder: "/api/skill-hub/source-groups/reorder",
39
44
  sources: "/api/skill-hub/sources",
40
45
  sourceCheck: "/api/skill-hub/sources/check",
41
46
  sourceSync: "/api/skill-hub/sources/sync",
@@ -172,7 +177,9 @@ function migrateStore(parsed) {
172
177
  ...sources !== void 0 ? { sources } : {},
173
178
  ...marketSources !== void 0 ? { marketSources } : {},
174
179
  ...trash !== void 0 ? { trash } : {},
175
- ...record.skillStats !== void 0 ? { skillStats: record.skillStats } : {}
180
+ ...record.skillStats !== void 0 ? { skillStats: record.skillStats } : {},
181
+ ...Array.isArray(record.collectionOrder) ? { collectionOrder: record.collectionOrder } : {},
182
+ ...Array.isArray(record.sourceGroupOrder) ? { sourceGroupOrder: record.sourceGroupOrder } : {}
176
183
  };
177
184
  }
178
185
  /** Sidecar state owner. */
@@ -185,6 +192,8 @@ var SkillHubStore = class {
185
192
  marketSources = [];
186
193
  trashByName = /* @__PURE__ */ new Map();
187
194
  skillStats = void 0;
195
+ collectionOrder = [];
196
+ sourceGroupOrder = [];
188
197
  loaded = false;
189
198
  /** Serializes persist runs: concurrent mutators must not let an earlier
190
199
  * snapshot overwrite a later one (rename is atomic, ordering is not). */
@@ -226,10 +235,11 @@ var SkillHubStore = class {
226
235
  const source = entry;
227
236
  if (source !== null && typeof source === "object" && typeof source.repo === "string" && source.repo !== "" && Array.isArray(source.skills)) {
228
237
  const manifest = source.manifest;
238
+ const rawRoot = typeof source.root === "string" && source.root !== "" && /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(source.root) ? source.root : "skills";
229
239
  this.sourcesByRepo.set(source.repo, {
230
240
  repo: source.repo,
231
241
  ...typeof source.ref === "string" && source.ref !== "" ? { ref: source.ref } : {},
232
- root: source.root === "design-templates" ? "design-templates" : "skills",
242
+ root: rawRoot,
233
243
  commitSha: typeof source.commitSha === "string" ? source.commitSha : "",
234
244
  skills: source.skills.filter((n) => typeof n === "string"),
235
245
  ...manifest !== null && typeof manifest === "object" && !Array.isArray(manifest) ? { manifest: Object.fromEntries(Object.entries(manifest).filter(([, size]) => typeof size === "number")) } : {}
@@ -254,7 +264,7 @@ var SkillHubStore = class {
254
264
  path: item.path,
255
265
  movedAt: typeof item.movedAt === "number" ? item.movedAt : 0,
256
266
  ...typeof item.sourcePath === "string" && item.sourcePath !== "" ? { sourcePath: item.sourcePath } : {},
257
- ...origin !== null && typeof origin === "object" && typeof origin.repo === "string" && origin.repo !== "" && (origin.root === "skills" || origin.root === "design-templates") ? { origin: {
267
+ ...origin !== null && typeof origin === "object" && typeof origin.repo === "string" && origin.repo !== "" && typeof origin.root === "string" && origin.root !== "" && /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(origin.root) ? { origin: {
258
268
  repo: origin.repo,
259
269
  root: origin.root,
260
270
  ...typeof origin.ref === "string" && origin.ref !== "" ? { ref: origin.ref } : {},
@@ -286,6 +296,10 @@ var SkillHubStore = class {
286
296
  lastFullReconcile: savedStats.lastFullReconcile
287
297
  };
288
298
  }
299
+ const rawColOrder = migrated.collectionOrder;
300
+ if (Array.isArray(rawColOrder)) this.collectionOrder = rawColOrder.filter((n) => typeof n === "string" && n !== "");
301
+ const rawSrcOrder = migrated.sourceGroupOrder;
302
+ if (Array.isArray(rawSrcOrder)) this.sourceGroupOrder = rawSrcOrder.filter((n) => typeof n === "string" && n !== "");
289
303
  }
290
304
  } catch (error) {
291
305
  if (error.code !== "ENOENT") console.warn("[dsh-skill-hub] sidecar state unreadable, starting empty:", error instanceof Error ? error.message : error);
@@ -432,6 +446,50 @@ var SkillHubStore = class {
432
446
  }
433
447
  if (changed) await this.persist();
434
448
  }
449
+ /** Reorder tag groups by orderedIds (drag-and-drop). */
450
+ async reorderTags(orderedIds) {
451
+ await this.ensureLoaded();
452
+ const currentIds = [...this.tagsById.keys()];
453
+ if (orderedIds.length !== currentIds.length) throw new StoreError("validation", "orderedIds length mismatch");
454
+ const seen = /* @__PURE__ */ new Set();
455
+ for (const id of orderedIds) {
456
+ if (typeof id !== "string" || id === "") throw new StoreError("validation", "invalid tag id");
457
+ if (seen.has(id)) throw new StoreError("validation", "duplicate tag id: " + id);
458
+ if (!this.tagsById.has(id)) throw new StoreError("not-found", "tag not found: " + id);
459
+ seen.add(id);
460
+ }
461
+ const newMap = /* @__PURE__ */ new Map();
462
+ for (const id of orderedIds) newMap.set(id, this.tagsById.get(id));
463
+ this.tagsById = newMap;
464
+ await this.persist();
465
+ return [...this.tagsById.values()];
466
+ }
467
+ /** Collection order for 来源分组拖拽 */
468
+ async getCollectionOrder() {
469
+ await this.ensureLoaded();
470
+ return [...this.collectionOrder];
471
+ }
472
+ /** Reorder collections by orderedNames (drag-and-drop). */
473
+ async reorderCollections(orderedNames) {
474
+ await this.ensureLoaded();
475
+ const uniq = [...new Set(orderedNames.filter((n) => typeof n === "string" && n !== ""))];
476
+ this.collectionOrder = uniq;
477
+ await this.persist();
478
+ return [...this.collectionOrder];
479
+ }
480
+ /** Source top-level group order for 来源分组(project / col:xxx / personal) */
481
+ async getSourceGroupOrder() {
482
+ await this.ensureLoaded();
483
+ return [...this.sourceGroupOrder];
484
+ }
485
+ /** Reorder source top-level groups by orderedKeys */
486
+ async reorderSourceGroups(orderedKeys) {
487
+ await this.ensureLoaded();
488
+ const uniq = [...new Set(orderedKeys.filter((k) => typeof k === "string" && k !== ""))];
489
+ this.sourceGroupOrder = uniq;
490
+ await this.persist();
491
+ return [...this.sourceGroupOrder];
492
+ }
435
493
  /** All source records, sorted by repo. */
436
494
  async listSources() {
437
495
  await this.ensureLoaded();
@@ -659,7 +717,9 @@ var SkillHubStore = class {
659
717
  ...this.sourcesByRepo.size > 0 ? { sources: [...this.sourcesByRepo.values()] } : {},
660
718
  ...this.marketSources.length > 0 ? { marketSources: [...this.marketSources] } : {},
661
719
  ...this.trashByName.size > 0 ? { trash: [...this.trashByName.values()] } : {},
662
- ...this.skillStats !== void 0 ? { skillStats: this.skillStats } : {}
720
+ ...this.skillStats !== void 0 ? { skillStats: this.skillStats } : {},
721
+ ...this.collectionOrder.length > 0 ? { collectionOrder: [...this.collectionOrder] } : {},
722
+ ...this.sourceGroupOrder.length > 0 ? { sourceGroupOrder: [...this.sourceGroupOrder] } : {}
663
723
  };
664
724
  const tmp = this.file + ".tmp";
665
725
  await mkdir(dirname(this.file), { recursive: true });
@@ -698,10 +758,22 @@ function rootPath(root, home = dshHome()) {
698
758
  }
699
759
  /** Root of an absolute skill file path, or undefined when not user-owned. */
700
760
  function rootOfPath(path, home = dshHome()) {
701
- const normalized = join(path);
761
+ const normalized = resolve(path);
702
762
  for (const root of WRITABLE_ROOTS) {
703
- const base = rootPath(root, home);
704
- if (normalized.startsWith(base + "/") || normalized === base) return root;
763
+ const base = resolve(rootPath(root, home));
764
+ if (process.platform === "win32") {
765
+ const n = normalized.toLowerCase();
766
+ const b = base.toLowerCase();
767
+ if (n === b) return root;
768
+ if (n.startsWith(b + "/") || n.startsWith(b + "\\") || n.startsWith(b + sep)) return root;
769
+ const rel = relative(base, normalized);
770
+ if (rel !== "" && !rel.startsWith("../") && !rel.startsWith("..\\") && !rel.startsWith(".." + sep) && rel !== ".." && !isAbsolute(rel)) return root;
771
+ } else {
772
+ if (normalized === base) return root;
773
+ if (normalized.startsWith(base + sep) || normalized.startsWith(base + "/") || normalized.startsWith(base + "\\")) return root;
774
+ const rel = relative(base, normalized);
775
+ if (rel !== "" && !rel.startsWith(".." + sep) && !rel.startsWith("../") && !rel.startsWith("..\\") && rel !== ".." && !isAbsolute(rel)) return root;
776
+ }
705
777
  }
706
778
  }
707
779
  /**
@@ -1102,10 +1174,12 @@ var SkillHubProvider = class {
1102
1174
  * GitHub repository skill discovery/import helpers.
1103
1175
  *
1104
1176
  * Kept dependency-free and mostly pure so the root/origin rules are easy to
1105
- * test. The two supported roots mirror the repositories we care about:
1106
- * `skills/**` (standard skill bundles) and `design-templates/**` (template
1107
- * skill bundles in nexu-io/open-design).
1177
+ * test. Roots are auto-derived: any top-level directory that contains a
1178
+ * `**\/SKILL.md` (e.g. `skills/**`, `design-templates/**`, `templates/**`,
1179
+ * `workflows/**`) is treated as a skill root. No hard-coded allowlist.
1108
1180
  */
1181
+ /** Top-level directory pattern for a skill root: visible, non-dot, safe chars. First char must be alphanum. */
1182
+ const ROOT_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
1109
1183
  /** Fetch failure carrying a useful HTTP status for the route layer. */
1110
1184
  var RepoFetchError = class extends Error {
1111
1185
  status;
@@ -1234,16 +1308,19 @@ function collectRepoSkillFiles(tree, dir) {
1234
1308
  function originForRoot(repo, rootsPresent, root) {
1235
1309
  return rootsPresent.size > 1 ? `${repo}/${root}` : repo;
1236
1310
  }
1237
- /** Discover importable skills from a repo tree. Invalid names are ignored. */
1311
+ /** Discover importable skills from a repo tree. Invalid names are ignored. Roots are auto-derived from the top-level directory of each SKILL.md. */
1238
1312
  function discoverRepoEntries(tree, repo, existingNames = /* @__PURE__ */ new Set()) {
1239
1313
  const candidates = [];
1240
1314
  const rootsPresent = /* @__PURE__ */ new Set();
1241
1315
  for (const item of tree) {
1242
1316
  if (item.type !== "blob") continue;
1243
- const match = /^(skills|design-templates)\/(.+)\/SKILL\.md$/.exec(item.path);
1244
- if (match === null) continue;
1245
- const root = match[1];
1317
+ const slash = item.path.indexOf("/");
1318
+ if (slash === -1) continue;
1319
+ if (!item.path.endsWith("/SKILL.md")) continue;
1320
+ const root = item.path.slice(0, slash);
1321
+ if (!ROOT_RE.test(root)) continue;
1246
1322
  const dir = item.path.slice(0, -9);
1323
+ if (dir === root || dir.length <= root.length + 1) continue;
1247
1324
  const name = dir.slice(dir.lastIndexOf("/") + 1);
1248
1325
  if (!isSkillName(name)) continue;
1249
1326
  rootsPresent.add(root);
@@ -1282,30 +1359,44 @@ async function mapConcurrent(items, limit, worker) {
1282
1359
  await Promise.all(runners);
1283
1360
  return results;
1284
1361
  }
1362
+ /** Whether an error is an abort (AbortError / signal). */
1363
+ function isAbortError(error) {
1364
+ return error instanceof Error && (error.name === "AbortError" || error.message.includes("aborted") || error.message.includes("Abort"));
1365
+ }
1285
1366
  /**
1286
1367
  * Download one GitHub file as a buffer. Tries raw.githubusercontent.com
1287
1368
  * first (no API quota); on any failure falls back to the api.github.com
1288
1369
  * contents endpoint with the raw media type (rate-limited, but reachable
1289
1370
  * from networks that block the raw host).
1290
1371
  */
1291
- async function downloadGitHubFile(repo, ref, path, fetchImpl = fetch) {
1372
+ async function downloadGitHubFile(repo, ref, path, fetchImpl = fetch, signal) {
1373
+ if (signal?.aborted) throw new DOMException("aborted", "AbortError");
1292
1374
  const encodedPath = path.split("/").map((segment) => encodeURIComponent(segment)).join("/");
1293
1375
  const rawUrl = `https://raw.githubusercontent.com/${repo}/${encodeURIComponent(ref)}/${encodedPath}`;
1294
1376
  let firstError = null;
1295
1377
  let response = null;
1296
1378
  try {
1297
- response = await fetchImpl(rawUrl, { headers: githubAuthHeaders() });
1379
+ response = await fetchImpl(rawUrl, {
1380
+ headers: githubAuthHeaders(),
1381
+ ...signal !== void 0 ? { signal } : {}
1382
+ });
1298
1383
  } catch (error) {
1384
+ if (isAbortError(error)) throw error;
1299
1385
  firstError = error instanceof Error ? error.message : String(error);
1300
1386
  }
1301
1387
  if (response === null || !response.ok) {
1388
+ if (signal?.aborted) throw new DOMException("aborted", "AbortError");
1302
1389
  const apiUrl = `https://api.github.com/repos/${repo}/contents/${encodedPath}`;
1303
1390
  try {
1304
- response = await fetchImpl(apiUrl, { headers: {
1305
- accept: "application/vnd.github.raw",
1306
- ...githubAuthHeaders()
1307
- } });
1391
+ response = await fetchImpl(apiUrl, {
1392
+ headers: {
1393
+ accept: "application/vnd.github.raw",
1394
+ ...githubAuthHeaders()
1395
+ },
1396
+ ...signal !== void 0 ? { signal } : {}
1397
+ });
1308
1398
  } catch (error) {
1399
+ if (isAbortError(error)) throw error;
1309
1400
  throw new RepoFetchError("download failed: " + (firstError ?? (error instanceof Error ? error.message : String(error))));
1310
1401
  }
1311
1402
  }
@@ -1313,6 +1404,7 @@ async function downloadGitHubFile(repo, ref, path, fetchImpl = fetch) {
1313
1404
  try {
1314
1405
  return Buffer.from(await response.arrayBuffer());
1315
1406
  } catch (error) {
1407
+ if (isAbortError(error)) throw error;
1316
1408
  throw new RepoFetchError("download read failed: " + (error instanceof Error ? error.message : String(error)));
1317
1409
  }
1318
1410
  }
@@ -1321,19 +1413,24 @@ async function downloadGitHubFile(repo, ref, path, fetchImpl = fetch) {
1321
1413
  * then atomically rename it into place. The target root is created when missing.
1322
1414
  * Callers pass a target root and a skill entry plus its collected files.
1323
1415
  */
1324
- async function downloadRepoSkill(repo, ref, entry, files, targetRoot, fetchImpl = fetch) {
1416
+ async function downloadRepoSkill(repo, ref, entry, files, targetRoot, fetchImpl = fetch, signal, onProgress) {
1417
+ if (signal?.aborted) throw new DOMException("aborted", "AbortError");
1325
1418
  const targetDir = join(targetRoot, entry.name);
1326
1419
  await mkdir(targetRoot, { recursive: true });
1327
1420
  const tempDir = await mkdtemp(join(targetRoot, "." + entry.name + ".import-"));
1421
+ let renamed = false;
1328
1422
  try {
1329
1423
  await mapConcurrent(files, 6, async (file) => {
1424
+ if (signal?.aborted) throw new DOMException("aborted", "AbortError");
1330
1425
  const relative = file.path.slice(entry.dir.length + 1);
1331
1426
  if (relative === "" || relative.includes("..")) throw new RepoFetchError("unsafe repo path: " + file.path);
1332
1427
  const target = join(tempDir, relative);
1333
- const buffer = await downloadGitHubFile(repo, ref, file.path, fetchImpl);
1428
+ const buffer = await downloadGitHubFile(repo, ref, file.path, fetchImpl, signal);
1334
1429
  await mkdir(dirname(target), { recursive: true });
1335
1430
  await writeFile(target, buffer);
1431
+ onProgress?.(buffer.length, relative);
1336
1432
  });
1433
+ if (signal?.aborted) throw new DOMException("aborted", "AbortError");
1337
1434
  let text;
1338
1435
  try {
1339
1436
  text = await readFile(join(tempDir, "SKILL.md"), "utf8");
@@ -1344,25 +1441,62 @@ async function downloadRepoSkill(repo, ref, entry, files, targetRoot, fetchImpl
1344
1441
  if ("error" in parsed) throw new RepoFetchError("downloaded skill rejected: " + parsed.error, 422);
1345
1442
  if (parsed.value.name !== entry.name) throw new RepoFetchError("downloaded skill declares name \"" + parsed.value.name + "\", expected \"" + entry.name + "\"", 422);
1346
1443
  await rename(tempDir, targetDir);
1444
+ renamed = true;
1347
1445
  return {
1348
1446
  targetDir,
1349
1447
  skillPath: join(targetDir, "SKILL.md")
1350
1448
  };
1351
1449
  } catch (error) {
1352
- await rm(tempDir, {
1353
- recursive: true,
1354
- force: true
1355
- }).catch(async () => {
1356
- await new Promise((resolve) => setTimeout(resolve, 60));
1450
+ throw error;
1451
+ } finally {
1452
+ if (!renamed) try {
1357
1453
  await rm(tempDir, {
1358
1454
  recursive: true,
1359
1455
  force: true
1360
- }).catch(() => {});
1361
- });
1362
- throw error;
1456
+ });
1457
+ } catch (firstError) {
1458
+ await new Promise((resolve) => setTimeout(resolve, 60));
1459
+ try {
1460
+ await rm(tempDir, {
1461
+ recursive: true,
1462
+ force: true
1463
+ });
1464
+ } catch (secondError) {
1465
+ console.warn(`[skill-hub] cleanup tempDir failed ${tempDir}:`, secondError instanceof Error ? secondError.message : String(secondError), "first:", firstError instanceof Error ? firstError.message : String(firstError));
1466
+ }
1467
+ }
1363
1468
  }
1364
1469
  }
1365
1470
  /**
1471
+ * 启动时扫描并清理残留的 `.*.import-*` 临时目录(Issue #3 第4点)。
1472
+ * 越积越多的点前缀目录不会显示为 skill,但会占空间,尽早回收。
1473
+ */
1474
+ async function cleanupLeftoverImportDirs(targetRoot) {
1475
+ const { readdir, rm: rm2 } = await import("node:fs/promises");
1476
+ let names;
1477
+ try {
1478
+ names = await readdir(targetRoot);
1479
+ } catch {
1480
+ return 0;
1481
+ }
1482
+ let cleaned = 0;
1483
+ for (const name of names) {
1484
+ if (!/^\..*\.import-/.test(name)) continue;
1485
+ const full = join(targetRoot, name);
1486
+ try {
1487
+ await rm2(full, {
1488
+ recursive: true,
1489
+ force: true
1490
+ });
1491
+ cleaned += 1;
1492
+ } catch (error) {
1493
+ console.warn(`[skill-hub] startup cleanup failed ${full}:`, error instanceof Error ? error.message : String(error));
1494
+ }
1495
+ }
1496
+ if (cleaned > 0) console.warn(`[skill-hub] startup cleaned ${cleaned} leftover import temp dir(s) in ${targetRoot}`);
1497
+ return cleaned;
1498
+ }
1499
+ /**
1366
1500
  * The latest non-prerelease release tag of a repo, or undefined when the
1367
1501
  * repo has no releases (GitHub's /releases/latest skips prereleases/drafts).
1368
1502
  */
@@ -1827,21 +1961,34 @@ async function resolveWritableSkill(deps, name, cwd) {
1827
1961
  }
1828
1962
  /** 系统集合组 + 用户 tag + origin 映射(groups 路由的数据源)。 */
1829
1963
  async function buildGroups(deps) {
1830
- const [tags, origins] = await Promise.all([deps.store.listTags(), deps.store.listOrigins()]);
1964
+ const [tags, origins, collectionOrder, sourceGroupOrder] = await Promise.all([
1965
+ deps.store.listTags(),
1966
+ deps.store.listOrigins(),
1967
+ deps.store.getCollectionOrder(),
1968
+ deps.store.getSourceGroupOrder()
1969
+ ]);
1831
1970
  const byCollection = /* @__PURE__ */ new Map();
1832
1971
  for (const [skillName, origin] of Object.entries(origins)) {
1833
1972
  const list = byCollection.get(origin);
1834
1973
  if (list === void 0) byCollection.set(origin, [skillName]);
1835
1974
  else list.push(skillName);
1836
1975
  }
1976
+ const orderIndex = new Map(collectionOrder.map((name, i) => [name, i]));
1837
1977
  return {
1838
1978
  ok: true,
1839
1979
  tags,
1840
1980
  collections: [...byCollection.entries()].map(([name, skillNames]) => ({
1841
1981
  name,
1842
1982
  skillNames: [...skillNames].sort((a, b) => a.localeCompare(b))
1843
- })).sort((a, b) => a.name.localeCompare(b.name)),
1844
- origins
1983
+ })).sort((a, b) => {
1984
+ const ai = orderIndex.has(a.name) ? orderIndex.get(a.name) : Infinity;
1985
+ const bi = orderIndex.has(b.name) ? orderIndex.get(b.name) : Infinity;
1986
+ if (ai !== bi) return ai - bi;
1987
+ return a.name.localeCompare(b.name);
1988
+ }),
1989
+ origins,
1990
+ ...sourceGroupOrder.length > 0 ? { sourceGroupOrder } : {},
1991
+ ...collectionOrder.length > 0 ? { collectionOrder } : {}
1845
1992
  };
1846
1993
  }
1847
1994
  /** 目录中存在的技能名集合(tag 成员校验用):启用目录 ∪ 已禁用名单,避免成员因禁用而丢失。 */
@@ -1986,6 +2133,18 @@ function toDetail(skill) {
1986
2133
  const lastSourceCheck = /* @__PURE__ */ new Map();
1987
2134
  /** Last successful market update check per repo (same throttle). */
1988
2135
  const lastMarketCheck = /* @__PURE__ */ new Map();
2136
+ const importJobs = /* @__PURE__ */ new Map();
2137
+ const IMPORT_JOB_TTL_MS = 5 * 6e4;
2138
+ const IMPORT_JOB_MAX = 100;
2139
+ function gcImportJobs() {
2140
+ if (importJobs.size > 500) importJobs.clear();
2141
+ const now = Date.now();
2142
+ for (const [id, job] of importJobs) if (job.status !== "running" && now - job.createdAt > IMPORT_JOB_TTL_MS) importJobs.delete(id);
2143
+ if (importJobs.size > IMPORT_JOB_MAX) {
2144
+ const sorted = [...importJobs.entries()].sort((a, b) => a[1].createdAt - b[1].createdAt);
2145
+ for (let i = 0; i < sorted.length - IMPORT_JOB_MAX; i++) importJobs.delete(sorted[i][0]);
2146
+ }
2147
+ }
1989
2148
  /** Replace one skill directory with a fresh download; restores the old dir on failure. */
1990
2149
  async function replaceSkillDir(targetDir, download) {
1991
2150
  const backup = join(dirname(targetDir), "." + basename(targetDir) + ".sync-bak-" + Date.now());
@@ -2103,13 +2262,27 @@ function makeRoutes(deps) {
2103
2262
  return;
2104
2263
  }
2105
2264
  const resolved = await resolveWritableSkill(deps, name, typeof request.cwd === "string" ? request.cwd : void 0);
2265
+ let trashResult = null;
2106
2266
  if (!resolved.ok) {
2107
- writeError(res, resolved.status, resolved.error);
2108
- return;
2267
+ const disabled = await deps.store.getDisabled(name);
2268
+ if (disabled !== void 0) {
2269
+ const sourceToTrash = disabled.path.endsWith("SKILL.md.disabled") ? dirname(disabled.path) : disabled.path;
2270
+ const trashDir = join(dirname(sourceToTrash), ".trash");
2271
+ await mkdir(trashDir, { recursive: true });
2272
+ const target = join(trashDir, basename(sourceToTrash) + "-" + Date.now());
2273
+ await rename(sourceToTrash, target);
2274
+ trashResult = {
2275
+ path: target,
2276
+ source: sourceToTrash
2277
+ };
2278
+ } else {
2279
+ writeError(res, resolved.status, resolved.error);
2280
+ return;
2281
+ }
2109
2282
  }
2110
2283
  const tracked = await deps.store.getSourceForSkill(name);
2111
2284
  const tagIds = (await deps.store.listTags()).filter((tag) => tag.skillNames.includes(name)).map((tag) => tag.id);
2112
- const { path, source } = await trashSkill(resolved.path);
2285
+ const { path, source } = trashResult ?? await trashSkill(resolved.path);
2113
2286
  await deps.store.addTrash({
2114
2287
  name,
2115
2288
  path,
@@ -2600,11 +2773,16 @@ function makeRoutes(deps) {
2600
2773
  const entries = discoverRepoEntries(tree, repo, await knownSkillNames(deps));
2601
2774
  const byPath = new Map(entries.map((entry) => [entry.path, entry]));
2602
2775
  const selected = paths.map((path) => byPath.get(path)).filter((entry) => entry !== void 0);
2776
+ if (selected.length === 0) {
2777
+ writeError(res, 400, "no matching skills for selected paths");
2778
+ return;
2779
+ }
2603
2780
  if (selected.length > 500) {
2604
2781
  writeError(res, 400, "select at most 500 skills per import");
2605
2782
  return;
2606
2783
  }
2607
- if (selected.reduce((sum, entry) => sum + entry.totalBytes, 0) > 200 * 1024 * 1024) {
2784
+ const totalBytes = selected.reduce((sum, entry) => sum + entry.totalBytes, 0);
2785
+ if (totalBytes > 200 * 1024 * 1024) {
2608
2786
  writeError(res, 400, "selected skills exceed the 200MB import limit");
2609
2787
  return;
2610
2788
  }
@@ -2612,51 +2790,164 @@ function makeRoutes(deps) {
2612
2790
  try {
2613
2791
  commitSha = (await getLatestCommit(repo, resolvedRef)).commitSha;
2614
2792
  } catch {}
2615
- const imported = [];
2616
- const skipped = [];
2617
- const failed = [];
2618
- const targetRoot = rootPath("user-dsh", homeOf(deps));
2619
- await mkdir(targetRoot, { recursive: true });
2620
- const defaultTag = await deps.store.getDefaultTag();
2621
- for (const entry of selected) {
2622
- if (entry.existing) {
2623
- skipped.push({
2624
- name: entry.name,
2625
- reason: "exists"
2626
- });
2627
- continue;
2628
- }
2629
- if (await pathExists(join(targetRoot, entry.name))) {
2630
- skipped.push({
2631
- name: entry.name,
2632
- reason: "exists"
2633
- });
2634
- continue;
2793
+ const jobId = "imp_" + randomUUID().slice(0, 8);
2794
+ const controller = new AbortController();
2795
+ const now = Date.now();
2796
+ const job = {
2797
+ jobId,
2798
+ repo,
2799
+ ref: resolvedRef,
2800
+ total: selected.length,
2801
+ done: 0,
2802
+ totalBytes,
2803
+ downloadedBytes: 0,
2804
+ startTime: now,
2805
+ imported: [],
2806
+ skipped: [],
2807
+ failed: [],
2808
+ status: "running",
2809
+ controller,
2810
+ createdAt: now
2811
+ };
2812
+ gcImportJobs();
2813
+ importJobs.set(jobId, job);
2814
+ cleanupLeftoverImportDirs(rootPath("user-dsh", homeOf(deps))).catch(() => {});
2815
+ (async () => {
2816
+ const targetRoot = rootPath("user-dsh", homeOf(deps));
2817
+ await mkdir(targetRoot, { recursive: true });
2818
+ const defaultTag = await deps.store.getDefaultTag();
2819
+ let needInvalidate = false;
2820
+ for (const entry of selected) {
2821
+ if (controller.signal.aborted) break;
2822
+ job.current = entry.name;
2823
+ job.currentFile = entry.dir + "/SKILL.md";
2824
+ if (entry.existing) {
2825
+ job.skipped.push({
2826
+ name: entry.name,
2827
+ reason: "exists"
2828
+ });
2829
+ job.downloadedBytes += entry.totalBytes;
2830
+ job.done += 1;
2831
+ continue;
2832
+ }
2833
+ if (await pathExists(join(targetRoot, entry.name))) {
2834
+ job.skipped.push({
2835
+ name: entry.name,
2836
+ reason: "exists"
2837
+ });
2838
+ job.downloadedBytes += entry.totalBytes;
2839
+ job.done += 1;
2840
+ continue;
2841
+ }
2842
+ if (controller.signal.aborted) break;
2843
+ const files = collectRepoSkillFiles(tree, entry.dir);
2844
+ try {
2845
+ const result = await downloadRepoSkill(repo, resolvedRef, entry, files, targetRoot, fetch, controller.signal, (bytes, file) => {
2846
+ job.downloadedBytes += bytes;
2847
+ job.currentFile = entry.dir + "/" + file;
2848
+ });
2849
+ await deps.store.addSourceSkill(repo, entry.root, commitSha, resolvedRef, entry.name);
2850
+ await deps.store.mergeSourceManifest(repo, skillManifest(tree, entry.dir), entry.dir);
2851
+ if (defaultTag !== void 0) await deps.store.addSkillToTag(defaultTag.id, entry.name);
2852
+ job.imported.push({
2853
+ name: entry.name,
2854
+ origin: entry.origin,
2855
+ path: result.skillPath
2856
+ });
2857
+ needInvalidate = true;
2858
+ } catch (error) {
2859
+ if (isAbortError(error) || controller.signal.aborted) break;
2860
+ job.failed.push({
2861
+ name: entry.name,
2862
+ error: error instanceof Error ? error.message : String(error)
2863
+ });
2864
+ } finally {
2865
+ job.done += 1;
2866
+ }
2635
2867
  }
2636
- const files = collectRepoSkillFiles(tree, entry.dir);
2637
- try {
2638
- const result = await downloadRepoSkill(repo, resolvedRef, entry, files, targetRoot);
2639
- await deps.store.addSourceSkill(repo, entry.root, commitSha, resolvedRef, entry.name);
2640
- await deps.store.mergeSourceManifest(repo, skillManifest(tree, entry.dir), entry.dir);
2641
- if (defaultTag !== void 0) await deps.store.addSkillToTag(defaultTag.id, entry.name);
2642
- imported.push({
2643
- name: entry.name,
2644
- origin: entry.origin,
2645
- path: result.skillPath
2646
- });
2647
- } catch (error) {
2648
- failed.push({
2649
- name: entry.name,
2650
- error: error instanceof Error ? error.message : String(error)
2651
- });
2868
+ if (controller.signal.aborted) {
2869
+ job.status = "cancelled";
2870
+ job.current = void 0;
2871
+ job.currentFile = void 0;
2872
+ } else {
2873
+ job.status = "done";
2874
+ job.current = void 0;
2875
+ job.currentFile = void 0;
2876
+ job.downloadedBytes = job.totalBytes;
2652
2877
  }
2878
+ if (needInvalidate) deps.invalidate?.();
2879
+ })().catch((error) => {
2880
+ job.status = "error";
2881
+ job.error = error instanceof Error ? error.message : String(error);
2882
+ job.current = void 0;
2883
+ job.currentFile = void 0;
2884
+ });
2885
+ writeJson(res, 200, {
2886
+ ok: true,
2887
+ jobId,
2888
+ total: selected.length,
2889
+ totalBytes
2890
+ });
2891
+ }
2892
+ }),
2893
+ route({
2894
+ path: SKILL_HUB_API.repoImportProgress,
2895
+ methods: ["GET"],
2896
+ handler: async ({ res, url }) => {
2897
+ const jobId = queryParam(url, "jobId") ?? "";
2898
+ if (jobId === "") {
2899
+ writeError(res, 400, "jobId is required");
2900
+ return;
2653
2901
  }
2654
- deps.invalidate?.();
2902
+ const job = importJobs.get(jobId);
2903
+ if (job === void 0) {
2904
+ writeError(res, 404, "import job not found: " + jobId);
2905
+ return;
2906
+ }
2907
+ const elapsedSec = Math.max(.5, (Date.now() - job.startTime) / 1e3);
2908
+ const bytesPerSecond = job.status === "running" ? Math.round(job.downloadedBytes / elapsedSec) : void 0;
2655
2909
  writeJson(res, 200, {
2656
2910
  ok: true,
2657
- imported,
2658
- skipped,
2659
- failed
2911
+ jobId: job.jobId,
2912
+ status: job.status,
2913
+ total: job.total,
2914
+ done: job.done,
2915
+ ...job.current !== void 0 ? { current: job.current } : {},
2916
+ ...job.currentFile !== void 0 ? { currentFile: job.currentFile } : {},
2917
+ totalBytes: job.totalBytes,
2918
+ downloadedBytes: job.downloadedBytes,
2919
+ ...bytesPerSecond !== void 0 ? { bytesPerSecond } : {},
2920
+ imported: [...job.imported],
2921
+ skipped: [...job.skipped],
2922
+ failed: [...job.failed],
2923
+ ...job.error !== void 0 ? { error: job.error } : {}
2924
+ });
2925
+ }
2926
+ }),
2927
+ route({
2928
+ path: SKILL_HUB_API.repoImportCancel,
2929
+ methods: ["POST"],
2930
+ jsonBody: true,
2931
+ handler: async ({ res, body }) => {
2932
+ const request = body;
2933
+ const jobId = typeof request.jobId === "string" ? request.jobId : "";
2934
+ if (jobId === "") {
2935
+ writeError(res, 400, "jobId is required");
2936
+ return;
2937
+ }
2938
+ const job = importJobs.get(jobId);
2939
+ if (job === void 0) {
2940
+ writeError(res, 404, "import job not found: " + jobId);
2941
+ return;
2942
+ }
2943
+ if (job.status === "running") {
2944
+ job.controller.abort();
2945
+ job.status = "cancelled";
2946
+ }
2947
+ writeJson(res, 200, {
2948
+ ok: true,
2949
+ jobId: job.jobId,
2950
+ status: job.status
2660
2951
  });
2661
2952
  }
2662
2953
  }),
@@ -2739,14 +3030,64 @@ function makeRoutes(deps) {
2739
3030
  });
2740
3031
  }
2741
3032
  }),
3033
+ route({
3034
+ path: SKILL_HUB_API.tagReorder,
3035
+ methods: ["POST"],
3036
+ jsonBody: true,
3037
+ handler: async ({ res, body }) => {
3038
+ const request = body;
3039
+ const orderedIds = Array.isArray(request.orderedIds) ? request.orderedIds.filter((id) => typeof id === "string" && id !== "") : [];
3040
+ try {
3041
+ writeJson(res, 200, {
3042
+ ok: true,
3043
+ tags: await deps.store.reorderTags(orderedIds)
3044
+ });
3045
+ } catch (error) {
3046
+ if (error instanceof StoreError) {
3047
+ writeError(res, error.kind === "validation" ? 400 : error.kind === "not-found" ? 404 : 409, error.message);
3048
+ return;
3049
+ }
3050
+ throw error;
3051
+ }
3052
+ }
3053
+ }),
3054
+ route({
3055
+ path: SKILL_HUB_API.collectionReorder,
3056
+ methods: ["POST"],
3057
+ jsonBody: true,
3058
+ handler: async ({ res, body }) => {
3059
+ const request = body;
3060
+ const orderedNames = Array.isArray(request.orderedNames) ? request.orderedNames.filter((n) => typeof n === "string" && n !== "") : [];
3061
+ const order = await deps.store.reorderCollections(orderedNames);
3062
+ writeJson(res, 200, {
3063
+ ok: true,
3064
+ collections: (await buildGroups(deps)).collections,
3065
+ order
3066
+ });
3067
+ }
3068
+ }),
3069
+ route({
3070
+ path: SKILL_HUB_API.sourceGroupReorder,
3071
+ methods: ["POST"],
3072
+ jsonBody: true,
3073
+ handler: async ({ res, body }) => {
3074
+ const request = body;
3075
+ const orderedKeys = Array.isArray(request.orderedKeys) ? request.orderedKeys.filter((k) => typeof k === "string" && k !== "") : [];
3076
+ writeJson(res, 200, {
3077
+ ok: true,
3078
+ order: await deps.store.reorderSourceGroups(orderedKeys)
3079
+ });
3080
+ }
3081
+ }),
2742
3082
  route({
2743
3083
  path: SKILL_HUB_API.sources,
2744
3084
  methods: ["GET"],
2745
3085
  handler: async ({ res }) => {
2746
- const [sources, origins, trash] = await Promise.all([
3086
+ const [sources, origins, trash, collectionOrder] = await Promise.all([
2747
3087
  deps.store.listSources(),
2748
3088
  deps.store.listOrigins(),
2749
- deps.store.listTrash()
3089
+ deps.store.listTrash(),
3090
+ deps.store.getCollectionOrder()
2750
3091
  ]);
2751
3092
  const byCollection = /* @__PURE__ */ new Map();
2752
3093
  for (const [skillName, origin] of Object.entries(origins)) {
@@ -2754,6 +3095,7 @@ function makeRoutes(deps) {
2754
3095
  if (list === void 0) byCollection.set(origin, [skillName]);
2755
3096
  else list.push(skillName);
2756
3097
  }
3098
+ const orderIndex = new Map(collectionOrder.map((name, i) => [name, i]));
2757
3099
  writeJson(res, 200, {
2758
3100
  ok: true,
2759
3101
  sources,
@@ -2761,7 +3103,12 @@ function makeRoutes(deps) {
2761
3103
  collections: [...byCollection.entries()].map(([name, skillNames]) => ({
2762
3104
  name,
2763
3105
  skillNames: [...skillNames].sort((a, b) => a.localeCompare(b))
2764
- })).sort((a, b) => a.name.localeCompare(b.name)),
3106
+ })).sort((a, b) => {
3107
+ const ai = orderIndex.has(a.name) ? orderIndex.get(a.name) : Infinity;
3108
+ const bi = orderIndex.has(b.name) ? orderIndex.get(b.name) : Infinity;
3109
+ if (ai !== bi) return ai - bi;
3110
+ return a.name.localeCompare(b.name);
3111
+ }),
2765
3112
  trash
2766
3113
  });
2767
3114
  }
@@ -3273,7 +3620,7 @@ const Config = z.object({
3273
3620
  * page edit this namespace through the official settings transport, and the
3274
3621
  * plugin consumes the same resolved value — one source of truth.
3275
3622
  */
3276
- const CONFIG_NAMESPACE = settingsNamespace("dsh-skill-hub");
3623
+ const CONFIG_NAMESPACE = "dsh-skill-hub";
3277
3624
  /** Schema of the hub's settings namespace: the card's fields (booleans + optional dot colors). */
3278
3625
  const HubSettingsSchema = z.object({
3279
3626
  enabled: z.boolean().default(HUB_CONFIG_DEFAULTS.enabled),
@@ -3306,7 +3653,7 @@ function apply(ctx, config) {
3306
3653
  let providerControl;
3307
3654
  let stats;
3308
3655
  const saved = () => {
3309
- return ctx.settings.describe().find((entry) => entry.ns === CONFIG_NAMESPACE)?.user ?? {};
3656
+ return ctx.settings.describe().find((entry) => entry.ns === "dsh-skill-hub")?.user ?? {};
3310
3657
  };
3311
3658
  const updateConfig = async (patch) => {
3312
3659
  const user = { ...saved() };
@@ -3362,6 +3709,16 @@ function apply(ctx, config) {
3362
3709
  ctx.effect(() => settingsScope.watch(() => {
3363
3710
  sync();
3364
3711
  }), "dsh-skill-hub: settings config watch");
3712
+ (async () => {
3713
+ const home = dshHome();
3714
+ const agentsHome = process.env.DSH_AGENTS_HOME ?? join(homedir(), ".agents");
3715
+ for (const root of [join(home, "skills"), join(agentsHome, "skills")]) try {
3716
+ const c = await cleanupLeftoverImportDirs(root);
3717
+ if (c > 0) ctx.logger.info(`[dsh-skill-hub] startup cleaned ${c} leftover import temp dir(s) in ${root}`);
3718
+ } catch (error) {
3719
+ ctx.logger.warn("[dsh-skill-hub] startup cleanup failed", error);
3720
+ }
3721
+ })();
3365
3722
  (async () => {
3366
3723
  const legacy = await store.getConfig();
3367
3724
  if (Object.keys(legacy).length > 0 && Object.keys(saved()).length === 0) try {