dsh-skill-hub 0.0.1 → 0.2.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.
Files changed (88) hide show
  1. package/CONTRIBUTING.md +56 -0
  2. package/README.md +355 -20
  3. package/cordis.patch.yml +12 -0
  4. package/lib/client.js +4514 -0
  5. package/lib/client.js.map +1 -0
  6. package/lib/index.js +3119 -0
  7. package/lib/invariant.js +6 -0
  8. package/lib/types/client/SkillHubSettingsCard.d.ts +53 -0
  9. package/lib/types/client/api-config-scope.d.ts +48 -0
  10. package/lib/types/client/api.d.ts +64 -0
  11. package/lib/types/client/grouping.d.ts +64 -0
  12. package/lib/types/client/helpers.d.ts +11 -0
  13. package/lib/types/client/index.d.ts +42 -0
  14. package/lib/types/client/locales.d.ts +216 -0
  15. package/lib/types/client/market-catalog.d.ts +14 -0
  16. package/lib/types/client/panel/DisabledRow.d.ts +12 -0
  17. package/lib/types/client/panel/GroupSummary.d.ts +10 -0
  18. package/lib/types/client/panel/MarketView.d.ts +11 -0
  19. package/lib/types/client/panel/ScenesView.d.ts +11 -0
  20. package/lib/types/client/panel/SkillDetailView.d.ts +31 -0
  21. package/lib/types/client/panel/SkillHubPanel.d.ts +18 -0
  22. package/lib/types/client/panel/SkillRow.d.ts +11 -0
  23. package/lib/types/client/panel/SourceStatusBadge.d.ts +18 -0
  24. package/lib/types/client/panel/SourcesView.d.ts +10 -0
  25. package/lib/types/client/panel/TagEditorView.d.ts +29 -0
  26. package/lib/types/client/panel/dialogs.d.ts +73 -0
  27. package/lib/types/client/panel/format.d.ts +14 -0
  28. package/lib/types/client/panel/useSkillHub.d.ts +183 -0
  29. package/lib/types/client/settings-card.d.ts +81 -0
  30. package/lib/types/client/settings-form.d.ts +95 -0
  31. package/lib/types/client/settings-nav-icon.d.ts +8 -0
  32. package/lib/types/index.d.ts +36 -0
  33. package/lib/types/invariant.d.ts +3 -0
  34. package/lib/types/protocol.d.ts +571 -0
  35. package/lib/types/provider.d.ts +47 -0
  36. package/lib/types/repo.d.ts +133 -0
  37. package/lib/types/routes.d.ts +51 -0
  38. package/lib/types/skillfs.d.ts +95 -0
  39. package/lib/types/stats.d.ts +49 -0
  40. package/lib/types/store.d.ts +142 -0
  41. package/lib/types/update.d.ts +20 -0
  42. package/package.json +115 -11
  43. package/src/client/SkillHubSettingsCard.tsx +161 -0
  44. package/src/client/api-config-scope.test.ts +79 -0
  45. package/src/client/api-config-scope.ts +119 -0
  46. package/src/client/api.ts +327 -0
  47. package/src/client/css-modules.d.ts +5 -0
  48. package/src/client/grouping.test.ts +109 -0
  49. package/src/client/grouping.ts +120 -0
  50. package/src/client/helpers.ts +23 -0
  51. package/src/client/index.tsx +104 -0
  52. package/src/client/locales.ts +432 -0
  53. package/src/client/market-catalog.ts +22 -0
  54. package/src/client/panel/DisabledRow.tsx +32 -0
  55. package/src/client/panel/GroupSummary.tsx +27 -0
  56. package/src/client/panel/MarketView.tsx +173 -0
  57. package/src/client/panel/ScenesView.tsx +75 -0
  58. package/src/client/panel/SkillDetailView.tsx +95 -0
  59. package/src/client/panel/SkillHubPanel.tsx +347 -0
  60. package/src/client/panel/SkillRow.tsx +54 -0
  61. package/src/client/panel/SourceStatusBadge.tsx +61 -0
  62. package/src/client/panel/SourcesView.tsx +104 -0
  63. package/src/client/panel/TagEditorView.tsx +89 -0
  64. package/src/client/panel/dialogs.tsx +185 -0
  65. package/src/client/panel/format.ts +31 -0
  66. package/src/client/panel/panel.module.css +219 -0
  67. package/src/client/panel/useSkillHub.ts +797 -0
  68. package/src/client/settings-card.module.css +52 -0
  69. package/src/client/settings-card.tsx +237 -0
  70. package/src/client/settings-form.ts +227 -0
  71. package/src/client/settings-nav-icon.ts +42 -0
  72. package/src/index.ts +181 -0
  73. package/src/invariant.ts +4 -0
  74. package/src/protocol.ts +622 -0
  75. package/src/provider.test.ts +112 -0
  76. package/src/provider.ts +168 -0
  77. package/src/repo.test.ts +212 -0
  78. package/src/repo.ts +528 -0
  79. package/src/routes.test.ts +786 -0
  80. package/src/routes.ts +1252 -0
  81. package/src/skillfs.test.ts +290 -0
  82. package/src/skillfs.ts +306 -0
  83. package/src/stats.test.ts +168 -0
  84. package/src/stats.ts +124 -0
  85. package/src/store.test.ts +253 -0
  86. package/src/store.ts +628 -0
  87. package/src/update.test.ts +73 -0
  88. package/src/update.ts +136 -0
