dsh-skill-hub 0.1.0 → 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 (79) hide show
  1. package/README.md +146 -33
  2. package/lib/client.js +3477 -436
  3. package/lib/client.js.map +1 -1
  4. package/lib/index.js +2408 -366
  5. package/lib/types/client/SkillHubSettingsCard.d.ts +5 -0
  6. package/lib/types/client/api-config-scope.d.ts +6 -0
  7. package/lib/types/client/api.d.ts +44 -2
  8. package/lib/types/client/grouping.d.ts +64 -0
  9. package/lib/types/client/locales.d.ts +155 -18
  10. package/lib/types/client/market-catalog.d.ts +14 -0
  11. package/lib/types/client/panel/DisabledRow.d.ts +12 -0
  12. package/lib/types/client/panel/GroupSummary.d.ts +10 -0
  13. package/lib/types/client/panel/MarketView.d.ts +11 -0
  14. package/lib/types/client/panel/ScenesView.d.ts +11 -0
  15. package/lib/types/client/panel/SkillDetailView.d.ts +31 -0
  16. package/lib/types/client/panel/SkillHubPanel.d.ts +11 -3
  17. package/lib/types/client/panel/SkillRow.d.ts +11 -0
  18. package/lib/types/client/panel/SourceStatusBadge.d.ts +18 -0
  19. package/lib/types/client/panel/SourcesView.d.ts +10 -0
  20. package/lib/types/client/panel/TagEditorView.d.ts +29 -0
  21. package/lib/types/client/panel/dialogs.d.ts +73 -0
  22. package/lib/types/client/panel/format.d.ts +14 -0
  23. package/lib/types/client/panel/useSkillHub.d.ts +183 -0
  24. package/lib/types/client/settings-card.d.ts +21 -4
  25. package/lib/types/client/settings-form.d.ts +2 -0
  26. package/lib/types/client/settings-nav-icon.d.ts +8 -0
  27. package/lib/types/index.d.ts +6 -0
  28. package/lib/types/protocol.d.ts +428 -7
  29. package/lib/types/provider.d.ts +6 -1
  30. package/lib/types/repo.d.ts +133 -0
  31. package/lib/types/routes.d.ts +9 -4
  32. package/lib/types/skillfs.d.ts +19 -14
  33. package/lib/types/stats.d.ts +17 -4
  34. package/lib/types/store.d.ts +112 -6
  35. package/lib/types/update.d.ts +20 -0
  36. package/package.json +5 -3
  37. package/src/client/SkillHubSettingsCard.tsx +61 -8
  38. package/src/client/api-config-scope.test.ts +79 -0
  39. package/src/client/api-config-scope.ts +28 -4
  40. package/src/client/api.ts +239 -9
  41. package/src/client/grouping.test.ts +109 -0
  42. package/src/client/grouping.ts +120 -0
  43. package/src/client/index.tsx +5 -2
  44. package/src/client/locales.ts +314 -40
  45. package/src/client/market-catalog.ts +22 -0
  46. package/src/client/panel/DisabledRow.tsx +32 -0
  47. package/src/client/panel/GroupSummary.tsx +27 -0
  48. package/src/client/panel/MarketView.tsx +173 -0
  49. package/src/client/panel/ScenesView.tsx +75 -0
  50. package/src/client/panel/SkillDetailView.tsx +95 -0
  51. package/src/client/panel/SkillHubPanel.tsx +251 -257
  52. package/src/client/panel/SkillRow.tsx +54 -0
  53. package/src/client/panel/SourceStatusBadge.tsx +61 -0
  54. package/src/client/panel/SourcesView.tsx +104 -0
  55. package/src/client/panel/TagEditorView.tsx +89 -0
  56. package/src/client/panel/dialogs.tsx +185 -0
  57. package/src/client/panel/format.ts +31 -0
  58. package/src/client/panel/panel.module.css +209 -45
  59. package/src/client/panel/useSkillHub.ts +797 -0
  60. package/src/client/settings-card.module.css +44 -28
  61. package/src/client/settings-card.tsx +64 -8
  62. package/src/client/settings-form.ts +12 -0
  63. package/src/client/settings-nav-icon.ts +42 -0
  64. package/src/index.ts +19 -15
  65. package/src/protocol.ts +460 -7
  66. package/src/provider.test.ts +2 -4
  67. package/src/provider.ts +18 -14
  68. package/src/repo.test.ts +212 -0
  69. package/src/repo.ts +528 -0
  70. package/src/routes.test.ts +564 -52
  71. package/src/routes.ts +999 -153
  72. package/src/skillfs.test.ts +98 -16
  73. package/src/skillfs.ts +68 -33
  74. package/src/stats.test.ts +52 -17
  75. package/src/stats.ts +55 -18
  76. package/src/store.test.ts +205 -1
  77. package/src/store.ts +527 -24
  78. package/src/update.test.ts +73 -0
  79. package/src/update.ts +136 -0
package/lib/index.js CHANGED
@@ -1,19 +1,97 @@
1
+ import { createRequire } from "node:module";
1
2
  import z from "schemastery";
