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/README.md +3 -1
- package/README.zh.md +3 -1
- package/lib/client.js +1276 -412
- package/lib/client.js.map +1 -1
- package/lib/index.js +441 -81
- package/lib/types/client/SkillHubSettingsCard.d.ts +1 -1
- package/lib/types/client/api.d.ts +12 -2
- package/lib/types/client/locales.d.ts +14 -1
- package/lib/types/client/panel/ScenesView.d.ts +1 -1
- package/lib/types/client/panel/SourcesView.d.ts +1 -1
- package/lib/types/client/panel/useSkillHub.d.ts +20 -2
- package/lib/types/client/settings-card.d.ts +2 -0
- package/lib/types/protocol.d.ts +95 -6
- package/lib/types/repo.d.ts +18 -11
- package/lib/types/store.d.ts +14 -2
- package/package.json +1 -1
- package/src/client/SkillHubSettingsCard.tsx +12 -1
- package/src/client/api.ts +61 -1
- package/src/client/locales.ts +27 -1
- package/src/client/panel/MarketView.tsx +201 -47
- package/src/client/panel/ScenesView.tsx +27 -2
- package/src/client/panel/SkillHubPanel.tsx +18 -19
- package/src/client/panel/SkillRow.tsx +8 -8
- package/src/client/panel/SourcesView.tsx +203 -26
- package/src/client/panel/panel.module.css +17 -6
- package/src/client/panel/useSkillHub.ts +219 -12
- package/src/client/settings-card.module.css +1 -0
- package/src/client/settings-card.tsx +3 -1
- package/src/index.ts +18 -0
- package/src/protocol.ts +91 -6
- package/src/repo.test.ts +61 -19
- package/src/repo.ts +86 -23
- package/src/routes.test.ts +19 -4
- package/src/routes.ts +271 -49
- package/src/skillfs.ts +17 -4
- package/src/store.ts +72 -4
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:
|
|
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 !== "" &&
|
|
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 =
|
|
762
|
+
const normalized = resolve(path);
|
|
702
763
|
for (const root of WRITABLE_ROOTS) {
|
|
703
|
-
const base = rootPath(root, home);
|
|
704
|
-
if (
|
|
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.
|
|
1106
|
-
* `
|
|
1107
|
-
* skill
|
|
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
|
|
1244
|
-
if (
|
|
1245
|
-
|
|
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, {
|
|
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, {
|
|
1305
|
-
|
|
1306
|
-
|
|
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
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
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
|
-
})
|
|
1361
|
-
})
|
|
1362
|
-
|
|
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([
|
|
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) =>
|
|
1844
|
-
|
|
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
|
-
|
|
2108
|
-
|
|
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
|
-
|
|
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
|
|
2614
|
-
const
|
|
2615
|
-
const
|
|
2616
|
-
const
|
|
2617
|
-
|
|
2618
|
-
|
|
2619
|
-
|
|
2620
|
-
|
|
2621
|
-
|
|
2622
|
-
|
|
2623
|
-
|
|
2624
|
-
|
|
2625
|
-
|
|
2626
|
-
|
|
2627
|
-
|
|
2628
|
-
|
|
2629
|
-
|
|
2630
|
-
|
|
2631
|
-
|
|
2632
|
-
|
|
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
|
-
|
|
2635
|
-
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
|
|
2639
|
-
|
|
2640
|
-
|
|
2641
|
-
|
|
2642
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2656
|
-
|
|
2657
|
-
|
|
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) =>
|
|
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 {
|