package/lib/index.js ADDED
@@ -0,0 +1,3119 @@
1
+ import { createRequire } from "node:module";
2
+ import z from "schemastery";
3
+ import { mkdir, mkdtemp, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
4
+ import { basename, dirname, join, resolve } from "node:path";
5
+ import { homedir } from "node:os";
6
+ import { dump, load } from "js-yaml";
7
+ import { isSkillName } from "@deepseek-ai/dsh-skill";
8
+ //#region src/protocol.ts
9
+ /**
10
+ * Shared API contract for dsh-skill-hub: the route paths and payload shapes
11
+ * both the host half and the browser half import. The browser half must never
12
+ * depend on host SDK packages, so registry types are re-spelled here as
13
+ * plain JSON-safe interfaces.
14
+ */
15
+ /** Browser-facing base paths of the skill-hub API family. */
16
+ const SKILL_HUB_API = {
17
+ catalog: "/api/skill-hub/catalog",
18
+ skill: "/api/skill-hub/skill",
19
+ skillDelete: "/api/skill-hub/skill/delete",
20
+ toggle: "/api/skill-hub/toggle",
21
+ toggleBatch: "/api/skill-hub/toggle-batch",
22
+ create: "/api/skill-hub/create",
23
+ stats: "/api/skill-hub/stats",
24
+ config: "/api/skill-hub/config",
25
+ market: "/api/skill-hub/market",
26
+ marketSource: "/api/skill-hub/market/source",
27
+ marketSourceDelete: "/api/skill-hub/market/source/delete",
28
+ marketSourceRef: "/api/skill-hub/market/source/ref",
29
+ marketCheck: "/api/skill-hub/market/check",
30
+ marketSync: "/api/skill-hub/market/source/sync",
31
+ repo: "/api/skill-hub/repo",
32
+ repoImport: "/api/skill-hub/repo/import",
33
+ update: "/api/skill-hub/update",
34
+ groups: "/api/skill-hub/groups",
35
+ tag: "/api/skill-hub/tag",
36
+ tagDelete: "/api/skill-hub/tag/delete",
37
+ tagMembers: "/api/skill-hub/tag/members",
38
+ sources: "/api/skill-hub/sources",
39
+ sourceCheck: "/api/skill-hub/sources/check",
40
+ sourceSync: "/api/skill-hub/sources/sync",
41
+ sourceDelete: "/api/skill-hub/sources/delete",
42
+ sourceRestore: "/api/skill-hub/sources/restore",
43
+ sourceTrashClear: "/api/skill-hub/sources/trash/clear"
44
+ };
45
+ /**
46
+ * Hub config defaults — the single source every layer reads: the cordis
47
+ * schema (index.ts), the host's saved-override merge, and the routes'
48
+ * fallback view. Changing a default here changes all three.
49
+ */
50
+ const HUB_CONFIG_DEFAULTS = {
51
+ enabled: true,
52
+ announceToAgent: true,
53
+ showUseCount: true,
54
+ showUseTime: true,
55
+ showGroupSummary: true
56
+ };
57
+ /**
58
+ * Resolve the effective hub config: saved sidecar overrides win over the
59
+ * cordis composition entry (the web card owns runtime config), missing
60
+ * booleans fall back to HUB_CONFIG_DEFAULTS, and dot colors pass through
61
+ * (saved first, then base) only when set.
62
+ */
63
+ function resolveHubConfig(saved, base = {}) {
64
+ const dotModelColor = saved.dotModelColor !== void 0 ? saved.dotModelColor : base.dotModelColor;
65
+ const dotUserColor = saved.dotUserColor !== void 0 ? saved.dotUserColor : base.dotUserColor;
66
+ return {
67
+ enabled: saved.enabled ?? base.enabled ?? HUB_CONFIG_DEFAULTS.enabled,
68
+ announceToAgent: saved.announceToAgent ?? base.announceToAgent ?? HUB_CONFIG_DEFAULTS.announceToAgent,
69
+ showUseCount: saved.showUseCount ?? base.showUseCount ?? HUB_CONFIG_DEFAULTS.showUseCount,
70
+ showUseTime: saved.showUseTime ?? base.showUseTime ?? HUB_CONFIG_DEFAULTS.showUseTime,
71
+ showGroupSummary: saved.showGroupSummary ?? base.showGroupSummary ?? HUB_CONFIG_DEFAULTS.showGroupSummary,
72
+ ...dotModelColor !== void 0 ? { dotModelColor } : {},
73
+ ...dotUserColor !== void 0 ? { dotUserColor } : {}
74
+ };
75
+ }
76
+ /** HEX color validation shared by host routes and the settings card. */
77
+ const HEX_COLOR_RE = /^#[0-9a-f]{6}$/i;
78
+ //#endregion
79
+ //#region src/store.ts
80
+ /**
81
+ * Hub sidecar store: remembers which skills the hub toggled off and the
82
+ * user's organization/tracking records. Disabling renames the skill's
83
+ * SKILL.md (or flat .md) out of the filesystem provider's discovery shapes,
84
+ * so the provider catalog alone cannot see disabled skills; this store keeps
85
+ * name/path/root so the GUI can list them and re-enable. It also persists
86
+ * user tag groups, upstream source records (repo + commit snapshot for
87
+ * update checks), the market source list, and the trash (skills removed
88
+ * after upstream deletion).
89
+ *
90
+ * State file: $DSH_HOME/dsh-skill-hub.json — a small JSON document written
91
+ * atomically (tmp file + rename).
92
+ */
93
+ /** 默认场景名(系统预置的兜底场景,新技能自动归入)。 */
94
+ const DEFAULT_SCENE_NAME = "通用";
95
+ /** Resolve the DSH home directory (the filesystem provider's user-dsh root base). */
96
+ function dshHome() {
97
+ return process.env.DSH_HOME ?? join(homedir(), ".dsh");
98
+ }
99
+ /** Resolve the sidecar state path (injectable in tests). */
100
+ function statePath(home = dshHome()) {
101
+ return join(home, "dsh-skill-hub.json");
102
+ }
103
+ /**
104
+ * Business-rule failure the routes layer can map onto a 4xx status instead
105
+ * of a blanket 500: user input is invalid (validation → 400), the target
106
+ * does not exist (not-found → 404), or the operation conflicts with an
107
+ * invariant (conflict → 409).
108
+ */
109
+ var StoreError = class extends Error {
110
+ kind;
111
+ constructor(kind, message) {
112
+ super(message);
113
+ this.name = "StoreError";
114
+ this.kind = kind;
115
+ }
116
+ };
117
+ /**
118
+ * Normalize an arbitrary parsed sidecar document to the current schema.
119
+ * v1 to v2: scenes are dropped (the one-click role merged into group
120
+ * switches), and origins are migrated into source records (repo = origin
121
+ * value, commit snapshot empty — the first update check backfills it).
122
+ * v2 to v3: market sources become records ({ repo, ref?, commitSha? })
123
+ * instead of bare repo slugs, so a source can pin a release/branch version.
124
+ * Returns null when the file claims a newer schema than this plugin
125
+ * understands, so the caller starts empty instead of risking data loss.
126
+ */
127
+ function migrateStore(parsed) {
128
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
129
+ const record = parsed;
130
+ const version = typeof record.version === "number" ? record.version : 0;
131
+ if (version > 3) return null;
132
+ const disabled = Array.isArray(record.disabled) ? record.disabled : [];
133
+ const config = typeof record.config === "object" && record.config !== null && !Array.isArray(record.config) ? record.config : void 0;
134
+ const tags = Array.isArray(record.tags) ? record.tags : void 0;
135
+ const trash = Array.isArray(record.trash) ? record.trash : void 0;
136
+ const marketSources = Array.isArray(record.marketSources) ? record.marketSources.map((entry) => typeof entry === "string" ? { repo: entry } : entry) : void 0;
137
+ let sources;
138
+ if (Array.isArray(record.sources)) sources = record.sources;
139
+ else if (version < 2 && typeof record.origins === "object" && record.origins !== null && !Array.isArray(record.origins)) {
140
+ const byOrigin = /* @__PURE__ */ new Map();
141
+ for (const [name, origin] of Object.entries(record.origins)) {
142
+ if (typeof origin !== "string" || origin === "") continue;
143
+ const list = byOrigin.get(origin);
144
+ if (list === void 0) byOrigin.set(origin, [name]);
145
+ else list.push(name);
146
+ }
147
+ sources = [...byOrigin.entries()].map(([repo, skillNames]) => ({
148
+ repo,
149
+ root: "skills",
150
+ commitSha: "",
151
+ skills: skillNames.sort((a, b) => a.localeCompare(b))
152
+ }));
153
+ }
154
+ return {
155
+ version: 3,
156
+ disabled,
157
+ ...config !== void 0 ? { config } : {},
158
+ ...tags !== void 0 ? { tags } : {},
159
+ ...sources !== void 0 ? { sources } : {},
160
+ ...marketSources !== void 0 ? { marketSources } : {},
161
+ ...trash !== void 0 ? { trash } : {}
162
+ };
163
+ }
164
+ /** Sidecar state owner. */
165
+ var SkillHubStore = class {
166
+ file;
167
+ entries = /* @__PURE__ */ new Map();
168
+ config = {};
169
+ tagsById = /* @__PURE__ */ new Map();
170
+ sourcesByRepo = /* @__PURE__ */ new Map();
171
+ marketSources = [];
172
+ trashByName = /* @__PURE__ */ new Map();
173
+ loaded = false;
174
+ /** Serializes persist runs: concurrent mutators must not let an earlier
175
+ * snapshot overwrite a later one (rename is atomic, ordering is not). */
176
+ writeChain = Promise.resolve();
177
+ constructor(file = statePath()) {
178
+ this.file = file;
179
+ }
180
+ async ensureLoaded() {
181
+ if (this.loaded) return;
182
+ this.loaded = true;
183
+ try {
184
+ const raw = await readFile(this.file, "utf8");
185
+ const migrated = migrateStore(JSON.parse(raw));
186
+ if (migrated === null) console.warn("[dsh-skill-hub] sidecar state uses a newer schema than this plugin supports; starting empty");
187
+ else {
188
+ if (Array.isArray(migrated.disabled)) {
189
+ for (const entry of migrated.disabled) if (typeof entry?.name === "string" && typeof entry?.path === "string") this.entries.set(entry.name, entry);
190
+ }
191
+ const saved = migrated.config;
192
+ if (typeof saved === "object" && saved !== null) {
193
+ if (typeof saved.enabled === "boolean") this.config.enabled = saved.enabled;
194
+ if (typeof saved.announceToAgent === "boolean") this.config.announceToAgent = saved.announceToAgent;
195
+ if (typeof saved.dotModelColor === "string" && saved.dotModelColor !== "") this.config.dotModelColor = saved.dotModelColor;
196
+ if (typeof saved.dotUserColor === "string" && saved.dotUserColor !== "") this.config.dotUserColor = saved.dotUserColor;
197
+ if (typeof saved.showUseCount === "boolean") this.config.showUseCount = saved.showUseCount;
198
+ if (typeof saved.showUseTime === "boolean") this.config.showUseTime = saved.showUseTime;
199
+ if (typeof saved.showGroupSummary === "boolean") this.config.showGroupSummary = saved.showGroupSummary;
200
+ }
201
+ if (Array.isArray(migrated.tags)) for (const entry of migrated.tags) {
202
+ const tag = entry;
203
+ if (tag !== null && typeof tag === "object" && typeof tag.id === "string" && typeof tag.name === "string" && Array.isArray(tag.skillNames)) this.tagsById.set(tag.id, {
204
+ id: tag.id,
205
+ name: tag.name,
206
+ skillNames: tag.skillNames.filter((n) => typeof n === "string"),
207
+ ...tag.default === true ? { default: true } : {}
208
+ });
209
+ }
210
+ if (Array.isArray(migrated.sources)) for (const entry of migrated.sources) {
211
+ const source = entry;
212
+ if (source !== null && typeof source === "object" && typeof source.repo === "string" && source.repo !== "" && Array.isArray(source.skills)) {
213
+ const manifest = source.manifest;
214
+ this.sourcesByRepo.set(source.repo, {
215
+ repo: source.repo,
216
+ ...typeof source.ref === "string" && source.ref !== "" ? { ref: source.ref } : {},
217
+ root: source.root === "design-templates" ? "design-templates" : "skills",
218
+ commitSha: typeof source.commitSha === "string" ? source.commitSha : "",
219
+ skills: source.skills.filter((n) => typeof n === "string"),
220
+ ...manifest !== null && typeof manifest === "object" && !Array.isArray(manifest) ? { manifest: Object.fromEntries(Object.entries(manifest).filter(([, size]) => typeof size === "number")) } : {}
221
+ });
222
+ }
223
+ }
224
+ if (Array.isArray(migrated.marketSources)) for (const entry of migrated.marketSources) {
225
+ const item = entry;
226
+ if (item !== null && typeof item === "object" && typeof item.repo === "string" && item.repo !== "" && !this.marketSources.some((s) => s.repo === item.repo)) this.marketSources.push({
227
+ repo: item.repo,
228
+ ...typeof item.ref === "string" && item.ref !== "" ? { ref: item.ref } : {},
229
+ ...typeof item.commitSha === "string" && item.commitSha !== "" ? { commitSha: item.commitSha } : {}
230
+ });
231
+ }
232
+ if (Array.isArray(migrated.trash)) for (const entry of migrated.trash) {
233
+ const item = entry;
234
+ if (item !== null && typeof item === "object" && typeof item.name === "string" && typeof item.path === "string") {
235
+ const origin = item.origin;
236
+ const tagIds = item.tagIds;
237
+ this.trashByName.set(item.name, {
238
+ name: item.name,
239
+ path: item.path,
240
+ movedAt: typeof item.movedAt === "number" ? item.movedAt : 0,
241
+ ...typeof item.sourcePath === "string" && item.sourcePath !== "" ? { sourcePath: item.sourcePath } : {},
242
+ ...origin !== null && typeof origin === "object" && typeof origin.repo === "string" && origin.repo !== "" && (origin.root === "skills" || origin.root === "design-templates") ? { origin: {
243
+ repo: origin.repo,
244
+ root: origin.root,
245
+ ...typeof origin.ref === "string" && origin.ref !== "" ? { ref: origin.ref } : {},
246
+ commitSha: typeof origin.commitSha === "string" ? origin.commitSha : ""
247
+ } } : {},
248
+ ...Array.isArray(tagIds) ? { tagIds: tagIds.filter((id) => typeof id === "string") } : {}
249
+ });
250
+ }
251
+ }
252
+ }
253
+ } catch (error) {
254
+ if (error.code !== "ENOENT") console.warn("[dsh-skill-hub] sidecar state unreadable, starting empty:", error instanceof Error ? error.message : error);
255
+ }
256
+ await this.ensureDefaultTag();
257
+ }
258
+ /**
259
+ * 保证存在默认场景:没有任何 default 标记的 tag 时创建「通用」。
260
+ * 新技能创建后自动归入它;用户可改名,但默认场景不可删除。
261
+ */
262
+ async ensureDefaultTag() {
263
+ if ([...this.tagsById.values()].some((tag) => tag.default === true)) return;
264
+ const tag = {
265
+ id: crypto.randomUUID(),
266
+ name: DEFAULT_SCENE_NAME,
267
+ skillNames: [],
268
+ default: true
269
+ };
270
+ this.tagsById.set(tag.id, tag);
271
+ await this.persist();
272
+ }
273
+ async listDisabled() {
274
+ await this.ensureLoaded();
275
+ return [...this.entries.values()].sort((a, b) => a.name.localeCompare(b.name));
276
+ }
277
+ async getDisabled(name) {
278
+ await this.ensureLoaded();
279
+ return this.entries.get(name);
280
+ }
281
+ async addDisabled(entry) {
282
+ await this.ensureLoaded();
283
+ this.entries.set(entry.name, entry);
284
+ await this.persist();
285
+ }
286
+ async removeDisabled(name) {
287
+ await this.ensureLoaded();
288
+ if (!this.entries.delete(name)) return;
289
+ await this.persist();
290
+ }
291
+ /** The hub's saved runtime configuration (fields absent here mean "not overridden"). */
292
+ async getConfig() {
293
+ await this.ensureLoaded();
294
+ return { ...this.config };
295
+ }
296
+ /**
297
+ * Persist a runtime-config patch, merged over the saved values. A field
298
+ * whose patch value is undefined is removed from the saved layer, so the
299
+ * setting re-inherits its default (the web card's "reset" path).
300
+ */
301
+ async setConfig(config) {
302
+ await this.ensureLoaded();
303
+ const next = { ...this.config };
304
+ for (const [key, value] of Object.entries(config)) if (value === void 0) delete next[key];
305
+ else next[key] = value;
306
+ this.config = next;
307
+ await this.persist();
308
+ }
309
+ /** All user-defined tag groups, in creation order. */
310
+ async listTags() {
311
+ await this.ensureLoaded();
312
+ return [...this.tagsById.values()];
313
+ }
314
+ /** One tag by id (undefined when absent). */
315
+ async getTag(id) {
316
+ await this.ensureLoaded();
317
+ return this.tagsById.get(id);
318
+ }
319
+ /**
320
+ * Create (no id) or rename (with id) a tag. Returns the saved tag.
321
+ * Creating assigns a fresh UUID; renaming keeps members.
322
+ */
323
+ async saveTag(input) {
324
+ await this.ensureLoaded();
325
+ const name = input.name.trim();
326
+ if (name === "") throw new StoreError("validation", "tag name must not be empty");
327
+ let tag;
328
+ if (input.id !== void 0) {
329
+ const existing = this.tagsById.get(input.id);
330
+ if (existing === void 0) throw new StoreError("not-found", "tag not found: " + input.id);
331
+ tag = {
332
+ ...existing,
333
+ name
334
+ };
335
+ } else tag = {
336
+ id: crypto.randomUUID(),
337
+ name,
338
+ skillNames: []
339
+ };
340
+ this.tagsById.set(tag.id, tag);
341
+ await this.persist();
342
+ return tag;
343
+ }
344
+ /** Delete a tag by id (no-op when absent). The default scene cannot be deleted. */
345
+ async deleteTag(id) {
346
+ await this.ensureLoaded();
347
+ if (this.tagsById.get(id)?.default === true) throw new StoreError("conflict", "the default scene cannot be deleted");
348
+ if (!this.tagsById.delete(id)) return;
349
+ await this.persist();
350
+ }
351
+ /** The default scene (「通用」), guaranteed to exist after ensureLoaded. */
352
+ async getDefaultTag() {
353
+ await this.ensureLoaded();
354
+ return [...this.tagsById.values()].find((tag) => tag.default === true);
355
+ }
356
+ /** Append one skill name to a tag (deduplicated; no-op when already a member). */
357
+ async addSkillToTag(id, name) {
358
+ await this.ensureLoaded();
359
+ const existing = this.tagsById.get(id);
360
+ if (existing === void 0 || name.trim() === "" || existing.skillNames.includes(name)) return existing;
361
+ const tag = {
362
+ ...existing,
363
+ skillNames: [...existing.skillNames, name]
364
+ };
365
+ this.tagsById.set(id, tag);
366
+ await this.persist();
367
+ return tag;
368
+ }
369
+ /**
370
+ * Replace a tag's member list wholesale (idempotent). Deduplicates and
371
+ * drops blank names; unknown skill names are kept (they may arrive later),
372
+ * the routes layer filters against the live catalog before persisting.
373
+ */
374
+ async setTagMembers(id, skillNames) {
375
+ await this.ensureLoaded();
376
+ const existing = this.tagsById.get(id);
377
+ if (existing === void 0) return void 0;
378
+ const names = [...new Set(skillNames.filter((n) => n.trim() !== ""))];
379
+ const tag = {
380
+ ...existing,
381
+ skillNames: names
382
+ };
383
+ this.tagsById.set(id, tag);
384
+ await this.persist();
385
+ return tag;
386
+ }
387
+ /** Remove one skill from every tag group (used when the skill is deleted). */
388
+ async removeSkillFromTags(name) {
389
+ await this.ensureLoaded();
390
+ let changed = false;
391
+ for (const tag of this.tagsById.values()) {
392
+ if (!tag.skillNames.includes(name)) continue;
393
+ tag.skillNames = tag.skillNames.filter((n) => n !== name);
394
+ changed = true;
395
+ }
396
+ if (changed) await this.persist();
397
+ }
398
+ /** All source records, sorted by repo. */
399
+ async listSources() {
400
+ await this.ensureLoaded();
401
+ return [...this.sourcesByRepo.values()].sort((a, b) => a.repo.localeCompare(b.repo));
402
+ }
403
+ /** One source record by repo (undefined when absent). */
404
+ async getSource(repo) {
405
+ await this.ensureLoaded();
406
+ return this.sourcesByRepo.get(repo);
407
+ }
408
+ /** The source record that tracks a skill name (undefined when untracked). */
409
+ async getSourceForSkill(name) {
410
+ await this.ensureLoaded();
411
+ for (const source of this.sourcesByRepo.values()) if (source.skills.includes(name)) return source;
412
+ }
413
+ /** Remove a source record entirely (no-op when absent). */
414
+ async deleteSource(repo) {
415
+ await this.ensureLoaded();
416
+ if (!this.sourcesByRepo.delete(repo)) return;
417
+ await this.persist();
418
+ }
419
+ /**
420
+ * Upsert one skill into a source record. When the repo has no record yet a
421
+ * new one is created (root + commit snapshot from the caller).
422
+ */
423
+ async addSourceSkill(repo, root, commitSha, ref, skillName) {
424
+ await this.ensureLoaded();
425
+ const existing = this.sourcesByRepo.get(repo);
426
+ if (existing === void 0) this.sourcesByRepo.set(repo, {
427
+ repo,
428
+ ...ref !== void 0 && ref !== "" ? { ref } : {},
429
+ root,
430
+ commitSha,
431
+ skills: [skillName]
432
+ });
433
+ else {
434
+ const skills = existing.skills.includes(skillName) ? existing.skills : [...existing.skills, skillName].sort((a, b) => a.localeCompare(b));
435
+ this.sourcesByRepo.set(repo, {
436
+ ...existing,
437
+ skills
438
+ });
439
+ }
440
+ await this.persist();
441
+ }
442
+ /** Replace a source's skill list (used after sync/confirm-delete). */
443
+ async setSourceSkills(repo, skills) {
444
+ await this.ensureLoaded();
445
+ const existing = this.sourcesByRepo.get(repo);
446
+ if (existing === void 0) return void 0;
447
+ const names = [...new Set(skills.filter((n) => n.trim() !== ""))].sort((a, b) => a.localeCompare(b));
448
+ if (names.length === 0) this.sourcesByRepo.delete(repo);
449
+ else this.sourcesByRepo.set(repo, {
450
+ ...existing,
451
+ skills: names
452
+ });
453
+ await this.persist();
454
+ return this.sourcesByRepo.get(repo);
455
+ }
456
+ /** Remove one skill from every source record (used when the skill is deleted). */
457
+ async removeSkillFromSources(name) {
458
+ await this.ensureLoaded();
459
+ let changed = false;
460
+ for (const [repo, source] of this.sourcesByRepo) {
461
+ if (!source.skills.includes(name)) continue;
462
+ const skills = source.skills.filter((n) => n !== name);
463
+ if (skills.length === 0) this.sourcesByRepo.delete(repo);
464
+ else this.sourcesByRepo.set(repo, {
465
+ ...source,
466
+ skills
467
+ });
468
+ changed = true;
469
+ }
470
+ if (changed) await this.persist();
471
+ }
472
+ /** Update a source's commit snapshot. */
473
+ async setSourceCommit(repo, commitSha) {
474
+ await this.ensureLoaded();
475
+ const existing = this.sourcesByRepo.get(repo);
476
+ if (existing === void 0) return;
477
+ this.sourcesByRepo.set(repo, {
478
+ ...existing,
479
+ commitSha
480
+ });
481
+ await this.persist();
482
+ }
483
+ /** Update a source's pinned ref (release tag / branch) when the market syncs. */
484
+ async setSourceRef(repo, ref) {
485
+ await this.ensureLoaded();
486
+ const existing = this.sourcesByRepo.get(repo);
487
+ if (existing === void 0 || ref.trim() === "") return;
488
+ this.sourcesByRepo.set(repo, {
489
+ ...existing,
490
+ ref: ref.trim()
491
+ });
492
+ await this.persist();
493
+ }
494
+ /**
495
+ * Merge per-path manifest entries into a source (incremental imports).
496
+ * When `dir` is given, every baseline path under that skill directory is
497
+ * dropped first, so files the upstream removed never linger in the
498
+ * baseline and skew later update diffs.
499
+ */
500
+ async mergeSourceManifest(repo, manifest, dir) {
501
+ await this.ensureLoaded();
502
+ const existing = this.sourcesByRepo.get(repo);
503
+ if (existing === void 0 || Object.keys(manifest).length === 0) return;
504
+ const base = { ...existing.manifest ?? {} };
505
+ if (dir !== void 0 && dir !== "") {
506
+ const prefix = dir + "/";
507
+ for (const path of Object.keys(base)) if (path.startsWith(prefix)) delete base[path];
508
+ }
509
+ this.sourcesByRepo.set(repo, {
510
+ ...existing,
511
+ manifest: {
512
+ ...base,
513
+ ...manifest
514
+ }
515
+ });
516
+ await this.persist();
517
+ }
518
+ /** skillName → collection name for every recorded origin (derived from sources). */
519
+ async listOrigins() {
520
+ await this.ensureLoaded();
521
+ const origins = {};
522
+ for (const source of this.sourcesByRepo.values()) for (const name of source.skills) origins[name] = source.repo;
523
+ return origins;
524
+ }
525
+ /** The user's market sources, in addition order. */
526
+ async listMarketSources() {
527
+ await this.ensureLoaded();
528
+ return [...this.marketSources];
529
+ }
530
+ /** One market source by repo (undefined when absent). */
531
+ async getMarketSource(repo) {
532
+ await this.ensureLoaded();
533
+ return this.marketSources.find((entry) => entry.repo === repo);
534
+ }
535
+ /** Add a repo (deduplicated), optionally with a pinned ref. Returns the fresh list. */
536
+ async addMarketSource(repo, ref) {
537
+ await this.ensureLoaded();
538
+ const existing = this.marketSources.find((entry) => entry.repo === repo);
539
+ if (existing === void 0) this.marketSources.push(ref !== void 0 && ref !== "" ? {
540
+ repo,
541
+ ref
542
+ } : { repo });
543
+ else if (ref !== void 0 && ref !== "" && existing.ref !== ref) existing.ref = ref;
544
+ await this.persist();
545
+ return [...this.marketSources];
546
+ }
547
+ /** Remove a repo (no-op when absent). Returns the fresh list. */
548
+ async removeMarketSource(repo) {
549
+ await this.ensureLoaded();
550
+ const index = this.marketSources.findIndex((entry) => entry.repo === repo);
551
+ if (index === -1) return [...this.marketSources];
552
+ this.marketSources.splice(index, 1);
553
+ await this.persist();
554
+ return [...this.marketSources];
555
+ }
556
+ /** Pin a market source to an explicit ref (branch/tag). Returns the record. */
557
+ async setMarketSourceRef(repo, ref) {
558
+ await this.ensureLoaded();
559
+ const entry = this.marketSources.find((item) => item.repo === repo);
560
+ if (entry === void 0 || ref.trim() === "") return entry;
561
+ entry.ref = ref.trim();
562
+ delete entry.commitSha;
563
+ await this.persist();
564
+ return entry;
565
+ }
566
+ /** Record the commit a market source's pinned ref resolved to (update baseline). */
567
+ async setMarketSourceCommit(repo, commitSha) {
568
+ await this.ensureLoaded();
569
+ const entry = this.marketSources.find((item) => item.repo === repo);
570
+ if (entry === void 0 || commitSha === "") return;
571
+ entry.commitSha = commitSha;
572
+ await this.persist();
573
+ }
574
+ /** All trashed skills, newest first. */
575
+ async listTrash() {
576
+ await this.ensureLoaded();
577
+ return [...this.trashByName.values()].sort((a, b) => b.movedAt - a.movedAt);
578
+ }
579
+ /** One trash entry by skill name (undefined when absent). */
580
+ async getTrash(name) {
581
+ await this.ensureLoaded();
582
+ return this.trashByName.get(name);
583
+ }
584
+ /** Record a trashed skill. */
585
+ async addTrash(entry) {
586
+ await this.ensureLoaded();
587
+ this.trashByName.set(entry.name, entry);
588
+ await this.persist();
589
+ }
590
+ /** Remove a trash record (after restore). */
591
+ async removeTrash(name) {
592
+ await this.ensureLoaded();
593
+ if (!this.trashByName.delete(name)) return;
594
+ await this.persist();
595
+ }
596
+ persist() {
597
+ const run = this.writeChain.then(async () => {
598
+ const payload = {
599
+ version: 3,
600
+ disabled: [...this.entries.values()],
601
+ config: this.config,
602
+ ...this.tagsById.size > 0 ? { tags: [...this.tagsById.values()] } : {},
603
+ ...this.sourcesByRepo.size > 0 ? { sources: [...this.sourcesByRepo.values()] } : {},
604
+ ...this.marketSources.length > 0 ? { marketSources: [...this.marketSources] } : {},
605
+ ...this.trashByName.size > 0 ? { trash: [...this.trashByName.values()] } : {}
606
+ };
607
+ const tmp = this.file + ".tmp";
608
+ await mkdir(dirname(this.file), { recursive: true });
609
+ await writeFile(tmp, JSON.stringify(payload, null, 2) + "\n", "utf8");
610
+ await rename(tmp, this.file);
611
+ });
612
+ this.writeChain = run.catch(() => {});
613
+ return run;
614
+ }
615
+ };
616
+ //#endregion
617
+ //#region src/skillfs.ts
618
+ /**
619
+ * Skill filesystem operations for the writable roots and the hub provider's
620
+ * discovery. Toggling works by renaming the discovery file (SKILL.md inside
621
+ * a directory bundle, or the flat <name>.md) to a .disabled name; the
622
+ * watcher (ours or the preset's official filesystem provider) then updates
623
+ * the live catalog by itself.
624
+ *
625
+ * Frontmatter parsing mirrors @deepseek-ai/dsh-skill-filesystem semantics:
626
+ * required name (kebab-case) + description, optional whenToUse, invocation
627
+ * booleans with the same defaults (modelInvocable defaults true,
628
+ * userInvocable defaults true), legacy keys rejected, and the returned
629
+ * content is the body after the frontmatter block, trimmed.
630
+ */
631
+ /** Root ids this module may write to. */
632
+ const WRITABLE_ROOTS = ["user-dsh", "user-agents"];
633
+ /** Resolve the absolute directory of one writable root. */
634
+ function rootPath(root, home = dshHome()) {
635
+ const agentsHome = process.env.DSH_AGENTS_HOME ?? join(homedir(), ".agents");
636
+ switch (root) {
637
+ case "user-dsh": return join(home, "skills");
638
+ case "user-agents": return join(agentsHome, "skills");
639
+ default: throw new TypeError("unknown root: " + String(root));
640
+ }
641
+ }
642
+ /** Root of an absolute skill file path, or undefined when not user-owned. */
643
+ function rootOfPath(path, home = dshHome()) {
644
+ const normalized = join(path);
645
+ for (const root of WRITABLE_ROOTS) {
646
+ const base = rootPath(root, home);
647
+ if (normalized.startsWith(base + "/") || normalized === base) return root;
648
+ }
649
+ }
650
+ /**
651
+ * Scaffold a directory-bundle skill: <root>/<name>/SKILL.md with a frontmatter
652
+ * template. Refuses non-kebab-case names.
653
+ * @returns the created SKILL.md path.
654
+ */
655
+ async function createSkill(root, name, description, home = dshHome()) {
656
+ if (!isSkillName(name)) throw new TypeError("skill name must be kebab-case (lowercase letters, digits, dashes): \"" + name + "\"");
657
+ const dir = join(rootPath(root, home), name);
658
+ const file = join(dir, "SKILL.md");
659
+ await mkdir(dir, { recursive: true });
660
+ const safeDescription = description.trim() === "" ? "New dsh skill created from the skill hub." : description.trim();
661
+ await writeFile(file, [
662
+ "---",
663
+ "name: " + dump(name).trim(),
664
+ "description: " + dump(safeDescription).trim(),
665
+ "---",
666
+ "",
667
+ "# " + name,
668
+ "",
669
+ "Describe what this skill does, when the agent should use it, and what output is expected.",
670
+ ""
671
+ ].join("\n"), "utf8");
672
+ return file;
673
+ }
674
+ /**
675
+ * Disable one skill file by renaming it out of discovery shapes. Accepts
676
+ * either the SKILL.md of a directory bundle or a flat .md.
677
+ * @returns the absolute path after renaming.
678
+ */
679
+ async function disableSkill(path) {
680
+ if (skillFileKind(path) === void 0) throw new TypeError("not a discoverable skill file: " + path);
681
+ const disabled = path + ".disabled";
682
+ await rename(path, disabled);
683
+ return disabled;
684
+ }
685
+ /** Re-enable a previously disabled file (reverse of disableSkill). */
686
+ async function enableSkill(disabledPath) {
687
+ if (!disabledPath.endsWith(".disabled")) throw new TypeError("not a disabled skill file: " + disabledPath);
688
+ const original = disabledPath.slice(0, -9);
689
+ await rename(disabledPath, original);
690
+ return original;
691
+ }
692
+ /**
693
+ * Move a skill (directory bundle or flat .md file) into the root's trash.
694
+ * The skill stays recoverable: the source is renamed into
695
+ * <root>/.trash/<name>-<timestamp>. A directory bundle is addressed by its
696
+ * SKILL.md path and moved as a whole directory.
697
+ * @returns the trashed path plus the original path that was moved.
698
+ */
699
+ async function trashSkill(sourcePath) {
700
+ const source = basename(sourcePath) === "SKILL.md" ? dirname(sourcePath) : sourcePath;
701
+ const trashDir = join(dirname(source), ".trash");
702
+ await mkdir(trashDir, { recursive: true });
703
+ const target = join(trashDir, basename(source) + "-" + Date.now());
704
+ await rename(source, target);
705
+ return {
706
+ path: target,
707
+ source
708
+ };
709
+ }
710
+ /** Restore a trashed skill (directory or flat file) to its original location. */
711
+ async function restoreSkill(entry, home = dshHome()) {
712
+ if (basename(dirname(entry.path)) !== ".trash") throw new TypeError("not a trashed skill path: " + entry.path);
713
+ const target = entry.sourcePath ?? join(dirname(dirname(entry.path)), entry.name);
714
+ if (entry.sourcePath !== void 0 && rootOfPath(entry.sourcePath, home) === void 0) throw new TypeError("not a hub writable skill path: " + entry.sourcePath);
715
+ await rename(entry.path, target);
716
+ return target;
717
+ }
718
+ /** Permanently delete one trashed skill (directory or flat file). */
719
+ async function clearTrash(entry, home = dshHome()) {
720
+ if (basename(dirname(entry.path)) !== ".trash" || rootOfPath(dirname(dirname(entry.path)), home) === void 0) throw new TypeError("not a hub trashed skill path: " + entry.path);
721
+ await rm(entry.path, {
722
+ recursive: true,
723
+ force: true
724
+ });
725
+ return entry.path;
726
+ }
727
+ /** Classify a skill file path: 'directory' (SKILL.md), 'flat' (<name>.md), or undefined. */
728
+ function skillFileKind(path) {
729
+ if (basename(path) === "SKILL.md") return "directory";
730
+ if (path.endsWith(".md")) return "flat";
731
+ }
732
+ /** Parse a SKILL.md the way the official filesystem provider does. */
733
+ function parseFrontmatter(text) {
734
+ const match = /^---\s*\r?\n([\s\S]*?)\r?\n---\s*(?:\r?\n([\s\S]*))?$/.exec(text);
735
+ if (match === null) return { error: "missing YAML frontmatter (--- block)" };
736
+ let data;
737
+ try {
738
+ data = load(match[1]);
739
+ } catch (error) {
740
+ return { error: "invalid YAML frontmatter: " + (error instanceof Error ? error.message : String(error)) };
741
+ }
742
+ if (typeof data !== "object" || data === null || Array.isArray(data)) return { error: "frontmatter must be a YAML mapping" };
743
+ const record = data;
744
+ const name = typeof record.name === "string" ? record.name.trim() : "";
745
+ if (name === "") return { error: "frontmatter requires a name field" };
746
+ if (!isSkillName(name)) return { error: "invalid skill name \"" + name + "\" (must be kebab-case)" };
747
+ const description = typeof record.description === "string" ? record.description.trim() : "";
748
+ if (description === "") return { error: "frontmatter requires a description field" };
749
+ const whenToUse = typeof record.whenToUse === "string" && record.whenToUse.trim() !== "" ? record.whenToUse.trim() : void 0;
750
+ if (Object.hasOwn(record, "disableModelInvocation")) return { error: "frontmatter field \"disableModelInvocation\" is unsupported; use \"disable-model-invocation\"" };
751
+ if (Object.hasOwn(record, "modelInvocable")) return { error: "frontmatter field \"modelInvocable\" is unsupported; use \"disable-model-invocation\"" };
752
+ if (Object.hasOwn(record, "userInvocable")) return { error: "frontmatter field \"userInvocable\" is unsupported; use \"user-invocable\"" };
753
+ let invocation;
754
+ try {
755
+ const disableModel = frontmatterBoolean(record, "disable-model-invocation");
756
+ const userInvocable = frontmatterBoolean(record, "user-invocable");
757
+ invocation = {
758
+ modelInvocable: disableModel !== true,
759
+ userInvocable: userInvocable !== false
760
+ };
761
+ } catch (error) {
762
+ return { error: error instanceof Error ? error.message : String(error) };
763
+ }
764
+ return { value: {
765
+ name,
766
+ description,
767
+ ...whenToUse !== void 0 ? { whenToUse } : {},
768
+ invocation,
769
+ content: (match[2] ?? "").trim()
770
+ } };
771
+ }
772
+ /** Official boolean grammar: true/false, 1/0, and the common string spellings. */
773
+ function frontmatterBoolean(data, key) {
774
+ if (!Object.hasOwn(data, key)) return void 0;
775
+ const value = data[key];
776
+ if (typeof value === "boolean") return value;
777
+ if (value === 1 || value === "1") return true;
778
+ if (value === 0 || value === "0") return false;
779
+ if (typeof value === "string") switch (value.toLowerCase()) {
780
+ case "true":
781
+ case "yes":
782
+ case "on": return true;
783
+ case "false":
784
+ case "no":
785
+ case "off": return false;
786
+ }
787
+ throw new TypeError("frontmatter field \"" + key + "\" must be a boolean");
788
+ }
789
+ /**
790
+ * Scan one skills root for discovery files: directory bundles (SKILL.md)
791
+ * and flat <name>.md files. Hub-disabled files (.disabled) are excluded;
792
+ * dot-prefixed entries (including .trash and .system) are always skipped.
793
+ */
794
+ async function scanRoot(base) {
795
+ const entries = [];
796
+ let names;
797
+ try {
798
+ names = await readdir(base);
799
+ } catch (error) {
800
+ if (error.code === "ENOENT") return entries;
801
+ throw error;
802
+ }
803
+ for (const name of names) {
804
+ if (name.startsWith(".")) continue;
805
+ const absolute = join(base, name);
806
+ let stats;
807
+ try {
808
+ stats = await stat(absolute);
809
+ } catch {
810
+ continue;
811
+ }
812
+ if (stats.isDirectory()) entries.push({
813
+ path: join(absolute, "SKILL.md"),
814
+ directory: absolute,
815
+ kind: "directory"
816
+ });
817
+ else if (name.endsWith(".md") && !name.endsWith(".md.disabled")) entries.push({
818
+ path: absolute,
819
+ directory: base,
820
+ kind: "flat"
821
+ });
822
+ }
823
+ return entries.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
824
+ }
825
+ /** Scan one writable root. */
826
+ function listSkillEntries(root, home = dshHome()) {
827
+ return scanRoot(rootPath(root, home));
828
+ }
829
+ /**
830
+ * Walk up from cwd (max 32 levels) for a project marker (.dsh or .git);
831
+ * falls back to cwd itself. The provider roots project skills here.
832
+ */
833
+ async function findProjectRoot(cwd) {
834
+ let current = resolve(cwd);
835
+ for (let depth = 0; depth < 32; depth += 1) {
836
+ if ((await Promise.allSettled([stat(join(current, ".dsh")), stat(join(current, ".git"))])).some((marker) => marker.status === "fulfilled")) return current;
837
+ const parent = dirname(current);
838
+ if (parent === current) break;
839
+ current = parent;
840
+ }
841
+ return resolve(cwd);
842
+ }
843
+ /**
844
+ * Scan one writable root for files the provider ignores, so the GUI can
845
+ * show why a skill never appears (the zcode-style diagnostics lesson: a
846
+ * missing frontmatter must be visible, not silent). .disabled files belong
847
+ * to the hub and are skipped.
848
+ */
849
+ async function scanDiagnostics(root, home = dshHome()) {
850
+ const diagnostics = [];
851
+ for (const entry of await listSkillEntries(root, home)) {
852
+ let text;
853
+ try {
854
+ text = await readFile(entry.path, "utf8");
855
+ } catch (error) {
856
+ if (error.code !== "ENOENT") diagnostics.push({
857
+ path: entry.path,
858
+ root,
859
+ reason: "unreadable: " + (error instanceof Error ? error.message : String(error))
860
+ });
861
+ continue;
862
+ }
863
+ const parsed = parseFrontmatter(text);
864
+ if ("error" in parsed) {
865
+ diagnostics.push({
866
+ path: entry.path,
867
+ root,
868
+ reason: parsed.error
869
+ });
870
+ continue;
871
+ }
872
+ const { value } = parsed;
873
+ const pathName = entry.kind === "directory" ? basename(entry.directory) : basename(entry.path, ".md");
874
+ if (value.name !== pathName) diagnostics.push({
875
+ path: entry.path,
876
+ root,
877
+ reason: `frontmatter name "${value.name}" does not match the discovery path "${pathName}" (the provider registers by path)`
878
+ });
879
+ if (value.description.length < 10) diagnostics.push({
880
+ path: entry.path,
881
+ root,
882
+ reason: `description is only ${value.description.length} chars; write a one-line description so agents can auto-activate this skill`
883
+ });
884
+ }
885
+ return diagnostics;
886
+ }
887
+ //#endregion
888
+ //#region src/provider.ts
889
+ /**
890
+ * The hub's own skill provider, registered into the GLOBAL layer of the
891
+ * official ctx.skills registry.
892
+ *
893
+ * Why: in the dsh web app the base host skill-filesystem row is disabled on
894
+ * purpose — agent presets own local discovery by mounting their own
895
+ * skill-filesystem into their preset scope layers, so a host-plane query
896
+ * against the registry's empty global layer sees nothing. The hub needs a
897
+ * session-independent management view, so it contributes one itself: the
898
+ * user roots always, plus the project roots when the caller names a cwd.
899
+ *
900
+ * Agent views are unaffected: preset layers are nearer than the global
901
+ * layer, so a preset's own filesystem provider wins every duplicate name
902
+ * outright, and this provider only surfaces skills for presets that mount
903
+ * no filesystem row at all.
904
+ */
905
+ /** Official root ranks (mirrors dsh-skill-filesystem). */
906
+ const PROJECT_DSH_RANK = 100;
907
+ const PROJECT_AGENTS_RANK = 200;
908
+ const USER_DSH_RANK = 400;
909
+ const USER_AGENTS_RANK = 500;
910
+ /**
911
+ * Global-layer skill provider for the hub's management catalog.
912
+ * Implements the official SkillProvider contract: list() returns
913
+ * invocation-neutral candidates; get() resolves the winning candidate's
914
+ * body. Both are readonly reads of the local skill roots.
915
+ */
916
+ var SkillHubProvider = class {
917
+ name = "skill-hub";
918
+ home;
919
+ control;
920
+ watchTimer;
921
+ rootStamp = "";
922
+ constructor(control, home = dshHome()) {
923
+ this.control = control;
924
+ this.home = home;
925
+ this.watchTimer = setInterval(() => {
926
+ this.checkRoots();
927
+ }, 5e3);
928
+ this.watchTimer.unref?.();
929
+ control.signal.addEventListener("abort", () => {
930
+ clearInterval(this.watchTimer);
931
+ }, { once: true });
932
+ this.checkRoots();
933
+ }
934
+ /**
935
+ * Invalidate the registry cache when the discovered skill files changed.
936
+ * The stamp covers the per-skill discovery file's mtime + size (plus the
937
+ * entry set itself), so editing a SKILL.md body — not just adding or
938
+ * renaming directories — refreshes the GUI catalog.
939
+ */
940
+ async checkRoots() {
941
+ let stamp = "";
942
+ for (const root of await this.roots(void 0)) for (const entry of await scanRoot(root.base)) try {
943
+ const info = await stat(entry.path);
944
+ stamp += entry.path + ":" + info.mtimeMs + ":" + info.size + ";";
945
+ } catch {
946
+ stamp += entry.path + ":missing;";
947
+ }
948
+ if (this.rootStamp === "") {
949
+ this.rootStamp = stamp;
950
+ return;
951
+ }
952
+ if (stamp !== this.rootStamp) {
953
+ this.rootStamp = stamp;
954
+ this.control.invalidate();
955
+ }
956
+ }
957
+ async list(options) {
958
+ const candidates = [];
959
+ for (const root of await this.roots(options.cwd)) for (const entry of await scanRoot(root.base)) {
960
+ let text;
961
+ try {
962
+ text = await readFile(entry.path, "utf8");
963
+ } catch {
964
+ continue;
965
+ }
966
+ const parsed = parseFrontmatter(text);
967
+ if ("error" in parsed) continue;
968
+ const value = parsed.value;
969
+ candidates.push({
970
+ name: value.name,
971
+ description: value.description,
972
+ ...value.whenToUse !== void 0 ? { whenToUse: value.whenToUse } : {},
973
+ invocation: value.invocation,
974
+ source: root.source,
975
+ provider: this.name,
976
+ rank: root.rank,
977
+ locator: {
978
+ path: entry.path,
979
+ directory: entry.directory
980
+ },
981
+ path: entry.path
982
+ });
983
+ }
984
+ return candidates;
985
+ }
986
+ async get(candidate) {
987
+ const locator = candidate.locator;
988
+ let text;
989
+ try {
990
+ text = await readFile(locator.path, "utf8");
991
+ } catch {
992
+ return;
993
+ }
994
+ const parsed = parseFrontmatter(text);
995
+ if ("error" in parsed) return void 0;
996
+ const value = parsed.value;
997
+ return {
998
+ name: value.name,
999
+ description: value.description,
1000
+ ...value.whenToUse !== void 0 ? { whenToUse: value.whenToUse } : {},
1001
+ invocation: value.invocation,
1002
+ source: candidate.source,
1003
+ provider: this.name,
1004
+ resourceBase: {
1005
+ kind: "directory",
1006
+ path: locator.directory
1007
+ },
1008
+ path: locator.path,
1009
+ content: value.content
1010
+ };
1011
+ }
1012
+ /** The roots this provider lists: user roots always, project roots with cwd. */
1013
+ async roots(cwd) {
1014
+ const roots = [{
1015
+ base: rootPath("user-dsh", this.home),
1016
+ source: "user-dsh",
1017
+ rank: USER_DSH_RANK
1018
+ }, {
1019
+ base: rootPath("user-agents", this.home),
1020
+ source: "user-agents",
1021
+ rank: USER_AGENTS_RANK
1022
+ }];
1023
+ if (cwd !== void 0 && cwd !== "") {
1024
+ const project = await findProjectRoot(cwd);
1025
+ roots.unshift({
1026
+ base: join(project, ".dsh", "skills"),
1027
+ source: "project-dsh",
1028
+ rank: PROJECT_DSH_RANK
1029
+ }, {
1030
+ base: join(project, ".agents", "skills"),
1031
+ source: "project-agents",
1032
+ rank: PROJECT_AGENTS_RANK
1033
+ });
1034
+ }
1035
+ return roots;
1036
+ }
1037
+ /** Public hook for the routes: invalidate after hub-driven mutations. */
1038
+ invalidate() {
1039
+ this.control.invalidate();
1040
+ }
1041
+ };
1042
+ //#endregion
1043
+ //#region src/repo.ts
1044
+ /**
1045
+ * GitHub repository skill discovery/import helpers.
1046
+ *
1047
+ * Kept dependency-free and mostly pure so the root/origin rules are easy to
1048
+ * test. The two supported roots mirror the repositories we care about:
1049
+ * `skills/**` (standard skill bundles) and `design-templates/**` (template
1050
+ * skill bundles in nexu-io/open-design).
1051
+ */
1052
+ /** Fetch failure carrying a useful HTTP status for the route layer. */
1053
+ var RepoFetchError = class extends Error {
1054
+ status;
1055
+ constructor(message, status = 502) {
1056
+ super(message);
1057
+ this.name = "RepoFetchError";
1058
+ this.status = status;
1059
+ }
1060
+ };
1061
+ /**
1062
+ * GitHub token for authenticated API calls. Read from GITHUB_TOKEN /
1063
+ * GH_TOKEN at module load; setGithubToken() overrides it at runtime (the
1064
+ * plugin config route calls it when a saved token exists).
1065
+ */
1066
+ let githubToken = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN ?? "";
1067
+ /** Authorization header for api.github.com / raw.githubusercontent.com calls. */
1068
+ function githubAuthHeaders() {
1069
+ return githubToken === "" ? {} : { authorization: "Bearer " + githubToken };
1070
+ }
1071
+ /**
1072
+ * Build a RepoFetchError; when the response shows an exhausted rate limit,
1073
+ * report the reset time instead of a bare HTTP status.
1074
+ */
1075
+ function fetchError(context, response, fallbackStatus = 502) {
1076
+ const remaining = response.headers.get("x-ratelimit-remaining");
1077
+ const reset = response.headers.get("x-ratelimit-reset");
1078
+ if (response.status === 403 || response.status === 429) {
1079
+ if (remaining === "0" && reset !== null) return new RepoFetchError("github rate limit reached (anonymous quota exhausted); retry after " + (/* @__PURE__ */ new Date(Number(reset) * 1e3)).toLocaleTimeString() + " or set GITHUB_TOKEN", 403);
1080
+ }
1081
+ return new RepoFetchError(context + " (HTTP " + response.status + ")", response.status === 404 ? 404 : fallbackStatus);
1082
+ }
1083
+ /** `owner/repo` slug for a parsed reference. */
1084
+ function repoSlug(ref) {
1085
+ return `${ref.owner}/${ref.repo}`;
1086
+ }
1087
+ /**
1088
+ * Normalize a GitHub URL or `owner/repo`/`owner/repo@ref` input.
1089
+ * Only github.com URLs are accepted in v1.
1090
+ */
1091
+ function normalizeRepoInput(input) {
1092
+ let value = input.trim();
1093
+ if (value === "") return null;
1094
+ let ref;
1095
+ const urlMatch = /^https?:\/\/github\.com\/([^/]+)\/([^/#?@]+)(?:\/tree\/([^/?#]+))?/.exec(value);
1096
+ if (urlMatch !== null) {
1097
+ value = `${urlMatch[1]}/${urlMatch[2]}`;
1098
+ ref = urlMatch[3];
1099
+ } else {
1100
+ if (/^https?:\/\//.test(value)) return null;
1101
+ const at = value.indexOf("@");
1102
+ if (at !== -1) {
1103
+ ref = value.slice(at + 1).trim();
1104
+ value = value.slice(0, at).trim();
1105
+ }
1106
+ }
1107
+ value = value.replace(/\.git$/, "").replace(/\/+$/, "");
1108
+ const parts = value.split("/").filter(Boolean);
1109
+ if (parts.length < 2) return null;
1110
+ const owner = parts[0];
1111
+ const repo = parts[1];
1112
+ if (owner === "" || repo === "" || owner.includes("..") || repo.includes("..")) return null;
1113
+ return {
1114
+ owner,
1115
+ repo,
1116
+ ...ref !== void 0 && ref !== "" ? { ref } : {}
1117
+ };
1118
+ }
1119
+ /**
1120
+ * Load repo metadata and the recursive git tree at the given ref. The meta
1121
+ * request always resolves the default branch (used when ref is absent); the
1122
+ * tree is fetched at `ref ?? default_branch` so an explicit branch/tag scans
1123
+ * the exact same content a later download would fetch.
1124
+ */
1125
+ async function loadRepoTree(repo, ref, fetchImpl = fetch) {
1126
+ let metaResponse;
1127
+ try {
1128
+ metaResponse = await fetchImpl(`https://api.github.com/repos/${repo}`, { headers: {
1129
+ accept: "application/vnd.github+json",
1130
+ ...githubAuthHeaders()
1131
+ } });
1132
+ } catch (error) {
1133
+ throw new RepoFetchError("github request failed: " + (error instanceof Error ? error.message : String(error)));
1134
+ }
1135
+ if (!metaResponse.ok) throw fetchError("github repo not found or unavailable", metaResponse);
1136
+ let meta;
1137
+ try {
1138
+ meta = await metaResponse.json();
1139
+ } catch {
1140
+ throw new RepoFetchError("invalid github repo response");
1141
+ }
1142
+ const record = typeof meta === "object" && meta !== null ? meta : {};
1143
+ const defaultBranch = typeof record.default_branch === "string" && record.default_branch !== "" ? record.default_branch : "main";
1144
+ const treeRef = ref !== void 0 && ref !== "" ? ref : defaultBranch;
1145
+ let treeResponse;
1146
+ try {
1147
+ treeResponse = await fetchImpl(`https://api.github.com/repos/${repo}/git/trees/${encodeURIComponent(treeRef)}?recursive=1`, { headers: {
1148
+ accept: "application/vnd.github+json",
1149
+ ...githubAuthHeaders()
1150
+ } });
1151
+ } catch (error) {
1152
+ throw new RepoFetchError("github tree request failed: " + (error instanceof Error ? error.message : String(error)));
1153
+ }
1154
+ if (!treeResponse.ok) throw fetchError("github tree not found", treeResponse);
1155
+ let payload;
1156
+ try {
1157
+ payload = await treeResponse.json();
1158
+ } catch {
1159
+ throw new RepoFetchError("invalid github tree response");
1160
+ }
1161
+ const treeRecord = typeof payload === "object" && payload !== null ? payload : {};
1162
+ if (treeRecord.truncated === true) throw new RepoFetchError("repo tree is too large to scan");
1163
+ return {
1164
+ ref: treeRef,
1165
+ tree: Array.isArray(treeRecord.tree) ? treeRecord.tree : []
1166
+ };
1167
+ }
1168
+ /** Collect every file inside a skill directory, including SKILL.md itself. */
1169
+ function collectRepoSkillFiles(tree, dir) {
1170
+ const prefix = dir + "/";
1171
+ return tree.filter((item) => item.type === "blob" && item.path.startsWith(prefix)).map((item) => ({
1172
+ path: item.path,
1173
+ size: typeof item.size === "number" ? item.size : 0
1174
+ })).sort((a, b) => a.path.localeCompare(b.path));
1175
+ }
1176
+ /** Compute an origin collection name. Multiple roots split by root, one root keeps the repo slug. */
1177
+ function originForRoot(repo, rootsPresent, root) {
1178
+ return rootsPresent.size > 1 ? `${repo}/${root}` : repo;
1179
+ }
1180
+ /** Discover importable skills from a repo tree. Invalid names are ignored. */
1181
+ function discoverRepoEntries(tree, repo, existingNames = /* @__PURE__ */ new Set()) {
1182
+ const candidates = [];
1183
+ const rootsPresent = /* @__PURE__ */ new Set();
1184
+ for (const item of tree) {
1185
+ if (item.type !== "blob") continue;
1186
+ const match = /^(skills|design-templates)\/(.+)\/SKILL\.md$/.exec(item.path);
1187
+ if (match === null) continue;
1188
+ const root = match[1];
1189
+ const dir = item.path.slice(0, -9);
1190
+ const name = dir.slice(dir.lastIndexOf("/") + 1);
1191
+ if (!isSkillName(name)) continue;
1192
+ rootsPresent.add(root);
1193
+ candidates.push({
1194
+ root,
1195
+ dir,
1196
+ name,
1197
+ path: item.path
1198
+ });
1199
+ }
1200
+ return candidates.map((candidate) => {
1201
+ const files = collectRepoSkillFiles(tree, candidate.dir);
1202
+ return {
1203
+ name: candidate.name,
1204
+ dir: candidate.dir,
1205
+ path: candidate.path,
1206
+ root: candidate.root,
1207
+ origin: originForRoot(repo, rootsPresent, candidate.root),
1208
+ fileCount: files.length,
1209
+ totalBytes: files.reduce((sum, file) => sum + file.size, 0),
1210
+ existing: existingNames.has(candidate.name)
1211
+ };
1212
+ }).sort((a, b) => a.name.localeCompare(b.name));
1213
+ }
1214
+ /** Run an async worker over items with a bounded concurrency. */
1215
+ async function mapConcurrent(items, limit, worker) {
1216
+ const results = new Array(items.length);
1217
+ let cursor = 0;
1218
+ const runners = Array.from({ length: Math.min(limit, items.length) }, async () => {
1219
+ while (cursor < items.length) {
1220
+ const index = cursor;
1221
+ cursor += 1;
1222
+ results[index] = await worker(items[index], index);
1223
+ }
1224
+ });
1225
+ await Promise.all(runners);
1226
+ return results;
1227
+ }
1228
+ /**
1229
+ * Download one GitHub file as a buffer. Tries raw.githubusercontent.com
1230
+ * first (no API quota); on any failure falls back to the api.github.com
1231
+ * contents endpoint with the raw media type (rate-limited, but reachable
1232
+ * from networks that block the raw host).
1233
+ */
1234
+ async function downloadGitHubFile(repo, ref, path, fetchImpl = fetch) {
1235
+ const encodedPath = path.split("/").map((segment) => encodeURIComponent(segment)).join("/");
1236
+ const rawUrl = `https://raw.githubusercontent.com/${repo}/${encodeURIComponent(ref)}/${encodedPath}`;
1237
+ let firstError = null;
1238
+ let response = null;
1239
+ try {
1240
+ response = await fetchImpl(rawUrl, { headers: githubAuthHeaders() });
1241
+ } catch (error) {
1242
+ firstError = error instanceof Error ? error.message : String(error);
1243
+ }
1244
+ if (response === null || !response.ok) {
1245
+ const apiUrl = `https://api.github.com/repos/${repo}/contents/${encodedPath}`;
1246
+ try {
1247
+ response = await fetchImpl(apiUrl, { headers: {
1248
+ accept: "application/vnd.github.raw",
1249
+ ...githubAuthHeaders()
1250
+ } });
1251
+ } catch (error) {
1252
+ throw new RepoFetchError("download failed: " + (firstError ?? (error instanceof Error ? error.message : String(error))));
1253
+ }
1254
+ }
1255
+ if (response === null || !response.ok) throw fetchError("download failed", response);
1256
+ try {
1257
+ return Buffer.from(await response.arrayBuffer());
1258
+ } catch (error) {
1259
+ throw new RepoFetchError("download read failed: " + (error instanceof Error ? error.message : String(error)));
1260
+ }
1261
+ }
1262
+ /**
1263
+ * Download a full skill directory into a temporary dir, validate SKILL.md,
1264
+ * then atomically rename it into place. The target root is created when missing.
1265
+ * Callers pass a target root and a skill entry plus its collected files.
1266
+ */
1267
+ async function downloadRepoSkill(repo, ref, entry, files, targetRoot, fetchImpl = fetch) {
1268
+ const targetDir = join(targetRoot, entry.name);
1269
+ await mkdir(targetRoot, { recursive: true });
1270
+ const tempDir = await mkdtemp(join(targetRoot, "." + entry.name + ".import-"));
1271
+ try {
1272
+ await mapConcurrent(files, 6, async (file) => {
1273
+ const relative = file.path.slice(entry.dir.length + 1);
1274
+ if (relative === "" || relative.includes("..")) throw new RepoFetchError("unsafe repo path: " + file.path);
1275
+ const target = join(tempDir, relative);
1276
+ const buffer = await downloadGitHubFile(repo, ref, file.path, fetchImpl);
1277
+ await mkdir(dirname(target), { recursive: true });
1278
+ await writeFile(target, buffer);
1279
+ });
1280
+ let text;
1281
+ try {
1282
+ text = await readFile(join(tempDir, "SKILL.md"), "utf8");
1283
+ } catch {
1284
+ throw new RepoFetchError("downloaded skill has no SKILL.md");
1285
+ }
1286
+ const parsed = parseFrontmatter(text);
1287
+ if ("error" in parsed) throw new RepoFetchError("downloaded skill rejected: " + parsed.error, 422);
1288
+ if (parsed.value.name !== entry.name) throw new RepoFetchError("downloaded skill declares name \"" + parsed.value.name + "\", expected \"" + entry.name + "\"", 422);
1289
+ await rename(tempDir, targetDir);
1290
+ return {
1291
+ targetDir,
1292
+ skillPath: join(targetDir, "SKILL.md")
1293
+ };
1294
+ } catch (error) {
1295
+ await rm(tempDir, {
1296
+ recursive: true,
1297
+ force: true
1298
+ }).catch(async () => {
1299
+ await new Promise((resolve) => setTimeout(resolve, 60));
1300
+ await rm(tempDir, {
1301
+ recursive: true,
1302
+ force: true
1303
+ }).catch(() => {});
1304
+ });
1305
+ throw error;
1306
+ }
1307
+ }
1308
+ /**
1309
+ * The latest non-prerelease release tag of a repo, or undefined when the
1310
+ * repo has no releases (GitHub's /releases/latest skips prereleases/drafts).
1311
+ */
1312
+ async function getLatestReleaseTag(repo, fetchImpl = fetch) {
1313
+ let response;
1314
+ try {
1315
+ response = await fetchImpl(`https://api.github.com/repos/${repo}/releases/latest`, { headers: {
1316
+ accept: "application/vnd.github+json",
1317
+ ...githubAuthHeaders()
1318
+ } });
1319
+ } catch (error) {
1320
+ throw new RepoFetchError("github release request failed: " + (error instanceof Error ? error.message : String(error)));
1321
+ }
1322
+ if (response.status === 404) return void 0;
1323
+ if (!response.ok) throw fetchError("github release lookup failed", response);
1324
+ let payload;
1325
+ try {
1326
+ payload = await response.json();
1327
+ } catch {
1328
+ throw new RepoFetchError("invalid github release response");
1329
+ }
1330
+ const tag = typeof payload === "object" && payload !== null ? payload.tag_name : void 0;
1331
+ return typeof tag === "string" && tag !== "" ? tag : void 0;
1332
+ }
1333
+ /** Branch names of a repo, default branch first (one GitHub API request). */
1334
+ async function listRepoBranches(repo, fetchImpl = fetch) {
1335
+ let response;
1336
+ try {
1337
+ response = await fetchImpl(`https://api.github.com/repos/${repo}/branches?per_page=100`, { headers: {
1338
+ accept: "application/vnd.github+json",
1339
+ ...githubAuthHeaders()
1340
+ } });
1341
+ } catch (error) {
1342
+ throw new RepoFetchError("github branches request failed: " + (error instanceof Error ? error.message : String(error)));
1343
+ }
1344
+ if (!response.ok) throw fetchError("github branches lookup failed", response);
1345
+ let payload;
1346
+ try {
1347
+ payload = await response.json();
1348
+ } catch {
1349
+ throw new RepoFetchError("invalid github branches response");
1350
+ }
1351
+ if (!Array.isArray(payload)) return [];
1352
+ return payload.map((item) => typeof item === "object" && item !== null ? item.name : void 0).filter((name) => typeof name === "string" && name !== "");
1353
+ }
1354
+ /**
1355
+ * Fetch the latest commit of a repo (explicit ref or default branch) with
1356
+ * its tree SHA. One GitHub API request; the tree SHA is used to diff the
1357
+ * upstream tree on change (one extra request).
1358
+ */
1359
+ async function getLatestCommit(repo, ref, fetchImpl = fetch) {
1360
+ const url = ref !== void 0 && ref !== "" ? `https://api.github.com/repos/${repo}/commits/${encodeURIComponent(ref)}` : `https://api.github.com/repos/${repo}/commits?per_page=1`;
1361
+ let response;
1362
+ try {
1363
+ response = await fetchImpl(url, { headers: {
1364
+ accept: "application/vnd.github+json",
1365
+ ...githubAuthHeaders()
1366
+ } });
1367
+ } catch (error) {
1368
+ throw new RepoFetchError("github request failed: " + (error instanceof Error ? error.message : String(error)));
1369
+ }
1370
+ if (!response.ok) throw fetchError("github commit lookup failed", response);
1371
+ let payload;
1372
+ try {
1373
+ payload = await response.json();
1374
+ } catch {
1375
+ throw new RepoFetchError("invalid github commit response");
1376
+ }
1377
+ const record = Array.isArray(payload) ? payload[0] ?? {} : typeof payload === "object" && payload !== null ? payload : {};
1378
+ const commitSha = typeof record.sha === "string" && record.sha !== "" ? record.sha : null;
1379
+ const tree = typeof record.commit === "object" && record.commit !== null ? record.commit.tree : void 0;
1380
+ const treeSha = typeof tree?.sha === "string" && tree.sha !== "" ? tree.sha : null;
1381
+ if (commitSha === null || treeSha === null) throw new RepoFetchError("github commit response has no sha/tree");
1382
+ return {
1383
+ commitSha,
1384
+ treeSha
1385
+ };
1386
+ }
1387
+ /** Load the recursive git tree at an explicit tree SHA (one API request). */
1388
+ async function loadRepoTreeAt(repo, treeSha, fetchImpl = fetch) {
1389
+ let response;
1390
+ try {
1391
+ response = await fetchImpl(`https://api.github.com/repos/${repo}/git/trees/${encodeURIComponent(treeSha)}?recursive=1`, { headers: {
1392
+ accept: "application/vnd.github+json",
1393
+ ...githubAuthHeaders()
1394
+ } });
1395
+ } catch (error) {
1396
+ throw new RepoFetchError("github tree request failed: " + (error instanceof Error ? error.message : String(error)));
1397
+ }
1398
+ if (!response.ok) throw fetchError("github tree lookup failed", response);
1399
+ let payload;
1400
+ try {
1401
+ payload = await response.json();
1402
+ } catch {
1403
+ throw new RepoFetchError("invalid github tree response");
1404
+ }
1405
+ const record = typeof payload === "object" && payload !== null ? payload : {};
1406
+ if (record.truncated === true) throw new RepoFetchError("repo tree is too large to scan");
1407
+ return Array.isArray(record.tree) ? record.tree : [];
1408
+ }
1409
+ /** Build a path→size manifest for one skill directory from a repo tree. */
1410
+ function skillManifest(tree, dir) {
1411
+ const prefix = dir + "/";
1412
+ const manifest = {};
1413
+ for (const item of tree) if (item.type === "blob" && item.path.startsWith(prefix)) manifest[item.path] = typeof item.size === "number" ? item.size : 0;
1414
+ return manifest;
1415
+ }
1416
+ /**
1417
+ * The real upstream directory of one tracked skill. Source records keep only
1418
+ * the short name and top-level root (e.g. root "skills", name "ask-matt"),
1419
+ * but upstream repos may nest skills under category directories
1420
+ * (skills/engineering/ask-matt/) or move them between categories. Resolution
1421
+ * order: exact match under the top-level root in the upstream tree, then the
1422
+ * manifest's recorded blob path, then any same-name skill in the tree, then
1423
+ * the flat root/name fallback. Passing the tree paths makes the lookup
1424
+ * resilient to incomplete manifests and upstream moves.
1425
+ */
1426
+ function skillDirOf(source, name, treePaths) {
1427
+ if (treePaths !== void 0) {
1428
+ const rootPrefix = source.root + "/";
1429
+ const found = treePaths.find((path) => path.startsWith(rootPrefix) && path.endsWith("/" + name + "/SKILL.md"));
1430
+ if (found !== void 0) return found.slice(0, found.lastIndexOf("/"));
1431
+ }
1432
+ const manifest = source.manifest ?? {};
1433
+ const fromManifest = Object.keys(manifest).find((path) => path.endsWith("/" + name + "/SKILL.md"));
1434
+ if (fromManifest !== void 0) return fromManifest.slice(0, fromManifest.lastIndexOf("/"));
1435
+ if (treePaths !== void 0) {
1436
+ const found = treePaths.find((path) => path.endsWith("/" + name + "/SKILL.md"));
1437
+ if (found !== void 0) return found.slice(0, found.lastIndexOf("/"));
1438
+ }
1439
+ return source.root + "/" + name;
1440
+ }
1441
+ /**
1442
+ * Diff one source record against an upstream tree at treeSha: which tracked
1443
+ * skills disappeared (no SKILL.md blob) and which changed (manifest baseline
1444
+ * differs, or no baseline exists — treated as changed). Pure over the tree.
1445
+ */
1446
+ function diffRemoteSkills(tree, source) {
1447
+ const blobs = /* @__PURE__ */ new Map();
1448
+ for (const item of tree) if (item.type === "blob") blobs.set(item.path, typeof item.size === "number" ? item.size : 0);
1449
+ const treePaths = [...blobs.keys()];
1450
+ const updated = [];
1451
+ const deleted = [];
1452
+ for (const name of source.skills) {
1453
+ const prefix = skillDirOf(source, name, treePaths) + "/";
1454
+ const remote = /* @__PURE__ */ new Map();
1455
+ for (const [path, size] of blobs) if (path.startsWith(prefix)) remote.set(path, size);
1456
+ if (!remote.has(prefix + "SKILL.md")) {
1457
+ deleted.push(name);
1458
+ continue;
1459
+ }
1460
+ const baseline = source.manifest ?? {};
1461
+ const baselineEntries = Object.entries(baseline).filter(([path]) => path.startsWith(prefix));
1462
+ if (baselineEntries.length === 0) {
1463
+ updated.push(name);
1464
+ continue;
1465
+ }
1466
+ let differs = baselineEntries.length !== remote.size;
1467
+ if (!differs) {
1468
+ for (const [path, size] of baselineEntries) if (remote.get(path) !== size) {
1469
+ differs = true;
1470
+ break;
1471
+ }
1472
+ }
1473
+ if (differs) updated.push(name);
1474
+ }
1475
+ return {
1476
+ updated,
1477
+ deleted
1478
+ };
1479
+ }
1480
+ /** Minimal RepoSkillEntry for a tracked skill name (sync re-downloads by name). */
1481
+ function repoSkillEntry(name, root, repo) {
1482
+ return {
1483
+ name,
1484
+ dir: root + "/" + name,
1485
+ path: root + "/" + name + "/SKILL.md",
1486
+ root,
1487
+ origin: repo,
1488
+ fileCount: 0,
1489
+ totalBytes: 0,
1490
+ existing: false
1491
+ };
1492
+ }
1493
+ //#endregion
1494
+ //#region src/update.ts
1495
+ /**
1496
+ * Self-update check for dsh-skill-hub, mirroring cc-switch's behavior:
1497
+ * query GitHub's latest release and compare it to the installed version.
1498
+ *
1499
+ * This stays dependency-free: Node's global fetch is used and the version
1500
+ * comparison is a small semver-subset parser (the plugin only publishes
1501
+ * plain x.y.z tags, optionally prefixed with `v`).
1502
+ */
1503
+ /** Repository checked for releases. Keep in sync with package.json. */
1504
+ const UPDATE_REPO = "cheshireez/dsh-skill-hub";
1505
+ const CURRENT_VERSION = createRequire(import.meta.url)("../package.json").version;
1506
+ /** Normalize a release tag (`v1.2.3` → `1.2.3`) and parse it numerically. */
1507
+ function parseVersion(tag) {
1508
+ const match = /^[vV]?(\d+)\.(\d+)\.(\d+)$/.exec(tag.trim());
1509
+ if (match === null) return null;
1510
+ return [
1511
+ Number(match[1]),
1512
+ Number(match[2]),
1513
+ Number(match[3])
1514
+ ];
1515
+ }
1516
+ /** Compare two dotted version strings: negative when a < b, 0 equal, positive when a > b. */
1517
+ function compareVersions(a, b) {
1518
+ const left = parseVersion(a);
1519
+ const right = parseVersion(b);
1520
+ if (left === null || right === null) return a.localeCompare(b, "en");
1521
+ for (let i = 0; i < 3; i += 1) if (left[i] !== right[i]) return left[i] < right[i] ? -1 : 1;
1522
+ return 0;
1523
+ }
1524
+ /** True when latest is strictly newer than current. */
1525
+ function isUpdateAvailable(currentVersion, latestVersion) {
1526
+ return compareVersions(latestVersion, currentVersion) > 0;
1527
+ }
1528
+ /** Best-effort GitHub latest-release lookup. Always resolves with ok:true. */
1529
+ async function checkLatestRelease(repo = UPDATE_REPO, fetchImpl = fetch) {
1530
+ const currentVersion = CURRENT_VERSION;
1531
+ const url = `https://api.github.com/repos/${repo}/releases/latest`;
1532
+ let response;
1533
+ try {
1534
+ response = await fetchImpl(url, { headers: {
1535
+ accept: "application/vnd.github+json",
1536
+ ...githubAuthHeaders()
1537
+ } });
1538
+ } catch (error) {
1539
+ return {
1540
+ ok: true,
1541
+ currentVersion,
1542
+ latestVersion: null,
1543
+ updateAvailable: false,
1544
+ url: null,
1545
+ error: error instanceof Error ? error.message : String(error)
1546
+ };
1547
+ }
1548
+ if (response.status === 404) return {
1549
+ ok: true,
1550
+ currentVersion,
1551
+ latestVersion: null,
1552
+ updateAvailable: false,
1553
+ url: null,
1554
+ error: "no release published yet"
1555
+ };
1556
+ if (!response.ok) {
1557
+ const remaining = response.headers.get("x-ratelimit-remaining");
1558
+ const reset = response.headers.get("x-ratelimit-reset");
1559
+ if ((response.status === 403 || response.status === 429) && remaining === "0" && reset !== null) return {
1560
+ ok: true,
1561
+ currentVersion,
1562
+ latestVersion: null,
1563
+ updateAvailable: false,
1564
+ url: null,
1565
+ error: "github rate limit reached; retry after " + (/* @__PURE__ */ new Date(Number(reset) * 1e3)).toLocaleTimeString() + " or set GITHUB_TOKEN"
1566
+ };
1567
+ return {
1568
+ ok: true,
1569
+ currentVersion,
1570
+ latestVersion: null,
1571
+ updateAvailable: false,
1572
+ url: null,
1573
+ error: "GitHub HTTP " + response.status
1574
+ };
1575
+ }
1576
+ let payload;
1577
+ try {
1578
+ payload = await response.json();
1579
+ } catch (error) {
1580
+ return {
1581
+ ok: true,
1582
+ currentVersion,
1583
+ latestVersion: null,
1584
+ updateAvailable: false,
1585
+ url: null,
1586
+ error: error instanceof Error ? error.message : "invalid GitHub response"
1587
+ };
1588
+ }
1589
+ const record = typeof payload === "object" && payload !== null ? payload : {};
1590
+ const rawTag = typeof record.tag_name === "string" ? record.tag_name.trim() : "";
1591
+ const parsed = parseVersion(rawTag);
1592
+ if (parsed === null) return {
1593
+ ok: true,
1594
+ currentVersion,
1595
+ latestVersion: null,
1596
+ updateAvailable: false,
1597
+ url: null,
1598
+ error: "unsupported release tag: " + (rawTag || "missing")
1599
+ };
1600
+ const latestVersion = `${parsed[0]}.${parsed[1]}.${parsed[2]}`;
1601
+ return {
1602
+ ok: true,
1603
+ currentVersion,
1604
+ latestVersion,
1605
+ updateAvailable: isUpdateAvailable(currentVersion, latestVersion),
1606
+ url: typeof record.html_url === "string" ? record.html_url : null
1607
+ };
1608
+ }
1609
+ //#endregion
1610
+ //#region src/routes.ts
1611
+ /**
1612
+ * The /api/skill-hub route family: full catalog (enabled skills from the
1613
+ * official registry + hub-disabled skills + discovery diagnostics), skill
1614
+ * detail, enable/disable toggle, new-skill scaffold, user groups (tags +
1615
+ * origin collections), and upstream source tracking (check/sync/follow
1616
+ * upstream deletion into a restorable trash). Every route carries a
1617
+ * loopback-only trust fence — these endpoints rename files under the user's
1618
+ * skill roots, so LAN-exposed dsh web deployments must not serve them.
1619
+ *
1620
+ * Every handler is declared through the local `route()` wrapper, which owns
1621
+ * the shared request fences (loopback trust, HTTP method, master switch,
1622
+ * JSON body) exactly once, so a new route cannot forget them.
1623
+ */
1624
+ /** Cap on JSON request bodies (toggle/create payloads are tiny). */
1625
+ const MAX_JSON_BODY_BYTES = 64 * 1024;
1626
+ /** Minimum interval between network update checks per source (GitHub rate limits). */
1627
+ const MIN_CHECK_INTERVAL_MS = 5 * 6e4;
1628
+ /** Loopback literal check plus browser same-origin markers. */
1629
+ function isLoopbackRequest(request) {
1630
+ const address = request.socket.remoteAddress;
1631
+ if (address !== "127.0.0.1" && address !== "::1" && address !== "::ffff:127.0.0.1") return false;
1632
+ const host = request.headers.host;
1633
+ if (typeof host !== "string") return false;
1634
+ let hostUrl;
1635
+ try {
1636
+ hostUrl = new URL("http://" + host);
1637
+ } catch {
1638
+ return false;
1639
+ }
1640
+ if (hostUrl.hostname !== "127.0.0.1" && hostUrl.hostname !== "localhost" && hostUrl.hostname !== "[::1]") return false;
1641
+ if (request.headers["sec-fetch-site"] === "cross-site") return false;
1642
+ const origin = request.headers.origin;
1643
+ if (origin === void 0) return true;
1644
+ try {
1645
+ return new URL(origin).host === hostUrl.host;
1646
+ } catch {
1647
+ return false;
1648
+ }
1649
+ }
1650
+ /** One JSON response. */
1651
+ function writeJson(res, status, body) {
1652
+ const payload = JSON.stringify(body);
1653
+ res.writeHead(status, {
1654
+ "content-type": "application/json; charset=utf-8",
1655
+ "referrer-policy": "no-referrer"
1656
+ });
1657
+ res.end(payload);
1658
+ }
1659
+ /** One JSON error response. */
1660
+ function writeError(res, status, error) {
1661
+ writeJson(res, status, { error: error instanceof Error ? error.message : String(error) });
1662
+ }
1663
+ /** HTTP status per store business-rule kind (user error, not server fault). */
1664
+ const STORE_ERROR_STATUS = {
1665
+ validation: 400,
1666
+ "not-found": 404,
1667
+ conflict: 409
1668
+ };
1669
+ /** Map known error types onto HTTP statuses: repo fetch + store business rules. */
1670
+ function writeRouteError(res, error) {
1671
+ if (error instanceof RepoFetchError) {
1672
+ writeError(res, error.status, error);
1673
+ return;
1674
+ }
1675
+ if (error instanceof StoreError) {
1676
+ writeError(res, STORE_ERROR_STATUS[error.kind], error);
1677
+ return;
1678
+ }
1679
+ writeError(res, 500, error);
1680
+ }
1681
+ /** Read a JSON request body (undefined when too large or unparseable). */
1682
+ async function readJsonBody(req) {
1683
+ const chunks = [];
1684
+ let size = 0;
1685
+ for await (const chunk of req) {
1686
+ const buffer = chunk;
1687
+ size += buffer.length;
1688
+ if (size > MAX_JSON_BODY_BYTES) return void 0;
1689
+ chunks.push(buffer);
1690
+ }
1691
+ try {
1692
+ const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8"));
1693
+ return typeof parsed === "object" && parsed !== null ? parsed : void 0;
1694
+ } catch {
1695
+ return;
1696
+ }
1697
+ }
1698
+ /** URL query helper (first value, decoded). */
1699
+ function queryParam(url, name) {
1700
+ const value = url.searchParams.get(name);
1701
+ return value === null ? void 0 : value;
1702
+ }
1703
+ /** Async existence check (import/sync use it on the user root). */
1704
+ async function pathExists(path) {
1705
+ try {
1706
+ await stat(path);
1707
+ return true;
1708
+ } catch {
1709
+ return false;
1710
+ }
1711
+ }
1712
+ /** Sources the hub may toggle (the user-level filesystem roots). */
1713
+ function isWritableSource(source) {
1714
+ return source === "user-dsh" || source === "user-agents";
1715
+ }
1716
+ /** The resolved hub config a route sees (the shared resolver fills defaults). */
1717
+ function configOf(deps) {
1718
+ return resolveHubConfig({}, deps.config?.() ?? {});
1719
+ }
1720
+ /** The raw saved config layer a route reports (empty when the owner omits it). */
1721
+ function savedOf(deps) {
1722
+ return deps.saved?.() ?? {};
1723
+ }
1724
+ /** Refuse business routes while the master switch is off (the config route stays up). */
1725
+ function disabledGate(deps, res) {
1726
+ if (configOf(deps).enabled) return false;
1727
+ writeError(res, 503, "plugin disabled: enable it from the settings card");
1728
+ return true;
1729
+ }
1730
+ /** Resolve the home used for writable-root operations. */
1731
+ function homeOf(deps) {
1732
+ return deps.home ?? dshHome();
1733
+ }
1734
+ /**
1735
+ * Resolve a skill by name as something the hub may write, or the exact
1736
+ * refusal the caller should answer with. Owns the guard sequence shared by
1737
+ * toggle, toggle-batch, and skill/delete: registry lookup, writable source,
1738
+ * writable file, and path containment inside a user root.
1739
+ */
1740
+ async function resolveWritableSkill(deps, name, cwd) {
1741
+ const lookup = cwd !== void 0 && cwd !== "" ? { cwd } : void 0;
1742
+ const skill = await deps.skills.get(name, lookup);
1743
+ if (skill === void 0) return {
1744
+ ok: false,
1745
+ status: 404,
1746
+ error: "skill not found: " + name
1747
+ };
1748
+ if (!isWritableSource(skill.source)) return {
1749
+ ok: false,
1750
+ status: 409,
1751
+ error: "source \"" + skill.source + "\" is managed outside the hub (read-only)"
1752
+ };
1753
+ if (skill.path === void 0) return {
1754
+ ok: false,
1755
+ status: 409,
1756
+ error: "provider-managed skill has no writable file"
1757
+ };
1758
+ const root = rootOfPath(skill.path, homeOf(deps));
1759
+ if (root === void 0) return {
1760
+ ok: false,
1761
+ status: 409,
1762
+ error: "skill path is outside the hub writable roots"
1763
+ };
1764
+ return {
1765
+ ok: true,
1766
+ skill,
1767
+ path: skill.path,
1768
+ root
1769
+ };
1770
+ }
1771
+ /** 系统集合组 + 用户 tag + origin 映射(groups 路由的数据源)。 */
1772
+ async function buildGroups(deps) {
1773
+ const [tags, origins] = await Promise.all([deps.store.listTags(), deps.store.listOrigins()]);
1774
+ const byCollection = /* @__PURE__ */ new Map();
1775
+ for (const [skillName, origin] of Object.entries(origins)) {
1776
+ const list = byCollection.get(origin);
1777
+ if (list === void 0) byCollection.set(origin, [skillName]);
1778
+ else list.push(skillName);
1779
+ }
1780
+ return {
1781
+ ok: true,
1782
+ tags,
1783
+ collections: [...byCollection.entries()].map(([name, skillNames]) => ({
1784
+ name,
1785
+ skillNames: [...skillNames].sort((a, b) => a.localeCompare(b))
1786
+ })).sort((a, b) => a.name.localeCompare(b.name)),
1787
+ origins
1788
+ };
1789
+ }
1790
+ /** 目录中存在的技能名集合(tag 成员校验用):启用目录 ∪ 已禁用名单,避免成员因禁用而丢失。 */
1791
+ async function knownSkillNames(deps) {
1792
+ const snapshot = await deps.skills.snapshot();
1793
+ const names = new Set(snapshot.skills.map((skill) => skill.name));
1794
+ for (const disabled of await deps.store.listDisabled()) names.add(disabled.name);
1795
+ return names;
1796
+ }
1797
+ /** Build the full catalog response (shared by catalog/toggle/create handlers). */
1798
+ async function buildCatalog(deps, cwd) {
1799
+ const lookup = cwd !== void 0 ? { cwd } : void 0;
1800
+ const snapshot = await deps.skills.snapshot(lookup);
1801
+ const home = homeOf(deps);
1802
+ const timesByName = /* @__PURE__ */ new Map();
1803
+ await Promise.all(snapshot.skills.map(async (skill) => {
1804
+ if (!isWritableSource(skill.source)) return;
1805
+ const base = rootPath(skill.source, home);
1806
+ for (const candidate of [
1807
+ join(base, skill.name, "SKILL.md"),
1808
+ join(base, skill.name),
1809
+ join(base, skill.name + ".md")
1810
+ ]) try {
1811
+ const times = await stat(candidate);
1812
+ timesByName.set(skill.name, {
1813
+ addedAt: times.birthtimeMs,
1814
+ updatedAt: times.mtimeMs
1815
+ });
1816
+ return;
1817
+ } catch {}
1818
+ }));
1819
+ const skills = snapshot.skills.map((skill) => {
1820
+ const row = {
1821
+ name: skill.name,
1822
+ description: skill.description,
1823
+ ...skill.whenToUse !== void 0 ? { whenToUse: skill.whenToUse } : {},
1824
+ invocation: {
1825
+ modelInvocable: skill.invocation.modelInvocable,
1826
+ userInvocable: skill.invocation.userInvocable
1827
+ },
1828
+ provider: skill.provider,
1829
+ writable: isWritableSource(skill.source)
1830
+ };
1831
+ const times = timesByName.get(skill.name);
1832
+ if (times !== void 0) {
1833
+ row.addedAt = times.addedAt;
1834
+ row.updatedAt = times.updatedAt;
1835
+ }
1836
+ return row;
1837
+ });
1838
+ const disabled = await deps.store.listDisabled();
1839
+ const diagnostics = [...await scanDiagnostics("user-dsh", home), ...await scanDiagnostics("user-agents", home)];
1840
+ return {
1841
+ ok: true,
1842
+ complete: snapshot.complete,
1843
+ skills,
1844
+ disabled,
1845
+ diagnostics
1846
+ };
1847
+ }
1848
+ /** Map a loaded definition onto the wire shape. */
1849
+ function toDetail(skill) {
1850
+ return {
1851
+ name: skill.name,
1852
+ description: skill.description,
1853
+ ...skill.whenToUse !== void 0 ? { whenToUse: skill.whenToUse } : {},
1854
+ invocation: {
1855
+ modelInvocable: skill.invocation.modelInvocable,
1856
+ userInvocable: skill.invocation.userInvocable
1857
+ },
1858
+ provider: skill.provider,
1859
+ ...skill.path !== void 0 ? { path: skill.path } : {},
1860
+ content: skill.content
1861
+ };
1862
+ }
1863
+ /** Last successful check time per repo (in-memory throttle for GitHub rate limits). */
1864
+ const lastSourceCheck = /* @__PURE__ */ new Map();
1865
+ /** Last successful market update check per repo (same throttle). */
1866
+ const lastMarketCheck = /* @__PURE__ */ new Map();
1867
+ /** Replace one skill directory with a fresh download; restores the old dir on failure. */
1868
+ async function replaceSkillDir(targetDir, download) {
1869
+ const backup = join(dirname(targetDir), "." + basename(targetDir) + ".sync-bak-" + Date.now());
1870
+ await rename(targetDir, backup);
1871
+ try {
1872
+ await download();
1873
+ } catch (error) {
1874
+ await rename(backup, targetDir).catch(() => {});
1875
+ throw error;
1876
+ }
1877
+ await rm(backup, {
1878
+ recursive: true,
1879
+ force: true
1880
+ });
1881
+ }
1882
+ /**
1883
+ * Build every /api/skill-hub route.
1884
+ * @param deps - skill registry view + sidecar store.
1885
+ * @returns the exact-path routes.
1886
+ */
1887
+ function makeRoutes(deps) {
1888
+ /**
1889
+ * Wrap one handler in the shared request fences. Order matches the
1890
+ * original per-handler prologue: loopback trust → HTTP method → master
1891
+ * switch → JSON body. The outer catch maps errors onto the JSON error
1892
+ * body (preserving RepoFetchError status codes).
1893
+ */
1894
+ const route = (spec) => ({
1895
+ kind: "exact",
1896
+ path: spec.path,
1897
+ handler: async (req, res) => {
1898
+ if (!isLoopbackRequest(req)) {
1899
+ writeError(res, 403, "forbidden: loopback-only");
1900
+ return;
1901
+ }
1902
+ if (!spec.methods.includes(req.method)) {
1903
+ writeError(res, 405, "method not allowed: " + (req.method ?? ""));
1904
+ return;
1905
+ }
1906
+ if (spec.skipGate !== true && disabledGate(deps, res)) return;
1907
+ const url = new URL(req.url ?? "/", "http://localhost");
1908
+ let body = {};
1909
+ if (spec.jsonBody === true && req.method === "POST") {
1910
+ const parsed = await readJsonBody(req);
1911
+ if (parsed === void 0) {
1912
+ writeError(res, 400, "invalid JSON body");
1913
+ return;
1914
+ }
1915
+ body = parsed;
1916
+ }
1917
+ try {
1918
+ await spec.handler({
1919
+ req,
1920
+ res,
1921
+ url,
1922
+ body
1923
+ });
1924
+ } catch (error) {
1925
+ writeRouteError(res, error);
1926
+ }
1927
+ }
1928
+ });
1929
+ return [
1930
+ route({
1931
+ path: SKILL_HUB_API.catalog,
1932
+ methods: ["GET"],
1933
+ handler: async ({ res, url }) => {
1934
+ writeJson(res, 200, await buildCatalog(deps, queryParam(url, "cwd")));
1935
+ }
1936
+ }),
1937
+ route({
1938
+ path: SKILL_HUB_API.skill,
1939
+ methods: ["GET"],
1940
+ handler: async ({ res, url }) => {
1941
+ const name = queryParam(url, "name");
1942
+ if (name === void 0 || name === "") {
1943
+ writeError(res, 400, "name query parameter is required");
1944
+ return;
1945
+ }
1946
+ const cwd = queryParam(url, "cwd");
1947
+ const skill = await deps.skills.get(name, cwd !== void 0 ? { cwd } : void 0);
1948
+ if (skill === void 0) {
1949
+ writeError(res, 404, "skill not found: " + name);
1950
+ return;
1951
+ }
1952
+ const detail = toDetail(skill);
1953
+ if (skill.path !== void 0) try {
1954
+ const times = await stat(skill.path);
1955
+ detail.addedAt = times.birthtimeMs;
1956
+ detail.updatedAt = times.mtimeMs;
1957
+ } catch {}
1958
+ writeJson(res, 200, {
1959
+ ok: true,
1960
+ skill: detail
1961
+ });
1962
+ }
1963
+ }),
1964
+ route({
1965
+ path: SKILL_HUB_API.skillDelete,
1966
+ methods: ["POST"],
1967
+ jsonBody: true,
1968
+ handler: async ({ res, body }) => {
1969
+ const request = body;
1970
+ const name = typeof request.name === "string" ? request.name : "";
1971
+ if (name === "") {
1972
+ writeError(res, 400, "name is required");
1973
+ return;
1974
+ }
1975
+ const resolved = await resolveWritableSkill(deps, name, typeof request.cwd === "string" ? request.cwd : void 0);
1976
+ if (!resolved.ok) {
1977
+ writeError(res, resolved.status, resolved.error);
1978
+ return;
1979
+ }
1980
+ const tracked = await deps.store.getSourceForSkill(name);
1981
+ const tagIds = (await deps.store.listTags()).filter((tag) => tag.skillNames.includes(name)).map((tag) => tag.id);
1982
+ const { path, source } = await trashSkill(resolved.path);
1983
+ await deps.store.addTrash({
1984
+ name,
1985
+ path,
1986
+ movedAt: Date.now(),
1987
+ sourcePath: source,
1988
+ ...tracked !== void 0 ? { origin: {
1989
+ repo: tracked.repo,
1990
+ root: tracked.root,
1991
+ ...tracked.ref !== void 0 && tracked.ref !== "" ? { ref: tracked.ref } : {},
1992
+ commitSha: tracked.commitSha
1993
+ } } : {},
1994
+ ...tagIds.length > 0 ? { tagIds } : {}
1995
+ });
1996
+ await deps.store.removeDisabled(name);
1997
+ await deps.store.removeSkillFromSources(name);
1998
+ await deps.store.removeSkillFromTags(name);
1999
+ deps.invalidate?.();
2000
+ writeJson(res, 200, {
2001
+ ok: true,
2002
+ name,
2003
+ path
2004
+ });
2005
+ }
2006
+ }),
2007
+ route({
2008
+ path: SKILL_HUB_API.toggle,
2009
+ methods: ["POST"],
2010
+ jsonBody: true,
2011
+ handler: async ({ res, body }) => {
2012
+ const request = body;
2013
+ const name = typeof request.name === "string" ? request.name : "";
2014
+ if (name === "") {
2015
+ writeError(res, 400, "name is required");
2016
+ return;
2017
+ }
2018
+ const lookup = typeof request.cwd === "string" && request.cwd !== "" ? { cwd: request.cwd } : void 0;
2019
+ if (request.enabled === true) {
2020
+ const record = await deps.store.getDisabled(name);
2021
+ if (record === void 0) {
2022
+ writeError(res, 404, "skill is not hub-disabled: " + name);
2023
+ return;
2024
+ }
2025
+ try {
2026
+ await enableSkill(record.path);
2027
+ } catch (error) {
2028
+ if (error.code === "ENOENT") {
2029
+ writeError(res, 409, "disabled skill file is missing on disk: " + record.path);
2030
+ return;
2031
+ }
2032
+ throw error;
2033
+ }
2034
+ await deps.store.removeDisabled(name);
2035
+ } else {
2036
+ const resolved = await resolveWritableSkill(deps, name, typeof request.cwd === "string" ? request.cwd : void 0);
2037
+ if (!resolved.ok) {
2038
+ writeError(res, resolved.status, resolved.error);
2039
+ return;
2040
+ }
2041
+ const disabledPath = await disableSkill(resolved.path);
2042
+ await deps.store.addDisabled({
2043
+ name,
2044
+ description: resolved.skill.description,
2045
+ path: disabledPath,
2046
+ root: resolved.root,
2047
+ disabledAt: Date.now()
2048
+ });
2049
+ }
2050
+ deps.invalidate?.();
2051
+ writeJson(res, 200, {
2052
+ ok: true,
2053
+ catalog: await buildCatalog(deps, lookup?.cwd)
2054
+ });
2055
+ }
2056
+ }),
2057
+ route({
2058
+ path: SKILL_HUB_API.toggleBatch,
2059
+ methods: ["POST"],
2060
+ jsonBody: true,
2061
+ handler: async ({ res, body }) => {
2062
+ const request = body;
2063
+ const names = Array.isArray(request.names) ? request.names.filter((n) => typeof n === "string" && n !== "") : [];
2064
+ if (names.length === 0) {
2065
+ writeError(res, 400, "names must be a non-empty array");
2066
+ return;
2067
+ }
2068
+ const enabled = request.enabled === true;
2069
+ const lookup = typeof request.cwd === "string" && request.cwd !== "" ? { cwd: request.cwd } : void 0;
2070
+ const failures = [];
2071
+ for (const name of names) try {
2072
+ if (enabled) {
2073
+ const record = await deps.store.getDisabled(name);
2074
+ if (record === void 0) continue;
2075
+ await enableSkill(record.path);
2076
+ await deps.store.removeDisabled(name);
2077
+ } else {
2078
+ const resolved = await resolveWritableSkill(deps, name, typeof request.cwd === "string" ? request.cwd : void 0);
2079
+ if (!resolved.ok) {
2080
+ failures.push({
2081
+ name,
2082
+ error: resolved.error
2083
+ });
2084
+ continue;
2085
+ }
2086
+ const disabledPath = await disableSkill(resolved.path);
2087
+ await deps.store.addDisabled({
2088
+ name,
2089
+ description: resolved.skill.description,
2090
+ path: disabledPath,
2091
+ root: resolved.root,
2092
+ disabledAt: Date.now()
2093
+ });
2094
+ }
2095
+ } catch (error) {
2096
+ failures.push({
2097
+ name,
2098
+ error: error instanceof Error ? error.message : String(error)
2099
+ });
2100
+ }
2101
+ deps.invalidate?.();
2102
+ writeJson(res, 200, {
2103
+ ok: true,
2104
+ catalog: await buildCatalog(deps, lookup?.cwd),
2105
+ failures
2106
+ });
2107
+ }
2108
+ }),
2109
+ route({
2110
+ path: SKILL_HUB_API.create,
2111
+ methods: ["POST"],
2112
+ jsonBody: true,
2113
+ handler: async ({ res, body }) => {
2114
+ const request = body;
2115
+ const name = typeof request.name === "string" ? request.name.trim() : "";
2116
+ if (!isSkillName(name)) {
2117
+ writeError(res, 400, "skill name must be kebab-case (lowercase letters, digits, dashes)");
2118
+ return;
2119
+ }
2120
+ const root = request.root ?? "user-dsh";
2121
+ if (root !== "user-dsh" && root !== "user-agents") {
2122
+ writeError(res, 400, "root must be user-dsh or user-agents");
2123
+ return;
2124
+ }
2125
+ if (await deps.skills.get(name) !== void 0) {
2126
+ writeError(res, 409, "skill name already exists: " + name);
2127
+ return;
2128
+ }
2129
+ if (await deps.store.getDisabled(name) !== void 0) {
2130
+ writeError(res, 409, "skill name is disabled: re-enable it from the disabled list first");
2131
+ return;
2132
+ }
2133
+ if (await pathExists(join(rootPath(root, homeOf(deps)), name))) {
2134
+ writeError(res, 409, "skill directory already exists on disk: " + name + " (check the discovery diagnostics)");
2135
+ return;
2136
+ }
2137
+ const path = await createSkill(root, name, typeof request.description === "string" ? request.description : "", homeOf(deps));
2138
+ const defaultTag = await deps.store.getDefaultTag();
2139
+ if (defaultTag !== void 0) await deps.store.addSkillToTag(defaultTag.id, name);
2140
+ deps.invalidate?.();
2141
+ writeJson(res, 201, {
2142
+ ok: true,
2143
+ path,
2144
+ root
2145
+ });
2146
+ }
2147
+ }),
2148
+ route({
2149
+ path: SKILL_HUB_API.stats,
2150
+ methods: ["GET"],
2151
+ handler: async ({ res }) => {
2152
+ if (deps.stats === void 0) {
2153
+ writeJson(res, 200, {
2154
+ ok: true,
2155
+ available: false,
2156
+ stats: []
2157
+ });
2158
+ return;
2159
+ }
2160
+ writeJson(res, 200, {
2161
+ ok: true,
2162
+ available: true,
2163
+ stats: await deps.stats()
2164
+ });
2165
+ }
2166
+ }),
2167
+ route({
2168
+ path: SKILL_HUB_API.config,
2169
+ methods: ["GET", "POST"],
2170
+ jsonBody: true,
2171
+ skipGate: true,
2172
+ handler: async ({ req, res, body }) => {
2173
+ if (req.method === "GET") {
2174
+ writeJson(res, 200, {
2175
+ ok: true,
2176
+ config: configOf(deps),
2177
+ saved: savedOf(deps)
2178
+ });
2179
+ return;
2180
+ }
2181
+ const raw = body;
2182
+ const patch = {};
2183
+ if (raw.enabled !== void 0) if (raw.enabled === null) patch.enabled = void 0;
2184
+ else if (typeof raw.enabled !== "boolean") {
2185
+ writeError(res, 400, "enabled must be a boolean or null");
2186
+ return;
2187
+ } else patch.enabled = raw.enabled;
2188
+ if (raw.announceToAgent !== void 0) if (raw.announceToAgent === null) patch.announceToAgent = void 0;
2189
+ else if (typeof raw.announceToAgent !== "boolean") {
2190
+ writeError(res, 400, "announceToAgent must be a boolean or null");
2191
+ return;
2192
+ } else patch.announceToAgent = raw.announceToAgent;
2193
+ for (const key of [
2194
+ "showUseCount",
2195
+ "showUseTime",
2196
+ "showGroupSummary"
2197
+ ]) {
2198
+ const value = raw[key];
2199
+ if (value === void 0) continue;
2200
+ if (value === null) {
2201
+ patch[key] = void 0;
2202
+ continue;
2203
+ }
2204
+ if (typeof value !== "boolean") {
2205
+ writeError(res, 400, key + " must be a boolean or null");
2206
+ return;
2207
+ }
2208
+ patch[key] = value;
2209
+ }
2210
+ for (const key of ["dotModelColor", "dotUserColor"]) {
2211
+ const value = raw[key];
2212
+ if (value === void 0) continue;
2213
+ if (value === null) {
2214
+ patch[key] = void 0;
2215
+ continue;
2216
+ }
2217
+ if (typeof value !== "string" || !HEX_COLOR_RE.test(value)) {
2218
+ writeError(res, 400, key + " must be a #rrggbb color or null");
2219
+ return;
2220
+ }
2221
+ patch[key] = value;
2222
+ }
2223
+ let config;
2224
+ if (deps.updateConfig === void 0) {
2225
+ const merged = { ...configOf(deps) };
2226
+ for (const [key, value] of Object.entries(patch)) if (value === void 0) delete merged[key];
2227
+ else merged[key] = value;
2228
+ config = merged;
2229
+ } else config = await deps.updateConfig(patch);
2230
+ writeJson(res, 200, {
2231
+ ok: true,
2232
+ config,
2233
+ saved: savedOf(deps)
2234
+ });
2235
+ }
2236
+ }),
2237
+ route({
2238
+ path: SKILL_HUB_API.market,
2239
+ methods: ["GET"],
2240
+ handler: async ({ res }) => {
2241
+ writeJson(res, 200, {
2242
+ ok: true,
2243
+ repos: await deps.store.listMarketSources()
2244
+ });
2245
+ }
2246
+ }),
2247
+ route({
2248
+ path: SKILL_HUB_API.marketSource,
2249
+ methods: ["POST"],
2250
+ jsonBody: true,
2251
+ handler: async ({ res, body }) => {
2252
+ const request = body;
2253
+ const parsed = normalizeRepoInput(typeof request.repo === "string" ? request.repo.trim() : "");
2254
+ if (parsed === null) {
2255
+ writeError(res, 400, "repo must be owner/repo or a github.com URL");
2256
+ return;
2257
+ }
2258
+ const ref = parsed.ref !== void 0 ? parsed.ref : void 0;
2259
+ writeJson(res, 200, {
2260
+ ok: true,
2261
+ repos: await deps.store.addMarketSource(repoSlug(parsed), ref)
2262
+ });
2263
+ }
2264
+ }),
2265
+ route({
2266
+ path: SKILL_HUB_API.marketSourceDelete,
2267
+ methods: ["POST"],
2268
+ jsonBody: true,
2269
+ handler: async ({ res, body }) => {
2270
+ const request = body;
2271
+ const parsed = normalizeRepoInput(typeof request.repo === "string" ? request.repo.trim() : "");
2272
+ if (parsed === null) {
2273
+ writeError(res, 400, "repo must be owner/repo or a github.com URL");
2274
+ return;
2275
+ }
2276
+ writeJson(res, 200, {
2277
+ ok: true,
2278
+ repos: await deps.store.removeMarketSource(repoSlug(parsed))
2279
+ });
2280
+ }
2281
+ }),
2282
+ route({
2283
+ path: SKILL_HUB_API.marketSourceRef,
2284
+ methods: ["POST"],
2285
+ jsonBody: true,
2286
+ handler: async ({ res, body }) => {
2287
+ const request = body;
2288
+ const parsed = normalizeRepoInput(typeof request.repo === "string" ? request.repo : "");
2289
+ const ref = typeof request.ref === "string" ? request.ref.trim() : "";
2290
+ if (parsed === null) {
2291
+ writeError(res, 400, "repo must be owner/repo or a github.com URL");
2292
+ return;
2293
+ }
2294
+ if (ref === "") {
2295
+ writeError(res, 400, "ref is required");
2296
+ return;
2297
+ }
2298
+ if (await deps.store.setMarketSourceRef(repoSlug(parsed), ref) === void 0) {
2299
+ writeError(res, 404, "market source not found: " + repoSlug(parsed));
2300
+ return;
2301
+ }
2302
+ writeJson(res, 200, {
2303
+ ok: true,
2304
+ repos: await deps.store.listMarketSources()
2305
+ });
2306
+ }
2307
+ }),
2308
+ route({
2309
+ path: SKILL_HUB_API.marketCheck,
2310
+ methods: ["GET"],
2311
+ handler: async ({ res }) => {
2312
+ const sources = await deps.store.listMarketSources();
2313
+ if (lastMarketCheck.size > 500) lastMarketCheck.clear();
2314
+ const results = [];
2315
+ for (const source of sources) {
2316
+ const base = {
2317
+ repo: source.repo,
2318
+ ...source.ref !== void 0 ? { ref: source.ref } : {}
2319
+ };
2320
+ const now = Date.now();
2321
+ if (now - (lastMarketCheck.get(source.repo) ?? 0) < MIN_CHECK_INTERVAL_MS) {
2322
+ results.push({
2323
+ ...base,
2324
+ updateAvailable: false,
2325
+ commitSha: source.commitSha ?? "",
2326
+ throttled: true
2327
+ });
2328
+ continue;
2329
+ }
2330
+ try {
2331
+ const latestTag = await getLatestReleaseTag(source.repo);
2332
+ if (source.ref === void 0) {
2333
+ results.push({
2334
+ ...base,
2335
+ updateAvailable: false,
2336
+ commitSha: source.commitSha ?? "",
2337
+ ...latestTag !== void 0 ? { latestTag } : {}
2338
+ });
2339
+ continue;
2340
+ }
2341
+ const latest = await getLatestCommit(source.repo, source.ref);
2342
+ lastMarketCheck.set(source.repo, now);
2343
+ const commitMoved = source.commitSha !== void 0 && latest.commitSha !== source.commitSha;
2344
+ const newRelease = latestTag !== void 0 && latestTag !== source.ref;
2345
+ results.push({
2346
+ ...base,
2347
+ updateAvailable: commitMoved || newRelease,
2348
+ commitSha: latest.commitSha,
2349
+ ...newRelease ? { latestTag } : {}
2350
+ });
2351
+ } catch (error) {
2352
+ results.push({
2353
+ ...base,
2354
+ updateAvailable: false,
2355
+ commitSha: source.commitSha ?? "",
2356
+ error: error instanceof Error ? error.message : String(error)
2357
+ });
2358
+ }
2359
+ }
2360
+ writeJson(res, 200, {
2361
+ ok: true,
2362
+ results
2363
+ });
2364
+ }
2365
+ }),
2366
+ route({
2367
+ path: SKILL_HUB_API.marketSync,
2368
+ methods: ["POST"],
2369
+ jsonBody: true,
2370
+ handler: async ({ res, body }) => {
2371
+ const request = body;
2372
+ const parsed = normalizeRepoInput(typeof request.repo === "string" ? request.repo : "");
2373
+ if (parsed === null) {
2374
+ writeError(res, 400, "repo must be owner/repo or a github.com URL");
2375
+ return;
2376
+ }
2377
+ const repo = repoSlug(parsed);
2378
+ const source = await deps.store.getMarketSource(repo);
2379
+ if (source === void 0) {
2380
+ writeError(res, 404, "market source not found: " + repo);
2381
+ return;
2382
+ }
2383
+ let ref = source.ref;
2384
+ if (ref === void 0 || ref === "") {
2385
+ const latestTag = await getLatestReleaseTag(repo);
2386
+ if (latestTag === void 0) {
2387
+ writeError(res, 409, "repo has no release — pick a branch first");
2388
+ return;
2389
+ }
2390
+ ref = latestTag;
2391
+ await deps.store.setMarketSourceRef(repo, ref);
2392
+ }
2393
+ const latest = await getLatestCommit(repo, ref);
2394
+ await deps.store.setMarketSourceCommit(repo, latest.commitSha);
2395
+ await deps.store.setSourceRef(repo, ref);
2396
+ const tracked = await deps.store.getSource(repo);
2397
+ writeJson(res, 200, {
2398
+ ok: true,
2399
+ repo,
2400
+ ref,
2401
+ commitSha: latest.commitSha,
2402
+ skills: tracked !== void 0 ? [...tracked.skills] : []
2403
+ });
2404
+ }
2405
+ }),
2406
+ route({
2407
+ path: SKILL_HUB_API.repo,
2408
+ methods: ["GET"],
2409
+ handler: async ({ res, url }) => {
2410
+ const parsed = normalizeRepoInput(queryParam(url, "repo") ?? "");
2411
+ if (parsed === null) {
2412
+ writeError(res, 400, "repo must be owner/repo or a github.com URL");
2413
+ return;
2414
+ }
2415
+ const repo = repoSlug(parsed);
2416
+ let ref = parsed.ref;
2417
+ if (ref === void 0 || ref === "") {
2418
+ const pinned = await deps.store.getMarketSource(repo);
2419
+ if (pinned?.ref !== void 0 && pinned.ref !== "") ref = pinned.ref;
2420
+ }
2421
+ if (ref === void 0 || ref === "") {
2422
+ const releaseTag = await getLatestReleaseTag(repo);
2423
+ if (releaseTag !== void 0) ref = releaseTag;
2424
+ else {
2425
+ const branches = await listRepoBranches(repo);
2426
+ if (branches.length === 0) {
2427
+ writeError(res, 404, "repo has no branches to scan");
2428
+ return;
2429
+ }
2430
+ writeJson(res, 200, {
2431
+ ok: true,
2432
+ repo,
2433
+ ref: null,
2434
+ branches,
2435
+ entries: []
2436
+ });
2437
+ return;
2438
+ }
2439
+ }
2440
+ const { ref: resolvedRef, tree } = await loadRepoTree(repo, ref);
2441
+ writeJson(res, 200, {
2442
+ ok: true,
2443
+ repo,
2444
+ ref: resolvedRef,
2445
+ entries: discoverRepoEntries(tree, repo, await knownSkillNames(deps))
2446
+ });
2447
+ }
2448
+ }),
2449
+ route({
2450
+ path: SKILL_HUB_API.repoImport,
2451
+ methods: ["POST"],
2452
+ jsonBody: true,
2453
+ handler: async ({ res, body }) => {
2454
+ const request = body;
2455
+ const input = typeof request.repo === "string" ? request.repo.trim() : "";
2456
+ const paths = Array.isArray(request.paths) ? request.paths.filter((path) => typeof path === "string" && path !== "") : [];
2457
+ if (paths.length === 0) {
2458
+ writeError(res, 400, "paths must be a non-empty array");
2459
+ return;
2460
+ }
2461
+ const parsed = normalizeRepoInput(input);
2462
+ if (parsed === null) {
2463
+ writeError(res, 400, "repo must be owner/repo or a github.com URL");
2464
+ return;
2465
+ }
2466
+ const repo = repoSlug(parsed);
2467
+ const { ref: resolvedRef, tree } = await loadRepoTree(repo, typeof request.ref === "string" && request.ref !== "" ? request.ref : parsed.ref);
2468
+ const entries = discoverRepoEntries(tree, repo, await knownSkillNames(deps));
2469
+ const byPath = new Map(entries.map((entry) => [entry.path, entry]));
2470
+ const selected = paths.map((path) => byPath.get(path)).filter((entry) => entry !== void 0);
2471
+ if (selected.length > 500) {
2472
+ writeError(res, 400, "select at most 500 skills per import");
2473
+ return;
2474
+ }
2475
+ if (selected.reduce((sum, entry) => sum + entry.totalBytes, 0) > 200 * 1024 * 1024) {
2476
+ writeError(res, 400, "selected skills exceed the 200MB import limit");
2477
+ return;
2478
+ }
2479
+ let commitSha = "";
2480
+ try {
2481
+ commitSha = (await getLatestCommit(repo, resolvedRef)).commitSha;
2482
+ } catch {}
2483
+ const imported = [];
2484
+ const skipped = [];
2485
+ const failed = [];
2486
+ const targetRoot = rootPath("user-dsh", homeOf(deps));
2487
+ await mkdir(targetRoot, { recursive: true });
2488
+ const defaultTag = await deps.store.getDefaultTag();
2489
+ for (const entry of selected) {
2490
+ if (entry.existing) {
2491
+ skipped.push({
2492
+ name: entry.name,
2493
+ reason: "exists"
2494
+ });
2495
+ continue;
2496
+ }
2497
+ if (await pathExists(join(targetRoot, entry.name))) {
2498
+ skipped.push({
2499
+ name: entry.name,
2500
+ reason: "exists"
2501
+ });
2502
+ continue;
2503
+ }
2504
+ const files = collectRepoSkillFiles(tree, entry.dir);
2505
+ try {
2506
+ const result = await downloadRepoSkill(repo, resolvedRef, entry, files, targetRoot);
2507
+ await deps.store.addSourceSkill(repo, entry.root, commitSha, resolvedRef, entry.name);
2508
+ await deps.store.mergeSourceManifest(repo, skillManifest(tree, entry.dir), entry.dir);
2509
+ if (defaultTag !== void 0) await deps.store.addSkillToTag(defaultTag.id, entry.name);
2510
+ imported.push({
2511
+ name: entry.name,
2512
+ origin: entry.origin,
2513
+ path: result.skillPath
2514
+ });
2515
+ } catch (error) {
2516
+ failed.push({
2517
+ name: entry.name,
2518
+ error: error instanceof Error ? error.message : String(error)
2519
+ });
2520
+ }
2521
+ }
2522
+ deps.invalidate?.();
2523
+ writeJson(res, 200, {
2524
+ ok: true,
2525
+ imported,
2526
+ skipped,
2527
+ failed
2528
+ });
2529
+ }
2530
+ }),
2531
+ route({
2532
+ path: SKILL_HUB_API.update,
2533
+ methods: ["GET"],
2534
+ handler: async ({ res }) => {
2535
+ writeJson(res, 200, await checkLatestRelease());
2536
+ }
2537
+ }),
2538
+ route({
2539
+ path: SKILL_HUB_API.groups,
2540
+ methods: ["GET"],
2541
+ handler: async ({ res }) => {
2542
+ writeJson(res, 200, await buildGroups(deps));
2543
+ }
2544
+ }),
2545
+ route({
2546
+ path: SKILL_HUB_API.tag,
2547
+ methods: ["POST"],
2548
+ jsonBody: true,
2549
+ handler: async ({ res, body }) => {
2550
+ const name = typeof body.name === "string" ? body.name.trim() : "";
2551
+ if (name === "") {
2552
+ writeError(res, 400, "tag name is required");
2553
+ return;
2554
+ }
2555
+ const id = typeof body.id === "string" && body.id !== "" ? body.id : void 0;
2556
+ await deps.store.saveTag({
2557
+ id,
2558
+ name
2559
+ });
2560
+ writeJson(res, 200, {
2561
+ ok: true,
2562
+ tags: await deps.store.listTags()
2563
+ });
2564
+ }
2565
+ }),
2566
+ route({
2567
+ path: SKILL_HUB_API.tagDelete,
2568
+ methods: ["POST"],
2569
+ jsonBody: true,
2570
+ handler: async ({ res, body }) => {
2571
+ const id = typeof body.id === "string" ? body.id : "";
2572
+ if (id === "") {
2573
+ writeError(res, 400, "tag id is required");
2574
+ return;
2575
+ }
2576
+ if ((await deps.store.getTag(id))?.default === true) {
2577
+ writeError(res, 409, "the default scene cannot be deleted");
2578
+ return;
2579
+ }
2580
+ await deps.store.deleteTag(id);
2581
+ writeJson(res, 200, {
2582
+ ok: true,
2583
+ tags: await deps.store.listTags()
2584
+ });
2585
+ }
2586
+ }),
2587
+ route({
2588
+ path: SKILL_HUB_API.tagMembers,
2589
+ methods: ["POST"],
2590
+ jsonBody: true,
2591
+ handler: async ({ res, body }) => {
2592
+ const request = body;
2593
+ const id = typeof request.id === "string" ? request.id : "";
2594
+ if (id === "") {
2595
+ writeError(res, 400, "tag id is required");
2596
+ return;
2597
+ }
2598
+ const names = Array.isArray(request.skillNames) ? request.skillNames.filter((n) => typeof n === "string") : [];
2599
+ const known = await knownSkillNames(deps);
2600
+ if (await deps.store.setTagMembers(id, names.filter((n) => known.has(n))) === void 0) {
2601
+ writeError(res, 404, "tag not found: " + id);
2602
+ return;
2603
+ }
2604
+ writeJson(res, 200, {
2605
+ ok: true,
2606
+ tags: await deps.store.listTags()
2607
+ });
2608
+ }
2609
+ }),
2610
+ route({
2611
+ path: SKILL_HUB_API.sources,
2612
+ methods: ["GET"],
2613
+ handler: async ({ res }) => {
2614
+ const [sources, origins, trash] = await Promise.all([
2615
+ deps.store.listSources(),
2616
+ deps.store.listOrigins(),
2617
+ deps.store.listTrash()
2618
+ ]);
2619
+ const byCollection = /* @__PURE__ */ new Map();
2620
+ for (const [skillName, origin] of Object.entries(origins)) {
2621
+ const list = byCollection.get(origin);
2622
+ if (list === void 0) byCollection.set(origin, [skillName]);
2623
+ else list.push(skillName);
2624
+ }
2625
+ writeJson(res, 200, {
2626
+ ok: true,
2627
+ sources,
2628
+ origins,
2629
+ collections: [...byCollection.entries()].map(([name, skillNames]) => ({
2630
+ name,
2631
+ skillNames: [...skillNames].sort((a, b) => a.localeCompare(b))
2632
+ })).sort((a, b) => a.name.localeCompare(b.name)),
2633
+ trash
2634
+ });
2635
+ }
2636
+ }),
2637
+ route({
2638
+ path: SKILL_HUB_API.sourceCheck,
2639
+ methods: ["POST"],
2640
+ jsonBody: true,
2641
+ handler: async ({ res, body }) => {
2642
+ const request = body;
2643
+ let only;
2644
+ if (typeof request.repo === "string" && request.repo !== "") {
2645
+ const parsedOnly = normalizeRepoInput(request.repo);
2646
+ only = parsedOnly !== null ? repoSlug(parsedOnly) : void 0;
2647
+ }
2648
+ const source = only !== void 0 ? await deps.store.getSource(only) : void 0;
2649
+ if (only !== void 0 && source === void 0) {
2650
+ writeError(res, 404, "source not found: " + only);
2651
+ return;
2652
+ }
2653
+ const sources = source !== void 0 ? [source] : await deps.store.listSources();
2654
+ if (lastSourceCheck.size > 500) lastSourceCheck.clear();
2655
+ const results = [];
2656
+ for (const item of sources) {
2657
+ const base = {
2658
+ repo: item.repo,
2659
+ ...item.ref !== void 0 ? { ref: item.ref } : {}
2660
+ };
2661
+ const now = Date.now();
2662
+ if (now - (lastSourceCheck.get(item.repo) ?? 0) < MIN_CHECK_INTERVAL_MS) {
2663
+ results.push({
2664
+ ...base,
2665
+ changed: false,
2666
+ updated: [],
2667
+ deleted: [],
2668
+ throttled: true
2669
+ });
2670
+ continue;
2671
+ }
2672
+ try {
2673
+ const latest = await getLatestCommit(item.repo, item.ref);
2674
+ lastSourceCheck.set(item.repo, now);
2675
+ if (item.commitSha === "") {
2676
+ await deps.store.setSourceCommit(item.repo, latest.commitSha);
2677
+ results.push({
2678
+ ...base,
2679
+ changed: false,
2680
+ updated: [],
2681
+ deleted: [],
2682
+ unverified: true,
2683
+ commitSha: latest.commitSha
2684
+ });
2685
+ continue;
2686
+ }
2687
+ if (latest.commitSha === item.commitSha) {
2688
+ results.push({
2689
+ ...base,
2690
+ changed: false,
2691
+ updated: [],
2692
+ deleted: []
2693
+ });
2694
+ continue;
2695
+ }
2696
+ const diff = diffRemoteSkills(await loadRepoTreeAt(item.repo, latest.treeSha), item);
2697
+ results.push({
2698
+ ...base,
2699
+ changed: true,
2700
+ commitSha: latest.commitSha,
2701
+ updated: diff.updated,
2702
+ deleted: diff.deleted
2703
+ });
2704
+ } catch (error) {
2705
+ results.push({
2706
+ ...base,
2707
+ changed: false,
2708
+ updated: [],
2709
+ deleted: [],
2710
+ error: error instanceof Error ? error.message : String(error)
2711
+ });
2712
+ }
2713
+ }
2714
+ writeJson(res, 200, {
2715
+ ok: true,
2716
+ results
2717
+ });
2718
+ }
2719
+ }),
2720
+ route({
2721
+ path: SKILL_HUB_API.sourceSync,
2722
+ methods: ["POST"],
2723
+ jsonBody: true,
2724
+ handler: async ({ res, body }) => {
2725
+ const request = body;
2726
+ const repo = typeof request.repo === "string" ? request.repo.trim() : "";
2727
+ if (repo === "") {
2728
+ writeError(res, 400, "repo is required");
2729
+ return;
2730
+ }
2731
+ const selected = Array.isArray(request.skills) ? request.skills.filter((n) => typeof n === "string" && n !== "") : void 0;
2732
+ const source = await deps.store.getSource(repo);
2733
+ if (source === void 0) {
2734
+ writeError(res, 404, "source not found: " + repo);
2735
+ return;
2736
+ }
2737
+ const targets = selected !== void 0 ? selected : source.skills;
2738
+ const latest = await getLatestCommit(repo, source.ref);
2739
+ const tree = await loadRepoTreeAt(repo, latest.treeSha);
2740
+ const targetRoot = rootPath("user-dsh", homeOf(deps));
2741
+ await mkdir(targetRoot, { recursive: true });
2742
+ const synced = [];
2743
+ const failed = [];
2744
+ for (const name of targets) {
2745
+ if (!isSkillName(name) || !source.skills.includes(name)) {
2746
+ failed.push({
2747
+ name,
2748
+ error: "skill is not tracked by this source"
2749
+ });
2750
+ continue;
2751
+ }
2752
+ try {
2753
+ const entry = repoSkillEntry(name, source.root, repo);
2754
+ entry.dir = skillDirOf(source, name, tree.map((item) => item.path));
2755
+ entry.path = entry.dir + "/SKILL.md";
2756
+ const files = collectRepoSkillFiles(tree, entry.dir);
2757
+ if (files.length === 0) {
2758
+ failed.push({
2759
+ name,
2760
+ error: "skill missing upstream"
2761
+ });
2762
+ continue;
2763
+ }
2764
+ const targetDir = join(targetRoot, name);
2765
+ const wasDisabled = await deps.store.getDisabled(name) !== void 0;
2766
+ if (await pathExists(targetDir)) await replaceSkillDir(targetDir, async () => {
2767
+ await downloadRepoSkill(repo, latest.commitSha, entry, files, targetRoot);
2768
+ });
2769
+ else await downloadRepoSkill(repo, latest.commitSha, entry, files, targetRoot);
2770
+ if (wasDisabled) await rename(join(targetDir, "SKILL.md"), join(targetDir, "SKILL.md.disabled"));
2771
+ else await deps.store.removeDisabled(name);
2772
+ await deps.store.mergeSourceManifest(repo, skillManifest(tree, entry.dir), entry.dir);
2773
+ synced.push(name);
2774
+ } catch (error) {
2775
+ failed.push({
2776
+ name,
2777
+ error: error instanceof Error ? error.message : String(error)
2778
+ });
2779
+ }
2780
+ }
2781
+ if (failed.length === 0) await deps.store.setSourceCommit(repo, latest.commitSha);
2782
+ deps.invalidate?.();
2783
+ writeJson(res, 200, {
2784
+ ok: true,
2785
+ repo,
2786
+ commitSha: latest.commitSha,
2787
+ synced,
2788
+ failed
2789
+ });
2790
+ }
2791
+ }),
2792
+ route({
2793
+ path: SKILL_HUB_API.sourceDelete,
2794
+ methods: ["POST"],
2795
+ jsonBody: true,
2796
+ handler: async ({ res, body }) => {
2797
+ const request = body;
2798
+ const repo = typeof request.repo === "string" ? request.repo.trim() : "";
2799
+ const skills = Array.isArray(request.skills) ? request.skills.filter((n) => typeof n === "string" && n !== "") : [];
2800
+ if (repo === "") {
2801
+ writeError(res, 400, "repo is required");
2802
+ return;
2803
+ }
2804
+ if (skills.length === 0) {
2805
+ writeError(res, 400, "skills must be a non-empty array");
2806
+ return;
2807
+ }
2808
+ const source = await deps.store.getSource(repo);
2809
+ if (source === void 0) {
2810
+ writeError(res, 404, "source not found: " + repo);
2811
+ return;
2812
+ }
2813
+ const home = homeOf(deps);
2814
+ const trashed = [];
2815
+ const failed = [];
2816
+ const sourceNames = new Set(source.skills);
2817
+ for (const name of skills) {
2818
+ if (!isSkillName(name) || !sourceNames.has(name)) {
2819
+ failed.push({
2820
+ name,
2821
+ error: "skill is not tracked by this source"
2822
+ });
2823
+ continue;
2824
+ }
2825
+ const sourcePath = join(rootPath("user-dsh", home), name);
2826
+ if (rootOfPath(sourcePath, home) === void 0) {
2827
+ failed.push({
2828
+ name,
2829
+ error: "skill path is outside the hub writable roots"
2830
+ });
2831
+ continue;
2832
+ }
2833
+ if (!await pathExists(sourcePath)) {
2834
+ failed.push({
2835
+ name,
2836
+ error: "skill directory not found"
2837
+ });
2838
+ continue;
2839
+ }
2840
+ try {
2841
+ const tagIds = (await deps.store.listTags()).filter((tag) => tag.skillNames.includes(name)).map((tag) => tag.id);
2842
+ const { path } = await trashSkill(sourcePath);
2843
+ await deps.store.addTrash({
2844
+ name,
2845
+ path,
2846
+ movedAt: Date.now(),
2847
+ sourcePath,
2848
+ origin: {
2849
+ repo: source.repo,
2850
+ root: source.root,
2851
+ ...source.ref !== void 0 && source.ref !== "" ? { ref: source.ref } : {},
2852
+ commitSha: source.commitSha
2853
+ },
2854
+ ...tagIds.length > 0 ? { tagIds } : {}
2855
+ });
2856
+ await deps.store.removeDisabled(name);
2857
+ await deps.store.removeSkillFromTags(name);
2858
+ trashed.push(name);
2859
+ } catch (error) {
2860
+ failed.push({
2861
+ name,
2862
+ error: error instanceof Error ? error.message : String(error)
2863
+ });
2864
+ }
2865
+ }
2866
+ if (trashed.length > 0) {
2867
+ await deps.store.setSourceSkills(repo, source.skills.filter((n) => !trashed.includes(n)));
2868
+ deps.invalidate?.();
2869
+ }
2870
+ writeJson(res, 200, {
2871
+ ok: true,
2872
+ trashed,
2873
+ failed
2874
+ });
2875
+ }
2876
+ }),
2877
+ route({
2878
+ path: SKILL_HUB_API.sourceRestore,
2879
+ methods: ["POST"],
2880
+ jsonBody: true,
2881
+ handler: async ({ res, body }) => {
2882
+ const request = body;
2883
+ const name = typeof request.name === "string" ? request.name : "";
2884
+ if (name === "") {
2885
+ writeError(res, 400, "name is required");
2886
+ return;
2887
+ }
2888
+ const entry = await deps.store.getTrash(name);
2889
+ if (entry === void 0) {
2890
+ writeError(res, 404, "trash entry not found: " + name);
2891
+ return;
2892
+ }
2893
+ const home = homeOf(deps);
2894
+ if (await pathExists(entry.sourcePath ?? join(rootPath("user-dsh", home), name))) {
2895
+ writeError(res, 409, "skill already exists: " + name);
2896
+ return;
2897
+ }
2898
+ const path = await restoreSkill(entry, home);
2899
+ if (entry.origin !== void 0) await deps.store.addSourceSkill(entry.origin.repo, entry.origin.root, entry.origin.commitSha, entry.origin.ref, name);
2900
+ for (const tagId of entry.tagIds ?? []) await deps.store.addSkillToTag(tagId, name);
2901
+ await deps.store.removeTrash(name);
2902
+ deps.invalidate?.();
2903
+ writeJson(res, 200, {
2904
+ ok: true,
2905
+ name,
2906
+ path
2907
+ });
2908
+ }
2909
+ }),
2910
+ route({
2911
+ path: SKILL_HUB_API.sourceTrashClear,
2912
+ methods: ["POST"],
2913
+ handler: async ({ res }) => {
2914
+ const home = homeOf(deps);
2915
+ const entries = await deps.store.listTrash();
2916
+ const deleted = [];
2917
+ const failed = [];
2918
+ for (const entry of entries) try {
2919
+ await clearTrash(entry, home);
2920
+ await deps.store.removeSkillFromTags(entry.name);
2921
+ deleted.push(entry.name);
2922
+ } catch (error) {
2923
+ failed.push({
2924
+ name: entry.name,
2925
+ error: error instanceof Error ? error.message : String(error)
2926
+ });
2927
+ }
2928
+ for (const name of deleted) await deps.store.removeTrash(name);
2929
+ if (deleted.length > 0) deps.invalidate?.();
2930
+ writeJson(res, 200, {
2931
+ ok: true,
2932
+ deleted,
2933
+ failed
2934
+ });
2935
+ }
2936
+ })
2937
+ ];
2938
+ }
2939
+ //#endregion
2940
+ //#region src/stats.ts
2941
+ /** Collect per-skill invocation counts and last-used times from one session. */
2942
+ function countSkillInvocations(events) {
2943
+ const stats = /* @__PURE__ */ new Map();
2944
+ const bump = (name, time) => {
2945
+ if (name === "") return;
2946
+ const current = stats.get(name);
2947
+ if (current === void 0) stats.set(name, {
2948
+ count: 1,
2949
+ lastUsed: time
2950
+ });
2951
+ else {
2952
+ current.count += 1;
2953
+ if (time > current.lastUsed) current.lastUsed = time;
2954
+ }
2955
+ };
2956
+ for (const event of events) if (event.type === "user/message") {
2957
+ const source = event.data.source;
2958
+ if (source.kind === "skill-invocation") bump(source.name, event.time);
2959
+ } else if (event.type === "tool/call") {
2960
+ const call = event.data;
2961
+ if (call.name === "skill") try {
2962
+ const parsed = JSON.parse(call.arguments);
2963
+ if (typeof parsed === "object" && parsed !== null) {
2964
+ const name = parsed.name;
2965
+ if (typeof name === "string") bump(name, event.time);
2966
+ }
2967
+ } catch {}
2968
+ }
2969
+ return stats;
2970
+ }
2971
+ /** Scan every session and total per-skill counts, keeping the latest lastUsed. */
2972
+ async function readSkillStats(query) {
2973
+ const totals = /* @__PURE__ */ new Map();
2974
+ const sessions = await query.listSessions();
2975
+ for (const record of sessions) {
2976
+ let snapshot;
2977
+ try {
2978
+ snapshot = await query.readSession(record.header.id);
2979
+ } catch {
2980
+ continue;
2981
+ }
2982
+ for (const [name, stat] of countSkillInvocations(snapshot.events)) {
2983
+ const total = totals.get(name);
2984
+ if (total === void 0) totals.set(name, { ...stat });
2985
+ else {
2986
+ total.count += stat.count;
2987
+ if (stat.lastUsed > total.lastUsed) total.lastUsed = stat.lastUsed;
2988
+ }
2989
+ }
2990
+ }
2991
+ return [...totals.entries()].map(([name, stat]) => ({
2992
+ name,
2993
+ count: stat.count,
2994
+ lastUsed: stat.lastUsed
2995
+ })).sort((a, b) => a.name.localeCompare(b.name));
2996
+ }
2997
+ /**
2998
+ * Wrap a query in a stale-while-revalidate cache: responses never wait for a
2999
+ * full session-log scan. While the TTL is fresh the cached totals are
3000
+ * returned; after expiry the stale totals are returned immediately and a
3001
+ * single background rescan refreshes them — the panel's next poll picks the
3002
+ * fresh numbers. A full scan decompresses every session log and can take
3003
+ * seconds, so it must never sit on the request path.
3004
+ */
3005
+ function createSkillStatsReader(query, ttlMs = 3e5) {
3006
+ let cached;
3007
+ let cachedAt = 0;
3008
+ let refreshing = null;
3009
+ return async () => {
3010
+ if (cached !== void 0 && Date.now() - cachedAt < ttlMs) return cached;
3011
+ if (refreshing === null) refreshing = readSkillStats(query).then((stats) => {
3012
+ cached = stats;
3013
+ cachedAt = Date.now();
3014
+ }).catch(() => {}).finally(() => {
3015
+ refreshing = null;
3016
+ });
3017
+ return cached ?? [];
3018
+ };
3019
+ }
3020
+ //#endregion
3021
+ //#region src/index.ts
3022
+ /** Stable cordis plugin name (matches cordis.patch.yml insert id). */
3023
+ const name = "skill-hub";
3024
+ /** Services required before the skill-hub surfaces can mount. */
3025
+ const inject = [
3026
+ "webServer",
3027
+ "skills",
3028
+ "systemPrompt"
3029
+ ];
3030
+ const Config = z.object({
3031
+ announceToAgent: z.boolean().default(HUB_CONFIG_DEFAULTS.announceToAgent),
3032
+ enabled: z.boolean().default(HUB_CONFIG_DEFAULTS.enabled),
3033
+ showUseCount: z.boolean().default(HUB_CONFIG_DEFAULTS.showUseCount),
3034
+ showUseTime: z.boolean().default(HUB_CONFIG_DEFAULTS.showUseTime),
3035
+ showGroupSummary: z.boolean().default(HUB_CONFIG_DEFAULTS.showGroupSummary)
3036
+ });
3037
+ /** Order of the announcement section within the tool-guidance band. */
3038
+ const SECTION_ORDER = 152;
3039
+ /** Model-facing announcement: plugin presence, capabilities, and limits. */
3040
+ const SKILL_HUB_GUIDANCE = ["本机已安装 dsh-skill-hub 插件(DSH Web GUI 技能中枢):设置 →「技能」分区为管理主页;设置 → 插件列表中有本插件的配置卡片(启用/公告开关)。能力:完整本地技能目录(项目/自定义/用户/内置全部来源,走官方 ctx.skills 注册表,含第三方 provider);按来源与自定义分组浏览,分组/来源头部的滑动开关可一键启用/禁用整组(跨组冲突时询问);市场:内置市场目录(精选仓库一键添加)加自定义仓库源,扫描后勾选安装,每个市场源行显示已装/可更新/上游已删数量,支持「检查全部」与「全部更新」;来源跟踪:从 GitHub 仓库(市场源或直接地址)导入的技能记录上游 repo/commit 快照,可检查更新、选择同步、上游删除时跟进删除(移入回收站可恢复,恢复后保留来源与场景归属);个人技能(无来源记录)不跟踪;调用次数与最近使用时间统计;查看技能正文;发现诊断;新建技能向导(写入 ~/.dsh/skills 或 ~/.agents/skills)。限制:仅用户级技能(user-dsh/user-agents 根目录)可写,项目/内置/运行时技能只读展示;路由仅回环可访问。用户提到「技能管理 / 技能列表 / 技能开关 / 技能同步 / 技能市场 / 更新技能 / 新建技能」时即指本插件,请据此协作。", "The dsh-skill-hub plugin is installed (the DSH Web GUI skill hub): Settings → \"Skills\" is the management page; Settings → Plugins lists this plugin's configuration card (enable / announcement toggles). Capabilities: full local skill catalog (project / custom / user / bundled roots via the official ctx.skills registry, including third-party providers); browsing by source and custom groups, each group header carrying a sliding switch to enable/disable the whole group in one click (cross-group conflicts prompt the user); market: a built-in catalog of curated repos (one-click add) plus custom repo sources, scan-and-install import, per-source installed / updatable / deleted-upstream badges with \"check all\" and \"update all\" actions; upstream source tracking: skills imported from GitHub repos (market sources or direct URLs) record the repo/commit snapshot, support update checks, selective sync, and follow-up deletion when the upstream removes a skill (moves it into a restorable trash; restoring keeps the source and scene membership); personal skills (no source record) are never tracked; invocation counts and last-used times; skill body inspection; discovery diagnostics; new-skill wizard (writes to ~/.dsh/skills or ~/.agents/skills). Limits: only user-level skills (user-dsh/user-agents roots) are writable; project/bundled/runtime skills are read-only; routes are loopback-only. When the user mentions \"skill management / skill list / skill toggle / skill sync / skill market / update skills / new skill\", this plugin is what they mean — collaborate accordingly."].join("\n\n");
3041
+ /**
3042
+ * Mount the skill hub routes and announcement.
3043
+ * @param ctx - host plugin context carrying webServer/skills/systemPrompt.
3044
+ * @param config - resolved plugin config (schema defaults applied by the loader).
3045
+ */
3046
+ function apply(ctx, config) {
3047
+ const base = config ?? {};
3048
+ let current = () => resolveHubConfig({}, base);
3049
+ let savedState = {};
3050
+ const store = new SkillHubStore();
3051
+ let disposeRoutes;
3052
+ let disposeSection;
3053
+ let disposeProvider;
3054
+ let providerControl;
3055
+ let stats;
3056
+ const updateConfig = async (patch) => {
3057
+ await store.setConfig(patch);
3058
+ savedState = await store.getConfig();
3059
+ const next = resolveHubConfig(savedState, base);
3060
+ current = () => next;
3061
+ sync();
3062
+ return next;
3063
+ };
3064
+ const sync = () => {
3065
+ if (disposeSection !== void 0) {
3066
+ disposeSection();
3067
+ disposeSection = void 0;
3068
+ }
3069
+ if (disposeProvider !== void 0) {
3070
+ disposeProvider();
3071
+ disposeProvider = void 0;
3072
+ }
3073
+ const value = current();
3074
+ if (value.announceToAgent) disposeSection = ctx.systemPrompt.section({
3075
+ name: "plugin:dsh-skill-hub",
3076
+ order: SECTION_ORDER,
3077
+ text: SKILL_HUB_GUIDANCE
3078
+ });
3079
+ if (value.enabled) {
3080
+ providerControl = void 0;
3081
+ disposeProvider = ctx.effect(() => ctx.skills.registerProvider((control) => {
3082
+ providerControl = control;
3083
+ return new SkillHubProvider(control);
3084
+ }), "dsh-skill-hub: provider");
3085
+ }
3086
+ if (disposeRoutes !== void 0) {
3087
+ disposeRoutes();
3088
+ disposeRoutes = void 0;
3089
+ }
3090
+ disposeRoutes = ctx.effect(() => {
3091
+ const disposers = makeRoutes({
3092
+ skills: ctx.skills,
3093
+ store,
3094
+ invalidate: () => {
3095
+ providerControl?.invalidate();
3096
+ },
3097
+ stats,
3098
+ config: current,
3099
+ saved: () => savedState,
3100
+ updateConfig
3101
+ }).map((route) => ctx.webServer.register(route));
3102
+ return () => {
3103
+ for (const dispose of disposers) dispose();
3104
+ };
3105
+ }, "dsh-skill-hub: routes");
3106
+ };
3107
+ sync();
3108
+ store.getConfig().then((saved) => {
3109
+ savedState = saved;
3110
+ current = () => resolveHubConfig(saved, base);
3111
+ sync();
3112
+ });
3113
+ ctx.inject(["sessionQuery"], (sctx) => {
3114
+ stats = createSkillStatsReader(sctx.sessionQuery);
3115
+ sync();
3116
+ });
3117
+ }
3118
+ //#endregion
3119
+ export { Config, SKILL_HUB_GUIDANCE, apply, inject, name };