2
- import { mkdir, readFile, readdir, rename, stat, writeFile } from "node:fs/promises";
3
+ import { mkdir, mkdtemp, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
3
4
  import { basename, dirname, join, resolve } from "node:path";
4
5
  import { homedir } from "node:os";
5
- import { load } from "js-yaml";
6
+ import { dump, load } from "js-yaml";
6
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
7
79
  //#region src/store.ts
8
80
  /**
9
- * Hub sidecar store: remembers which skills the hub toggled off. Disabling
10
- * renames the skill's SKILL.md (or flat .md) out of the filesystem provider's
11
- * discovery shapes, so the provider catalog alone cannot see disabled skills;
12
- * this store keeps name/path/root so the GUI can list them and re-enable.
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).
13
89
  *
14
90
  * State file: $DSH_HOME/dsh-skill-hub.json — a small JSON document written
15
91
  * atomically (tmp file + rename).
16
92
  */
93
+ /** 默认场景名(系统预置的兜底场景,新技能自动归入)。 */
94
+ const DEFAULT_SCENE_NAME = "通用";
17
95
  /** Resolve the DSH home directory (the filesystem provider's user-dsh root base). */
18
96
  function dshHome() {
19
97
  return process.env.DSH_HOME ?? join(homedir(), ".dsh");
@@ -22,12 +100,80 @@ function dshHome() {
22
100
  function statePath(home = dshHome()) {
23
101
  return join(home, "dsh-skill-hub.json");
24
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
+ }
25
164
  /** Sidecar state owner. */
26
165
  var SkillHubStore = class {
27
166
  file;
28
167
  entries = /* @__PURE__ */ new Map();
29
168
  config = {};
169
+ tagsById = /* @__PURE__ */ new Map();
170
+ sourcesByRepo = /* @__PURE__ */ new Map();
171
+ marketSources = [];
172
+ trashByName = /* @__PURE__ */ new Map();
30
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();
31
177
  constructor(file = statePath()) {
32
178
  this.file = file;
33
179
  }
@@ -36,18 +182,93 @@ var SkillHubStore = class {
36
182
  this.loaded = true;
37
183
  try {
38
184
  const raw = await readFile(this.file, "utf8");
39
- const parsed = JSON.parse(raw);
40
- if (typeof parsed === "object" && parsed !== null && Array.isArray(parsed.disabled)) {
41
- for (const entry of parsed.disabled) if (typeof entry?.name === "string" && typeof entry?.path === "string") this.entries.set(entry.name, entry);
42
- const saved = parsed.config;
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;
43
192
  if (typeof saved === "object" && saved !== null) {
44
193
  if (typeof saved.enabled === "boolean") this.config.enabled = saved.enabled;
45
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
+ }
46
251
  }
47
252
  }
48
253
  } catch (error) {
49
254
  if (error.code !== "ENOENT") console.warn("[dsh-skill-hub] sidecar state unreadable, starting empty:", error instanceof Error ? error.message : error);
50
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();
51
272
  }
52
273
  async listDisabled() {
53
274
  await this.ensureLoaded();
@@ -74,7 +295,7 @@ var SkillHubStore = class {
74
295
  }
75
296
  /**
76
297
  * Persist a runtime-config patch, merged over the saved values. A field
77
- * whose patch value is `undefined` is removed from the saved layer, so the
298
+ * whose patch value is undefined is removed from the saved layer, so the
78
299
  * setting re-inherits its default (the web card's "reset" path).
79
300
  */
80
301
  async setConfig(config) {
@@ -85,16 +306,311 @@ var SkillHubStore = class {
85
306
  this.config = next;
86
307
  await this.persist();
87
308
  }
88
- async persist() {
89
- const payload = {
90
- version: 1,
91
- disabled: [...this.entries.values()],
92
- config: this.config
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
93
382
  };
94
- const tmp = this.file + ".tmp";
95
- await mkdir(dirname(this.file), { recursive: true });
96
- await writeFile(tmp, JSON.stringify(payload, null, 2) + "\n", "utf8");
97
- await rename(tmp, this.file);
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;
98
614
  }
99
615
  };
100
616
  //#endregion
@@ -141,10 +657,11 @@ async function createSkill(root, name, description, home = dshHome()) {
141
657
  const dir = join(rootPath(root, home), name);
142
658
  const file = join(dir, "SKILL.md");
143
659
  await mkdir(dir, { recursive: true });
660
+ const safeDescription = description.trim() === "" ? "New dsh skill created from the skill hub." : description.trim();
144
661
  await writeFile(file, [
145
662
  "---",
146
- "name: " + name,
147
- "description: " + (description.trim() === "" ? "New dsh skill created from the skill hub." : description.trim()),
663
+ "name: " + dump(name).trim(),
664
+ "description: " + dump(safeDescription).trim(),
148
665
  "---",
149
666
  "",
150
667
  "# " + name,
@@ -172,23 +689,46 @@ async function enableSkill(disabledPath) {
172
689
  await rename(disabledPath, original);
173
690
  return original;
174
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
+ }
175
727
  /** Classify a skill file path: 'directory' (SKILL.md), 'flat' (<name>.md), or undefined. */
176
728
  function skillFileKind(path) {
177
729
  if (basename(path) === "SKILL.md") return "directory";
178
730
  if (path.endsWith(".md")) return "flat";
179
731
  }
180
- /**
181
- * Normalize an optional frontmatter `sets` value into a clean string list.
182
- * Accepts a single string or a string array; trims, drops empties, and
183
- * ignores non-string entries. Returns undefined when absent or empty, so a
184
- * skill without sets stays uncategorized.
185
- */
186
- function normalizeSets(raw) {
187
- const items = typeof raw === "string" ? [raw] : Array.isArray(raw) ? raw : void 0;
188
- if (items === void 0) return void 0;
189
- const sets = items.filter((item) => typeof item === "string").map((item) => item.trim()).filter((item) => item !== "");
190
- return sets.length === 0 ? void 0 : sets;
191
- }
192
732
  /** Parse a SKILL.md the way the official filesystem provider does. */
193
733
  function parseFrontmatter(text) {
194
734
  const match = /^---\s*\r?\n([\s\S]*?)\r?\n---\s*(?:\r?\n([\s\S]*))?$/.exec(text);
@@ -207,7 +747,6 @@ function parseFrontmatter(text) {
207
747
  const description = typeof record.description === "string" ? record.description.trim() : "";
208
748
  if (description === "") return { error: "frontmatter requires a description field" };
209
749
  const whenToUse = typeof record.whenToUse === "string" && record.whenToUse.trim() !== "" ? record.whenToUse.trim() : void 0;
210
- const sets = normalizeSets(record.sets);
211
750
  if (Object.hasOwn(record, "disableModelInvocation")) return { error: "frontmatter field \"disableModelInvocation\" is unsupported; use \"disable-model-invocation\"" };
212
751
  if (Object.hasOwn(record, "modelInvocable")) return { error: "frontmatter field \"modelInvocable\" is unsupported; use \"disable-model-invocation\"" };
213
752
  if (Object.hasOwn(record, "userInvocable")) return { error: "frontmatter field \"userInvocable\" is unsupported; use \"user-invocable\"" };
@@ -226,7 +765,6 @@ function parseFrontmatter(text) {
226
765
  name,
227
766
  description,
228
767
  ...whenToUse !== void 0 ? { whenToUse } : {},
229
- ...sets !== void 0 ? { sets } : {},
230
768
  invocation,
231
769
  content: (match[2] ?? "").trim()
232
770
  } };
@@ -251,10 +789,9 @@ function frontmatterBoolean(data, key) {
251
789
  /**
252
790
  * Scan one skills root for discovery files: directory bundles (SKILL.md)
253
791
  * and flat <name>.md files. Hub-disabled files (.disabled) are excluded;
254
- * the .system subdirectory is excluded when skipSystem is set (mirrors the
255
- * official provider's user-dsh behavior).
792
+ * dot-prefixed entries (including .trash and .system) are always skipped.
256
793
  */
257
- async function scanRoot(base, skipSystem) {
794
+ async function scanRoot(base) {
258
795
  const entries = [];
259
796
  let names;
260
797
  try {
@@ -265,7 +802,6 @@ async function scanRoot(base, skipSystem) {
265
802
  }
266
803
  for (const name of names) {
267
804
  if (name.startsWith(".")) continue;
268
- if (skipSystem && name === ".system") continue;
269
805
  const absolute = join(base, name);
270
806
  let stats;
271
807
  try {
@@ -286,9 +822,9 @@ async function scanRoot(base, skipSystem) {
286
822
  }
287
823
  return entries.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
288
824
  }
289
- /** Scan one writable root (user-dsh skips .system, mirroring the official provider). */
825
+ /** Scan one writable root. */
290
826
  function listSkillEntries(root, home = dshHome()) {
291
- return scanRoot(rootPath(root, home), root === "user-dsh");
827
+ return scanRoot(rootPath(root, home));
292
828
  }
293
829
  /**
294
830
  * Walk up from cwd (max 32 levels) for a project marker (.dsh or .git);
@@ -325,10 +861,25 @@ async function scanDiagnostics(root, home = dshHome()) {
325
861
  continue;
326
862
  }
327
863
  const parsed = parseFrontmatter(text);
328
- if ("error" in parsed) diagnostics.push({
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({
329
880
  path: entry.path,
330
881
  root,
331
- reason: parsed.error
882
+ reason: `description is only ${value.description.length} chars; write a one-line description so agents can auto-activate this skill`
332
883
  });
333
884
  }
334
885
  return diagnostics;
@@ -380,13 +931,19 @@ var SkillHubProvider = class {
380
931
  }, { once: true });
381
932
  this.checkRoots();
382
933
  }
383
- /** Invalidate the registry cache when the top-level root shape changed. */
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
+ */
384
940
  async checkRoots() {
385
941
  let stamp = "";
386
- for (const root of await this.roots(void 0)) try {
387
- stamp += root.base + ":" + (await stat(root.base)).mtimeMs + ";";
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 + ";";
388
945
  } catch {
389
- stamp += root.base + ":missing;";
946
+ stamp += entry.path + ":missing;";
390
947
  }
391
948
  if (this.rootStamp === "") {
392
949
  this.rootStamp = stamp;
@@ -399,7 +956,7 @@ var SkillHubProvider = class {
399
956
  }
400
957
  async list(options) {
401
958
  const candidates = [];
402
- for (const root of await this.roots(options.cwd)) for (const entry of await scanRoot(root.base, root.skipSystem)) {
959
+ for (const root of await this.roots(options.cwd)) for (const entry of await scanRoot(root.base)) {
403
960
  let text;
404
961
  try {
405
962
  text = await readFile(entry.path, "utf8");
@@ -413,7 +970,6 @@ var SkillHubProvider = class {
413
970
  name: value.name,
414
971
  description: value.description,
415
972
  ...value.whenToUse !== void 0 ? { whenToUse: value.whenToUse } : {},
416
- ...value.sets !== void 0 ? { metadata: { sets: value.sets } } : {},
417
973
  invocation: value.invocation,
418
974
  source: root.source,
419
975
  provider: this.name,
@@ -442,7 +998,6 @@ var SkillHubProvider = class {
442
998
  name: value.name,
443
999
  description: value.description,
444
1000
  ...value.whenToUse !== void 0 ? { whenToUse: value.whenToUse } : {},
445
- ...value.sets !== void 0 ? { metadata: { sets: value.sets } } : {},
446
1001
  invocation: value.invocation,
447
1002
  source: candidate.source,
448
1003
  provider: this.name,
@@ -459,26 +1014,22 @@ var SkillHubProvider = class {
459
1014
  const roots = [{
460
1015
  base: rootPath("user-dsh", this.home),
461
1016
  source: "user-dsh",
462
- rank: USER_DSH_RANK,
463
- skipSystem: true
1017
+ rank: USER_DSH_RANK
464
1018
  }, {
465
1019
  base: rootPath("user-agents", this.home),
466
1020
  source: "user-agents",
467
- rank: USER_AGENTS_RANK,
468
- skipSystem: false
1021
+ rank: USER_AGENTS_RANK
469
1022
  }];
470
1023
  if (cwd !== void 0 && cwd !== "") {
471
1024
  const project = await findProjectRoot(cwd);
472
1025
  roots.unshift({
473
1026
  base: join(project, ".dsh", "skills"),
474
1027
  source: "project-dsh",
475
- rank: PROJECT_DSH_RANK,
476
- skipSystem: false
1028
+ rank: PROJECT_DSH_RANK
477
1029
  }, {
478
1030
  base: join(project, ".agents", "skills"),
479
1031
  source: "project-agents",
480
- rank: PROJECT_AGENTS_RANK,
481
- skipSystem: false
1032
+ rank: PROJECT_AGENTS_RANK
482
1033
  });
483
1034
  }
484
1035
  return roots;
@@ -489,76 +1040,659 @@ var SkillHubProvider = class {
489
1040
  }
490
1041
  };
491
1042
  //#endregion
492
- //#region src/protocol.ts
1043
+ //#region src/repo.ts
493
1044
  /**
494
- * Shared API contract for dsh-skill-hub: the route paths and payload shapes
495
- * both the host half and the browser half import. The browser half must never
496
- * depend on host SDK packages, so registry types are re-spelled here as
497
- * plain JSON-safe interfaces.
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).
498
1051
  */
499
- /** Browser-facing base paths of the skill-hub API family. */
500
- const SKILL_HUB_API = {
501
- catalog: "/api/skill-hub/catalog",
502
- skill: "/api/skill-hub/skill",
503
- toggle: "/api/skill-hub/toggle",
504
- create: "/api/skill-hub/create",
505
- stats: "/api/skill-hub/stats",
506
- config: "/api/skill-hub/config"
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
+ }
507
1060
  };
508
- //#endregion
509
- //#region src/routes.ts
510
- /** Cap on JSON request bodies (toggle/create payloads are tiny). */
511
- const MAX_JSON_BODY_BYTES = 64 * 1024;
512
- /** Loopback literal check plus browser same-origin markers. */
513
- function isLoopbackRequest(request) {
514
- const address = request.socket.remoteAddress;
515
- if (address !== "127.0.0.1" && address !== "::1" && address !== "::ffff:127.0.0.1") return false;
516
- const host = request.headers.host;
517
- if (typeof host !== "string") return false;
518
- let hostUrl;
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;
519
1127
  try {
520
- hostUrl = new URL("http://" + host);
521
- } catch {
522
- return false;
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)));
523
1134
  }
524
- if (hostUrl.hostname !== "127.0.0.1" && hostUrl.hostname !== "localhost" && hostUrl.hostname !== "[::1]") return false;
525
- if (request.headers["sec-fetch-site"] === "cross-site") return false;
526
- const origin = request.headers.origin;
527
- if (origin === void 0) return true;
1135
+ if (!metaResponse.ok) throw fetchError("github repo not found or unavailable", metaResponse);
1136
+ let meta;
528
1137
  try {
529
- return new URL(origin).host === hostUrl.host;
1138
+ meta = await metaResponse.json();
530
1139
  } catch {
531
- return false;
1140
+ throw new RepoFetchError("invalid github repo response");
532
1141
  }
533
- }
534
- /** One JSON response. */
535
- function writeJson(res, status, body) {
536
- const payload = JSON.stringify(body);
537
- res.writeHead(status, {
538
- "content-type": "application/json; charset=utf-8",
539
- "referrer-policy": "no-referrer"
540
- });
541
- res.end(payload);
542
- }
543
- /** One JSON error response. */
544
- function writeError(res, status, error) {
545
- writeJson(res, status, { error: error instanceof Error ? error.message : String(error) });
546
- }
547
- /** Read a JSON request body (undefined when too large or unparseable). */
548
- async function readJsonBody(req) {
549
- const chunks = [];
550
- let size = 0;
551
- for await (const chunk of req) {
552
- const buffer = chunk;
553
- size += buffer.length;
554
- if (size > MAX_JSON_BODY_BYTES) return void 0;
555
- chunks.push(buffer);
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)));
556
1153
  }
1154
+ if (!treeResponse.ok) throw fetchError("github tree not found", treeResponse);
1155
+ let payload;
557
1156
  try {
558
- const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8"));
559
- return typeof parsed === "object" && parsed !== null ? parsed : void 0;
1157
+ payload = await treeResponse.json();
560
1158
  } catch {
561
- return;
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;
562
1696
  }
563
1697
  }
564
1698
  /** URL query helper (first value, decoded). */
@@ -566,16 +1700,22 @@ function queryParam(url, name) {
566
1700
  const value = url.searchParams.get(name);
567
1701
  return value === null ? void 0 : value;
568
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
+ }
569
1712
  /** Sources the hub may toggle (the user-level filesystem roots). */
570
1713
  function isWritableSource(source) {
571
1714
  return source === "user-dsh" || source === "user-agents";
572
1715
  }
573
- /** The resolved hub config a route sees (defaults when the owner omits it). */
1716
+ /** The resolved hub config a route sees (the shared resolver fills defaults). */
574
1717
  function configOf(deps) {
575
- return deps.config?.() ?? {
576
- enabled: true,
577
- announceToAgent: true
578
- };
1718
+ return resolveHubConfig({}, deps.config?.() ?? {});
579
1719
  }
580
1720
  /** The raw saved config layer a route reports (empty when the owner omits it). */
581
1721
  function savedOf(deps) {
@@ -591,28 +1731,111 @@ function disabledGate(deps, res) {
591
1731
  function homeOf(deps) {
592
1732
  return deps.home ?? dshHome();
593
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
+ }
594
1797
  /** Build the full catalog response (shared by catalog/toggle/create handlers). */
595
1798
  async function buildCatalog(deps, cwd) {
596
1799
  const lookup = cwd !== void 0 ? { cwd } : void 0;
597
1800
  const snapshot = await deps.skills.snapshot(lookup);
598
- const skills = await Promise.all(snapshot.skills.map(async (skill) => {
599
- const sets = await readSets(deps, skill.name, lookup);
600
- return {
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 = {
601
1821
  name: skill.name,
602
1822
  description: skill.description,
603
1823
  ...skill.whenToUse !== void 0 ? { whenToUse: skill.whenToUse } : {},
604
- ...sets !== void 0 ? { sets } : {},
605
1824
  invocation: {
606
1825
  modelInvocable: skill.invocation.modelInvocable,
607
1826
  userInvocable: skill.invocation.userInvocable
608
1827
  },
609
- source: skill.source,
610
1828
  provider: skill.provider,
611
1829
  writable: isWritableSource(skill.source)
612
1830
  };
613
- }));
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
+ });
614
1838
  const disabled = await deps.store.listDisabled();
615
- const home = homeOf(deps);
616
1839
  const diagnostics = [...await scanDiagnostics("user-dsh", home), ...await scanDiagnostics("user-agents", home)];
617
1840
  return {
618
1841
  ok: true,
@@ -622,325 +1845,1130 @@ async function buildCatalog(deps, cwd) {
622
1845
  diagnostics
623
1846
  };
624
1847
  }
625
- /** Read one skill's normalized `sets` (undefined when absent, unreadable, or empty). */
626
- async function readSets(deps, name, lookup) {
627
- try {
628
- return normalizeSets((await deps.skills.get(name, lookup))?.metadata?.sets);
629
- } catch {
630
- return;
631
- }
632
- }
633
1848
  /** Map a loaded definition onto the wire shape. */
634
1849
  function toDetail(skill) {
635
- const sets = normalizeSets(skill.metadata?.sets);
636
1850
  return {
637
1851
  name: skill.name,
638
1852
  description: skill.description,
639
1853
  ...skill.whenToUse !== void 0 ? { whenToUse: skill.whenToUse } : {},
640
- ...sets !== void 0 ? { sets } : {},
641
1854
  invocation: {
642
1855
  modelInvocable: skill.invocation.modelInvocable,
643
1856
  userInvocable: skill.invocation.userInvocable
644
1857
  },
645
- source: skill.source,
646
1858
  provider: skill.provider,
647
1859
  ...skill.path !== void 0 ? { path: skill.path } : {},
648
1860
  content: skill.content
649
1861
  };
650
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
+ }
651
1882
  /**
652
1883
  * Build every /api/skill-hub route.
653
1884
  * @param deps - skill registry view + sidecar store.
654
1885
  * @returns the exact-path routes.
655
1886
  */
656
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
+ });
657
1929
  return [
658
- {
659
- kind: "exact",
1930
+ route({
660
1931
  path: SKILL_HUB_API.catalog,
661
- handler: async (req, res) => {
662
- if (!isLoopbackRequest(req)) {
663
- writeError(res, 403, "forbidden: loopback-only");
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");
664
2256
  return;
665
2257
  }
666
- if (req.method !== "GET") {
667
- writeError(res, 405, "method not allowed: " + (req.method ?? ""));
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");
668
2274
  return;
669
2275
  }
670
- if (disabledGate(deps, res)) return;
671
- try {
672
- writeJson(res, 200, await buildCatalog(deps, queryParam(new URL(req.url ?? "/", "http://localhost"), "cwd")));
673
- } catch (error) {
674
- writeError(res, 500, error);
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;
675
2301
  }
2302
+ writeJson(res, 200, {
2303
+ ok: true,
2304
+ repos: await deps.store.listMarketSources()
2305
+ });
676
2306
  }
677
- },
678
- {
679
- kind: "exact",
680
- path: SKILL_HUB_API.skill,
681
- handler: async (req, res) => {
682
- if (!isLoopbackRequest(req)) {
683
- writeError(res, 403, "forbidden: loopback-only");
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");
684
2375
  return;
685
2376
  }
686
- if (req.method !== "GET") {
687
- writeError(res, 405, "method not allowed: " + (req.method ?? ""));
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);
688
2381
  return;
689
2382
  }
690
- if (disabledGate(deps, res)) return;
691
- const url = new URL(req.url ?? "/", "http://localhost");
692
- const name = queryParam(url, "name");
693
- if (name === void 0 || name === "") {
694
- writeError(res, 400, "name query parameter is required");
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");
695
2413
  return;
696
2414
  }
697
- const cwd = queryParam(url, "cwd");
698
- try {
699
- const skill = await deps.skills.get(name, cwd !== void 0 ? { cwd } : void 0);
700
- if (skill === void 0) {
701
- writeError(res, 404, "skill not found: " + name);
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
+ });
702
2437
  return;
703
2438
  }
704
- writeJson(res, 200, {
705
- ok: true,
706
- skill: toDetail(skill)
707
- });
708
- } catch (error) {
709
- writeError(res, 500, error);
710
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
+ });
711
2447
  }
712
- },
713
- {
714
- kind: "exact",
715
- path: SKILL_HUB_API.toggle,
716
- handler: async (req, res) => {
717
- if (!isLoopbackRequest(req)) {
718
- writeError(res, 403, "forbidden: loopback-only");
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");
719
2459
  return;
720
2460
  }
721
- if (req.method !== "POST") {
722
- writeError(res, 405, "method not allowed: " + (req.method ?? ""));
2461
+ const parsed = normalizeRepoInput(input);
2462
+ if (parsed === null) {
2463
+ writeError(res, 400, "repo must be owner/repo or a github.com URL");
723
2464
  return;
724
2465
  }
725
- if (disabledGate(deps, res)) return;
726
- const raw = await readJsonBody(req);
727
- if (raw === void 0) {
728
- writeError(res, 400, "invalid JSON body");
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");
729
2473
  return;
730
2474
  }
731
- const request = raw;
732
- const name = typeof request.name === "string" ? request.name : "";
733
- if (name === "") {
734
- writeError(res, 400, "name is required");
2475
+ if (selected.reduce((sum, entry) => sum + entry.totalBytes, 0) > 200 * 1024 * 1024) {
2476
+ writeError(res, 400, "selected skills exceed the 200MB import limit");
735
2477
  return;
736
2478
  }
737
- const lookup = typeof request.cwd === "string" && request.cwd !== "" ? { cwd: request.cwd } : void 0;
2479
+ let commitSha = "";
738
2480
  try {
739
- if (request.enabled === true) {
740
- const record = await deps.store.getDisabled(name);
741
- if (record === void 0) {
742
- writeError(res, 404, "skill is not hub-disabled: " + name);
743
- return;
744
- }
745
- await enableSkill(record.path);
746
- await deps.store.removeDisabled(name);
747
- } else {
748
- const skill = await deps.skills.get(name, lookup);
749
- if (skill === void 0) {
750
- writeError(res, 404, "skill not found: " + name);
751
- return;
752
- }
753
- if (!isWritableSource(skill.source)) {
754
- writeError(res, 409, "source \"" + skill.source + "\" is managed outside the hub (read-only)");
755
- return;
756
- }
757
- if (skill.path === void 0) {
758
- writeError(res, 409, "provider-managed skill has no writable file");
759
- return;
760
- }
761
- const root = rootOfPath(skill.path, homeOf(deps));
762
- if (root === void 0) {
763
- writeError(res, 409, "skill path is outside the hub writable roots");
764
- return;
765
- }
766
- const disabledPath = await disableSkill(skill.path);
767
- await deps.store.addDisabled({
768
- name,
769
- description: skill.description,
770
- path: disabledPath,
771
- root,
772
- disabledAt: Date.now()
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)
773
2519
  });
774
2520
  }
775
- deps.invalidate?.();
776
- writeJson(res, 200, {
777
- ok: true,
778
- catalog: await buildCatalog(deps, lookup?.cwd)
779
- });
780
- } catch (error) {
781
- writeError(res, 500, error);
782
2521
  }
2522
+ deps.invalidate?.();
2523
+ writeJson(res, 200, {
2524
+ ok: true,
2525
+ imported,
2526
+ skipped,
2527
+ failed
2528
+ });
783
2529
  }
784
- },
785
- {
786
- kind: "exact",
787
- path: SKILL_HUB_API.create,
788
- handler: async (req, res) => {
789
- if (!isLoopbackRequest(req)) {
790
- writeError(res, 403, "forbidden: loopback-only");
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");
791
2553
  return;
792
2554
  }
793
- if (req.method !== "POST") {
794
- writeError(res, 405, "method not allowed: " + (req.method ?? ""));
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");
795
2574
  return;
796
2575
  }
797
- if (disabledGate(deps, res)) return;
798
- const raw = await readJsonBody(req);
799
- if (raw === void 0) {
800
- writeError(res, 400, "invalid JSON body");
2576
+ if ((await deps.store.getTag(id))?.default === true) {
2577
+ writeError(res, 409, "the default scene cannot be deleted");
801
2578
  return;
802
2579
  }
803
- const request = raw;
804
- const name = typeof request.name === "string" ? request.name.trim() : "";
805
- if (!isSkillName(name)) {
806
- writeError(res, 400, "skill name must be kebab-case (lowercase letters, digits, dashes)");
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");
807
2596
  return;
808
2597
  }
809
- const root = request.root ?? "user-dsh";
810
- if (root !== "user-dsh" && root !== "user-agents") {
811
- writeError(res, 400, "root must be user-dsh or user-agents");
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);
812
2602
  return;
813
2603
  }
814
- try {
815
- if (await deps.skills.get(name) !== void 0) {
816
- writeError(res, 409, "skill name already exists: " + name);
817
- return;
818
- }
819
- const path = await createSkill(root, name, typeof request.description === "string" ? request.description : "", homeOf(deps));
820
- deps.invalidate?.();
821
- writeJson(res, 201, {
822
- ok: true,
823
- path,
824
- root
825
- });
826
- } catch (error) {
827
- writeError(res, 500, error);
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);
828
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
+ });
829
2635
  }
830
- },
831
- {
832
- kind: "exact",
833
- path: SKILL_HUB_API.stats,
834
- handler: async (req, res) => {
835
- if (!isLoopbackRequest(req)) {
836
- writeError(res, 403, "forbidden: loopback-only");
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);
837
2651
  return;
838
2652
  }
839
- if (req.method !== "GET") {
840
- writeError(res, 405, "method not allowed: " + (req.method ?? ""));
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");
841
2729
  return;
842
2730
  }
843
- if (disabledGate(deps, res)) return;
844
- if (deps.stats === void 0) {
845
- writeJson(res, 200, {
846
- ok: true,
847
- available: false,
848
- stats: []
849
- });
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);
850
2735
  return;
851
2736
  }
852
- try {
853
- writeJson(res, 200, {
854
- ok: true,
855
- available: true,
856
- stats: await deps.stats()
857
- });
858
- } catch (error) {
859
- writeError(res, 500, error);
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
+ }
860
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
+ });
861
2790
  }
862
- },
863
- {
864
- kind: "exact",
865
- path: SKILL_HUB_API.config,
866
- handler: async (req, res) => {
867
- if (!isLoopbackRequest(req)) {
868
- writeError(res, 403, "forbidden: loopback-only");
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");
869
2802
  return;
870
2803
  }
871
- if (req.method === "GET") {
872
- writeJson(res, 200, {
873
- ok: true,
874
- config: configOf(deps),
875
- saved: savedOf(deps)
876
- });
2804
+ if (skills.length === 0) {
2805
+ writeError(res, 400, "skills must be a non-empty array");
877
2806
  return;
878
2807
  }
879
- if (req.method !== "POST") {
880
- writeError(res, 405, "method not allowed: " + (req.method ?? ""));
2808
+ const source = await deps.store.getSource(repo);
2809
+ if (source === void 0) {
2810
+ writeError(res, 404, "source not found: " + repo);
881
2811
  return;
882
2812
  }
883
- const raw = await readJsonBody(req);
884
- if (raw === void 0) {
885
- writeError(res, 400, "invalid JSON body");
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");
886
2886
  return;
887
2887
  }
888
- const patch = {};
889
- if (raw.enabled !== void 0) if (raw.enabled === null) patch.enabled = void 0;
890
- else if (typeof raw.enabled !== "boolean") {
891
- writeError(res, 400, "enabled must be a boolean or null");
2888
+ const entry = await deps.store.getTrash(name);
2889
+ if (entry === void 0) {
2890
+ writeError(res, 404, "trash entry not found: " + name);
892
2891
  return;
893
- } else patch.enabled = raw.enabled;
894
- if (raw.announceToAgent !== void 0) if (raw.announceToAgent === null) patch.announceToAgent = void 0;
895
- else if (typeof raw.announceToAgent !== "boolean") {
896
- writeError(res, 400, "announceToAgent must be a boolean or null");
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);
897
2896
  return;
898
- } else patch.announceToAgent = raw.announceToAgent;
899
- let config;
900
- if (deps.updateConfig === void 0) {
901
- const merged = { ...configOf(deps) };
902
- for (const [key, value] of Object.entries(patch)) if (value === void 0) delete merged[key];
903
- else merged[key] = value;
904
- config = merged;
905
- } else try {
906
- config = await deps.updateConfig(patch);
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);
907
2922
  } catch (error) {
908
- writeError(res, 500, error);
909
- return;
2923
+ failed.push({
2924
+ name: entry.name,
2925
+ error: error instanceof Error ? error.message : String(error)
2926
+ });
910
2927
  }
2928
+ for (const name of deleted) await deps.store.removeTrash(name);
2929
+ if (deleted.length > 0) deps.invalidate?.();
911
2930
  writeJson(res, 200, {
912
2931
  ok: true,
913
- config,
914
- saved: savedOf(deps)
2932
+ deleted,
2933
+ failed
915
2934
  });
916
2935
  }
917
- }
2936
+ })
918
2937
  ];
919
2938
  }
920
2939
  //#endregion
921
2940
  //#region src/stats.ts
922
- /** Count skill invocations in one session's event log, keyed by skill name. */
2941
+ /** Collect per-skill invocation counts and last-used times from one session. */
923
2942
  function countSkillInvocations(events) {
924
- const counts = /* @__PURE__ */ new Map();
925
- const bump = (name) => {
926
- if (name !== "") counts.set(name, (counts.get(name) ?? 0) + 1);
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
+ }
927
2955
  };
928
2956
  for (const event of events) if (event.type === "user/message") {
929
2957
  const source = event.data.source;
930
- if (source.kind === "skill-invocation") bump(source.name);
2958
+ if (source.kind === "skill-invocation") bump(source.name, event.time);
931
2959
  } else if (event.type === "tool/call") {
932
2960
  const call = event.data;
933
2961
  if (call.name === "skill") try {
934
2962
  const parsed = JSON.parse(call.arguments);
935
2963
  if (typeof parsed === "object" && parsed !== null) {
936
2964
  const name = parsed.name;
937
- if (typeof name === "string") bump(name);
2965
+ if (typeof name === "string") bump(name, event.time);
938
2966
  }
939
2967
  } catch {}
940
2968
  }
941
- return counts;
2969
+ return stats;
942
2970
  }
943
- /** Scan every session and total per-skill invocation counts. */
2971
+ /** Scan every session and total per-skill counts, keeping the latest lastUsed. */
944
2972
  async function readSkillStats(query) {
945
2973
  const totals = /* @__PURE__ */ new Map();
946
2974
  const sessions = await query.listSessions();
@@ -951,22 +2979,42 @@ async function readSkillStats(query) {
951
2979
  } catch {
952
2980
  continue;
953
2981
  }
954
- for (const [name, count] of countSkillInvocations(snapshot.events)) totals.set(name, (totals.get(name) ?? 0) + count);
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
+ }
955
2990
  }
956
- return [...totals.entries()].map(([name, count]) => ({
2991
+ return [...totals.entries()].map(([name, stat]) => ({
957
2992
  name,
958
- count
2993
+ count: stat.count,
2994
+ lastUsed: stat.lastUsed
959
2995
  })).sort((a, b) => a.name.localeCompare(b.name));
960
2996
  }
961
- /** Wrap a query in a TTL cache so a 5s panel poll does not rescan every log. */
962
- function createSkillStatsReader(query, ttlMs = 3e4) {
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) {
963
3006
  let cached;
964
3007
  let cachedAt = 0;
3008
+ let refreshing = null;
965
3009
  return async () => {
966
3010
  if (cached !== void 0 && Date.now() - cachedAt < ttlMs) return cached;
967
- cached = await readSkillStats(query);
968
- cachedAt = Date.now();
969
- 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 ?? [];
970
3018
  };
971
3019
  }
972
3020
  //#endregion
@@ -980,18 +3028,16 @@ const inject = [
980
3028
  "systemPrompt"
981
3029
  ];
982
3030
  const Config = z.object({
983
- announceToAgent: z.boolean().default(true),
984
- enabled: z.boolean().default(true)
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)
985
3036
  });
986
3037
  /** Order of the announcement section within the tool-guidance band. */
987
3038
  const SECTION_ORDER = 152;
988
3039
  /** Model-facing announcement: plugin presence, capabilities, and limits. */
989
- const SKILL_HUB_GUIDANCE = ["本机已安装 dsh-skill-hub 插件(DSH Web GUI 技能中枢):设置 →「技能」分区为管理主页;设置 → 插件列表中有本插件的配置卡片(启用/公告开关)。能力:完整本地技能目录(项目/自定义/用户/内置全部来源,走官方 ctx.skills 注册表,含第三方 provider);搜索、按来源分组;启用/禁用技能(重命名 SKILL.md 并记录于 ~/.dsh/dsh-skill-hub.json,文件不删除、可随时恢复);查看技能正文;发现诊断(列出被忽略技能的原因:缺 frontmatter、缺 name/description、非法名称);新建技能向导(写入 ~/.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); search; grouping by source; enable/disable skills (renames SKILL.md and records it in ~/.dsh/dsh-skill-hub.json; files are never deleted and can be restored anytime); view skill bodies; discovery diagnostics (reasons a skill is ignored: missing frontmatter, missing name/description, invalid name); 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 / new skill\", this plugin is what they mean — collaborate accordingly."].join("\n\n");
990
- /** Defaults the hub applies when neither the cordis config nor the sidecar says otherwise. */
991
- const DEFAULTS = {
992
- enabled: true,
993
- announceToAgent: true
994
- };
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");
995
3041
  /**
996
3042
  * Mount the skill hub routes and announcement.
997
3043
  * @param ctx - host plugin context carrying webServer/skills/systemPrompt.
@@ -999,11 +3045,7 @@ const DEFAULTS = {
999
3045
  */
1000
3046
  function apply(ctx, config) {
1001
3047
  const base = config ?? {};
1002
- const resolveSaved = (saved) => ({
1003
- enabled: saved.enabled ?? base.enabled ?? DEFAULTS.enabled,
1004
- announceToAgent: saved.announceToAgent ?? base.announceToAgent ?? DEFAULTS.announceToAgent
1005
- });
1006
- let current = () => resolveSaved({});
3048
+ let current = () => resolveHubConfig({}, base);
1007
3049
  let savedState = {};
1008
3050
  const store = new SkillHubStore();
1009
3051
  let disposeRoutes;
@@ -1014,7 +3056,7 @@ function apply(ctx, config) {
1014
3056
  const updateConfig = async (patch) => {
1015
3057
  await store.setConfig(patch);
1016
3058
  savedState = await store.getConfig();
1017
- const next = resolveSaved(savedState);
3059
+ const next = resolveHubConfig(savedState, base);
1018
3060
  current = () => next;
1019
3061
  sync();
1020
3062
  return next;
@@ -1065,7 +3107,7 @@ function apply(ctx, config) {
1065
3107
  sync();
1066
3108
  store.getConfig().then((saved) => {
1067
3109
  savedState = saved;
1068
- current = () => resolveSaved(saved);
3110
+ current = () => resolveHubConfig(saved, base);
1069
3111
  sync();
1070
3112
  });
1071
3113
  ctx.inject(["sessionQuery"], (sctx) => {