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.
- package/README.md +68 -229
- package/README.zh.md +67 -203
- package/lib/client.js +1955 -413
- package/lib/client.js.map +1 -1
- package/lib/index.js +441 -84
- package/lib/types/client/SkillHubSettingsCard.d.ts +1 -1
- package/lib/types/client/api.d.ts +12 -2
- package/lib/types/client/index.d.ts +6 -1
- package/lib/types/client/locales.d.ts +13 -0
- 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-form.d.ts +1 -1
- package/lib/types/client/slash-dots.d.ts +2 -1
- package/lib/types/index.d.ts +2 -1
- package/lib/types/protocol.d.ts +93 -6
- package/lib/types/repo.d.ts +18 -11
- package/lib/types/store.d.ts +14 -2
- package/package.json +38 -29
- package/src/client/SkillHubSettingsCard.tsx +1 -1
- package/src/client/api.ts +61 -1
- package/src/client/index.tsx +10 -1
- package/src/client/locales.ts +26 -0
- 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-form.ts +2 -1
- package/src/client/slash-dots.test.ts +24 -6
- package/src/client/slash-dots.tsx +10 -1
- package/src/index.ts +20 -2
- package/src/protocol.ts +89 -6
- package/src/repo.test.ts +61 -19
- package/src/repo.ts +86 -23
- package/src/routes.test.ts +18 -4
- package/src/routes.ts +269 -47
- package/src/skillfs.ts +17 -4
- 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:
|
|
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 !== "" &&
|
|
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 =
|
|
761
|
+
const normalized = resolve(path);
|
|
702
762
|
for (const root of WRITABLE_ROOTS) {
|
|
703
|
-
const base = rootPath(root, home);
|
|
704
|
-
if (
|
|
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.
|
|
1106
|
-
* `
|
|
1107
|
-
* skill
|
|
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
|
|
1244
|
-
if (
|
|
1245
|
-
|
|
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, {
|
|
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, {
|
|
1305
|
-
|
|
1306
|
-
|
|
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
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
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
|
-
})
|
|
1361
|
-
})
|
|
1362
|
-
|
|
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([
|
|
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) =>
|
|
1844
|
-
|
|
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
|
-
|
|
2108
|
-
|
|
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
|
-
|
|
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
|
|
2616
|
-
const
|
|
2617
|
-
const
|
|
2618
|
-
const
|
|
2619
|
-
|
|
2620
|
-
|
|
2621
|
-
|
|
2622
|
-
|
|
2623
|
-
|
|
2624
|
-
|
|
2625
|
-
|
|
2626
|
-
|
|
2627
|
-
|
|
2628
|
-
|
|
2629
|
-
|
|
2630
|
-
|
|
2631
|
-
|
|
2632
|
-
|
|
2633
|
-
|
|
2634
|
-
|
|
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
|
-
|
|
2637
|
-
|
|
2638
|
-
|
|
2639
|
-
|
|
2640
|
-
|
|
2641
|
-
|
|
2642
|
-
|
|
2643
|
-
|
|
2644
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2658
|
-
|
|
2659
|
-
|
|
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) =>
|
|
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 =
|
|
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 ===
|
|
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 {
|