dsh-skill-hub 0.2.5 → 0.3.0

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