dsh-skill-hub 0.3.6 → 0.3.8

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 (122) hide show
  1. package/README.md +40 -50
  2. package/README.zh.md +38 -48
  3. package/lib/client.js +2569 -1646
  4. package/lib/client.js.map +1 -1
  5. package/lib/index.js +988 -400
  6. package/lib/types/client/SkillHubSettingsCard.d.ts +1 -2
  7. package/lib/types/client/api.d.ts +15 -5
  8. package/lib/types/client/helpers.d.ts +10 -1
  9. package/lib/types/client/locales/common.d.ts +52 -0
  10. package/lib/types/client/locales/detail.d.ts +16 -0
  11. package/lib/types/client/locales/market.d.ts +73 -0
  12. package/lib/types/client/locales/settings.d.ts +41 -0
  13. package/lib/types/client/locales/skills.d.ts +65 -0
  14. package/lib/types/client/locales/sources.d.ts +35 -0
  15. package/lib/types/client/locales.d.ts +147 -111
  16. package/lib/types/client/panel/DisabledRow.d.ts +2 -0
  17. package/lib/types/client/panel/MarketSourceRow.d.ts +12 -0
  18. package/lib/types/client/panel/MarketView.d.ts +1 -0
  19. package/lib/types/client/panel/RepoScanCard.d.ts +10 -0
  20. package/lib/types/client/panel/SkillDetailView.d.ts +5 -1
  21. package/lib/types/client/panel/dialogs.d.ts +19 -0
  22. package/lib/types/client/panel/hooks/shared.d.ts +72 -0
  23. package/lib/types/client/panel/hooks/useCatalogFlow.d.ts +62 -0
  24. package/lib/types/client/panel/hooks/useGroupFlow.d.ts +41 -0
  25. package/lib/types/client/panel/hooks/useMarketFlow.d.ts +73 -0
  26. package/lib/types/client/panel/hooks/useMetaFlow.d.ts +18 -0
  27. package/lib/types/client/panel/hooks/useSourceFlow.d.ts +44 -0
  28. package/lib/types/client/panel/market-format.d.ts +6 -0
  29. package/lib/types/client/panel/useSkillHub.d.ts +54 -71
  30. package/lib/types/client/settings-card.d.ts +20 -17
  31. package/lib/types/client/settings-form.d.ts +6 -1
  32. package/lib/types/protocol/api.d.ts +38 -0
  33. package/lib/types/protocol/catalog.d.ts +155 -0
  34. package/lib/types/protocol/config.d.ts +113 -0
  35. package/lib/types/protocol/groups.d.ts +95 -0
  36. package/lib/types/protocol/market.d.ts +94 -0
  37. package/lib/types/protocol/repo.d.ts +107 -0
  38. package/lib/types/protocol/sources.d.ts +139 -0
  39. package/lib/types/protocol/stats.d.ts +46 -0
  40. package/lib/types/protocol.d.ts +13 -720
  41. package/lib/types/repo/github-client.d.ts +42 -0
  42. package/lib/types/repo.d.ts +9 -22
  43. package/lib/types/routes/catalog.d.ts +7 -0
  44. package/lib/types/routes/config.d.ts +7 -0
  45. package/lib/types/routes/groups.d.ts +7 -0
  46. package/lib/types/routes/helpers.d.ts +145 -0
  47. package/lib/types/routes/market.d.ts +8 -0
  48. package/lib/types/routes/repo-import.d.ts +7 -0
  49. package/lib/types/routes/route-state.d.ts +55 -0
  50. package/lib/types/routes/sources.d.ts +8 -0
  51. package/lib/types/routes.d.ts +5 -34
  52. package/lib/types/skillfs.d.ts +13 -0
  53. package/lib/types/stats.d.ts +3 -2
  54. package/lib/types/store/errors.d.ts +10 -0
  55. package/lib/types/store/migrate.d.ts +44 -0
  56. package/lib/types/store/paths.d.ts +32 -0
  57. package/lib/types/store/store.d.ts +133 -0
  58. package/lib/types/store.d.ts +7 -146
  59. package/package.json +30 -28
  60. package/src/client/SkillHubSettingsCard.tsx +17 -5
  61. package/src/client/api.ts +108 -184
  62. package/src/client/helpers.ts +50 -1
  63. package/src/client/locales/common.ts +102 -0
  64. package/src/client/locales/detail.ts +30 -0
  65. package/src/client/locales/market.ts +144 -0
  66. package/src/client/locales/settings.ts +80 -0
  67. package/src/client/locales/skills.ts +128 -0
  68. package/src/client/locales/sources.ts +68 -0
  69. package/src/client/locales.ts +26 -452
  70. package/src/client/panel/DisabledRow.tsx +22 -7
  71. package/src/client/panel/MarketSourceRow.tsx +108 -0
  72. package/src/client/panel/MarketView.tsx +12 -246
  73. package/src/client/panel/RepoScanCard.tsx +171 -0
  74. package/src/client/panel/ScenesView.tsx +1 -1
  75. package/src/client/panel/SkillDetailView.tsx +35 -11
  76. package/src/client/panel/SkillHubPanel.tsx +42 -4
  77. package/src/client/panel/SkillRow.tsx +5 -2
  78. package/src/client/panel/SourcesView.tsx +2 -17
  79. package/src/client/panel/dialogs.tsx +61 -0
  80. package/src/client/panel/hooks/shared.ts +81 -0
  81. package/src/client/panel/hooks/useCatalogFlow.ts +211 -0
  82. package/src/client/panel/hooks/useGroupFlow.ts +240 -0
  83. package/src/client/panel/hooks/useMarketFlow.ts +421 -0
  84. package/src/client/panel/hooks/useMetaFlow.ts +58 -0
  85. package/src/client/panel/hooks/useSourceFlow.ts +193 -0
  86. package/src/client/panel/market-format.ts +22 -0
  87. package/src/client/panel/panel.module.css +1 -8
  88. package/src/client/panel/useSkillHub.ts +142 -948
  89. package/src/client/settings-card.tsx +51 -42
  90. package/src/client/settings-form.ts +19 -5
  91. package/src/index.ts +14 -4
  92. package/src/protocol/api.ts +38 -0
  93. package/src/protocol/catalog.ts +168 -0
  94. package/src/protocol/config.ts +145 -0
  95. package/src/protocol/groups.ts +106 -0
  96. package/src/protocol/market.ts +100 -0
  97. package/src/protocol/repo.ts +104 -0
  98. package/src/protocol/sources.ts +146 -0
  99. package/src/protocol/stats.ts +42 -0
  100. package/src/protocol.ts +83 -775
  101. package/src/repo/github-client.ts +131 -0
  102. package/src/repo.test.ts +24 -1
  103. package/src/repo.ts +69 -147
  104. package/src/routes/catalog.ts +309 -0
  105. package/src/routes/config.ts +119 -0
  106. package/src/routes/groups.ts +133 -0
  107. package/src/routes/helpers.ts +489 -0
  108. package/src/routes/market.ts +255 -0
  109. package/src/routes/repo-import.ts +267 -0
  110. package/src/routes/route-state.ts +89 -0
  111. package/src/routes/sources.ts +318 -0
  112. package/src/routes.test.ts +42 -0
  113. package/src/routes.ts +21 -1528
  114. package/src/skillfs.ts +188 -3
  115. package/src/stats.test.ts +58 -3
  116. package/src/stats.ts +45 -16
  117. package/src/store/errors.ts +14 -0
  118. package/src/store/migrate.ts +272 -0
  119. package/src/store/paths.ts +43 -0
  120. package/src/store/store.ts +532 -0
  121. package/src/store.test.ts +18 -0
  122. package/src/store.ts +7 -737
package/lib/index.js CHANGED
@@ -6,13 +6,7 @@ import { homedir } from "node:os";
6
6
  import { dump, load } from "js-yaml";
7
7
  import { isSkillName } from "@deepseek-ai/dsh-skill";
8
8
  import { randomUUID } from "node:crypto";
9
- //#region src/protocol.ts
10
- /**
11
- * Shared API contract for dsh-skill-hub: the route paths and payload shapes
12
- * both the host half and the browser half import. The browser half must never
13
- * depend on host SDK packages, so registry types are re-spelled here as
14
- * plain JSON-safe interfaces.
15
- */
9
+ //#region src/protocol/api.ts
16
10
  /** Browser-facing base paths of the skill-hub API family. */
17
11
  const SKILL_HUB_API = {
18
12
  catalog: "/api/skill-hub/catalog",
@@ -46,8 +40,13 @@ const SKILL_HUB_API = {
46
40
  sourceSync: "/api/skill-hub/sources/sync",
47
41
  sourceDelete: "/api/skill-hub/sources/delete",
48
42
  sourceRestore: "/api/skill-hub/sources/restore",
49
- sourceTrashClear: "/api/skill-hub/sources/trash/clear"
43
+ sourceTrashClear: "/api/skill-hub/sources/trash/clear",
44
+ diagnosticFix: "/api/skill-hub/diagnostic/fix",
45
+ marketSourceVersions: "/api/skill-hub/market/source/versions",
46
+ marketStats: "/api/skill-hub/market/stats"
50
47
  };
48
+ //#endregion
49
+ //#region src/protocol/config.ts
51
50
  /**
52
51
  * Hub config defaults — the single source every layer reads: the cordis
53
52
  * schema (index.ts), the host's saved-override merge, and the routes'
@@ -72,6 +71,7 @@ const HUB_CONFIG_DEFAULTS = {
72
71
  function resolveHubConfig(saved, base = {}) {
73
72
  const dotModelColor = saved.dotModelColor !== void 0 ? saved.dotModelColor : base.dotModelColor;
74
73
  const dotUserColor = saved.dotUserColor !== void 0 ? saved.dotUserColor : base.dotUserColor;
74
+ const githubToken = saved.githubToken !== void 0 ? saved.githubToken : base.githubToken;
75
75
  const windowDays = clampNumber(saved.statsWindowDays ?? base.statsWindowDays, 0) ?? HUB_CONFIG_DEFAULTS.statsWindowDays;
76
76
  const scanMinutes = clampNumber(saved.statsScanMinutes ?? base.statsScanMinutes, 1) ?? HUB_CONFIG_DEFAULTS.statsScanMinutes;
77
77
  return {
@@ -83,7 +83,8 @@ function resolveHubConfig(saved, base = {}) {
83
83
  statsWindowDays: windowDays,
84
84
  statsScanMinutes: scanMinutes,
85
85
  ...dotModelColor !== void 0 ? { dotModelColor } : {},
86
- ...dotUserColor !== void 0 ? { dotUserColor } : {}
86
+ ...dotUserColor !== void 0 ? { dotUserColor } : {},
87
+ ...githubToken !== void 0 ? { githubToken } : {}
87
88
  };
88
89
  }
89
90
  /** Clamp a numeric override into a valid value; undefined/invalid stays undefined. */
@@ -93,25 +94,16 @@ function clampNumber(value, min) {
93
94
  }
94
95
  /** HEX color validation shared by host routes and the settings card. */
95
96
  const HEX_COLOR_RE = /^#[0-9a-f]{6}$/i;
97
+ /** GitHub token validation shared by host routes and the settings card. */
98
+ const GITHUB_TOKEN_RE = /^[A-Za-z0-9_\-]{8,255}$/;
99
+ //#endregion
100
+ //#region src/protocol/sources.ts
96
101
  /** 项目级技能来源(它们有 workspace 归属,不属于「个人」组)。 */
97
102
  function isProjectSource(source) {
98
103
  return source === "project-dsh" || source === "project-agents";
99
104
  }
100
105
  //#endregion
101
- //#region src/store.ts
102
- /**
103
- * Hub sidecar store: remembers which skills the hub toggled off and the
104
- * user's organization/tracking records. Disabling renames the skill's
105
- * SKILL.md (or flat .md) out of the filesystem provider's discovery shapes,
106
- * so the provider catalog alone cannot see disabled skills; this store keeps
107
- * name/path/root so the GUI can list them and re-enable. It also persists
108
- * user tag groups, upstream source records (repo + commit snapshot for
109
- * update checks), the market source list, and the trash (skills removed
110
- * after upstream deletion).
111
- *
112
- * State file: $DSH_HOME/dsh-skill-hub.json — a small JSON document written
113
- * atomically (tmp file + rename).
114
- */
106
+ //#region src/store/paths.ts
115
107
  /** 默认场景名(系统预置的兜底场景,新技能自动归入)。 */
116
108
  const DEFAULT_SCENE_NAME = "通用";
117
109
  /** Resolve the DSH home directory (the filesystem provider's user-dsh root base). */
@@ -122,6 +114,8 @@ function dshHome() {
122
114
  function statePath(home = dshHome()) {
123
115
  return join(home, "dsh-skill-hub.json");
124
116
  }
117
+ //#endregion
118
+ //#region src/store/errors.ts
125
119
  /**
126
120
  * Business-rule failure the routes layer can map onto a 4xx status instead
127
121
  * of a blanket 500: user input is invalid (validation → 400), the target
@@ -136,6 +130,8 @@ var StoreError = class extends Error {
136
130
  this.kind = kind;
137
131
  }
138
132
  };
133
+ //#endregion
134
+ //#region src/store/migrate.ts
139
135
  /**
140
136
  * Normalize an arbitrary parsed sidecar document to the current schema.
141
137
  * v1 to v2: scenes are dropped (the one-click role merged into group
@@ -182,10 +178,152 @@ function migrateStore(parsed) {
182
178
  ...marketSources !== void 0 ? { marketSources } : {},
183
179
  ...trash !== void 0 ? { trash } : {},
184
180
  ...record.skillStats !== void 0 ? { skillStats: record.skillStats } : {},
181
+ ...record.marketStats !== void 0 ? { marketStats: record.marketStats } : {},
185
182
  ...Array.isArray(record.collectionOrder) ? { collectionOrder: record.collectionOrder } : {},
186
183
  ...Array.isArray(record.sourceGroupOrder) ? { sourceGroupOrder: record.sourceGroupOrder } : {}
187
184
  };
188
185
  }
186
+ /** 小优化:统一的非空字符串数组清洗(去空、去重可选由调用方处理)。 */
187
+ function cleanStringList(value) {
188
+ if (!Array.isArray(value)) return [];
189
+ return value.filter((n) => typeof n === "string" && n !== "");
190
+ }
191
+ /**
192
+ * 把迁移后的原始文档校验为内存状态:坏条目丢弃、坏桶降级为空,
193
+ * 绝不因一条脏数据丢掉整个 sidecar(与原 ensureLoaded 内联逻辑一致)。
194
+ */
195
+ function hydrateMigratedState(migrated) {
196
+ const entries = /* @__PURE__ */ new Map();
197
+ if (Array.isArray(migrated.disabled)) {
198
+ for (const entry of migrated.disabled) if (typeof entry?.name === "string" && typeof entry?.path === "string") entries.set(entry.name, entry);
199
+ }
200
+ const config = {};
201
+ const saved = migrated.config;
202
+ if (typeof saved === "object" && saved !== null) {
203
+ if (typeof saved.enabled === "boolean") config.enabled = saved.enabled;
204
+ if (typeof saved.announceToAgent === "boolean") config.announceToAgent = saved.announceToAgent;
205
+ if (typeof saved.dotModelColor === "string" && saved.dotModelColor !== "") config.dotModelColor = saved.dotModelColor;
206
+ if (typeof saved.dotUserColor === "string" && saved.dotUserColor !== "") config.dotUserColor = saved.dotUserColor;
207
+ if (typeof saved.showUseCount === "boolean") config.showUseCount = saved.showUseCount;
208
+ if (typeof saved.showUseTime === "boolean") config.showUseTime = saved.showUseTime;
209
+ if (typeof saved.showGroupSummary === "boolean") config.showGroupSummary = saved.showGroupSummary;
210
+ }
211
+ const tagsById = /* @__PURE__ */ new Map();
212
+ if (Array.isArray(migrated.tags)) for (const entry of migrated.tags) {
213
+ const tag = entry;
214
+ if (tag !== null && typeof tag === "object" && typeof tag.id === "string" && typeof tag.name === "string" && Array.isArray(tag.skillNames)) tagsById.set(tag.id, {
215
+ id: tag.id,
216
+ name: tag.name,
217
+ skillNames: tag.skillNames.filter((n) => typeof n === "string"),
218
+ ...tag.default === true ? { default: true } : {}
219
+ });
220
+ }
221
+ const sourcesByRepo = /* @__PURE__ */ new Map();
222
+ if (Array.isArray(migrated.sources)) for (const entry of migrated.sources) {
223
+ const source = entry;
224
+ if (source !== null && typeof source === "object" && typeof source.repo === "string" && source.repo !== "" && Array.isArray(source.skills)) {
225
+ const manifest = source.manifest;
226
+ const rawRoot = typeof source.root === "string" && source.root !== "" && /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(source.root) ? source.root : "skills";
227
+ sourcesByRepo.set(source.repo, {
228
+ repo: source.repo,
229
+ ...typeof source.ref === "string" && source.ref !== "" ? { ref: source.ref } : {},
230
+ root: rawRoot,
231
+ commitSha: typeof source.commitSha === "string" ? source.commitSha : "",
232
+ skills: source.skills.filter((n) => typeof n === "string"),
233
+ ...manifest !== null && typeof manifest === "object" && !Array.isArray(manifest) ? { manifest: Object.fromEntries(Object.entries(manifest).filter(([, size]) => typeof size === "number")) } : {}
234
+ });
235
+ }
236
+ }
237
+ const marketSources = [];
238
+ if (Array.isArray(migrated.marketSources)) for (const entry of migrated.marketSources) {
239
+ const item = entry;
240
+ if (item !== null && typeof item === "object" && typeof item.repo === "string" && item.repo !== "" && !marketSources.some((s) => s.repo === item.repo)) marketSources.push({
241
+ repo: item.repo,
242
+ ...typeof item.ref === "string" && item.ref !== "" ? { ref: item.ref } : {},
243
+ ...typeof item.commitSha === "string" && item.commitSha !== "" ? { commitSha: item.commitSha } : {}
244
+ });
245
+ }
246
+ const trashByName = /* @__PURE__ */ new Map();
247
+ if (Array.isArray(migrated.trash)) for (const entry of migrated.trash) {
248
+ const item = entry;
249
+ if (item !== null && typeof item === "object" && typeof item.name === "string" && typeof item.path === "string") {
250
+ const origin = item.origin;
251
+ const tagIds = item.tagIds;
252
+ trashByName.set(item.name, {
253
+ name: item.name,
254
+ path: item.path,
255
+ movedAt: typeof item.movedAt === "number" ? item.movedAt : 0,
256
+ ...typeof item.sourcePath === "string" && item.sourcePath !== "" ? { sourcePath: item.sourcePath } : {},
257
+ ...origin !== null && typeof origin === "object" && typeof origin.repo === "string" && origin.repo !== "" && typeof origin.root === "string" && origin.root !== "" && /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(origin.root) ? { origin: {
258
+ repo: origin.repo,
259
+ root: origin.root,
260
+ ...typeof origin.ref === "string" && origin.ref !== "" ? { ref: origin.ref } : {},
261
+ commitSha: typeof origin.commitSha === "string" ? origin.commitSha : ""
262
+ } } : {},
263
+ ...Array.isArray(tagIds) ? { tagIds: tagIds.filter((id) => typeof id === "string") } : {}
264
+ });
265
+ }
266
+ }
267
+ let skillStats = void 0;
268
+ const savedStats = migrated.skillStats;
269
+ if (savedStats !== null && typeof savedStats === "object" && typeof savedStats.frozenBefore === "number" && typeof savedStats.lastFullReconcile === "number" && typeof savedStats.windowDays === "number" && typeof savedStats.frozenSessions === "object" && savedStats.frozenSessions !== null) {
270
+ const sessions = {};
271
+ for (const [id, entry] of Object.entries(savedStats.frozenSessions)) {
272
+ if (entry === null || typeof entry !== "object" || typeof entry.createdAt !== "number" || typeof entry.counts !== "object" || entry.counts === null) continue;
273
+ const counts = {};
274
+ for (const [name, stat] of Object.entries(entry.counts)) if (stat !== null && typeof stat === "object" && typeof stat.count === "number" && typeof stat.lastUsed === "number") counts[name] = {
275
+ count: stat.count,
276
+ lastUsed: stat.lastUsed
277
+ };
278
+ sessions[id] = {
279
+ createdAt: entry.createdAt,
280
+ counts
281
+ };
282
+ }
283
+ const rawTotals = savedStats.lastTotals;
284
+ const lastTotals = Array.isArray(rawTotals) ? rawTotals.filter((entry) => entry !== null && typeof entry === "object" && typeof entry.name === "string" && typeof entry.count === "number").map((entry) => ({
285
+ name: entry.name,
286
+ count: entry.count,
287
+ ...typeof entry.lastUsed === "number" ? { lastUsed: entry.lastUsed } : {}
288
+ })) : void 0;
289
+ skillStats = {
290
+ windowDays: savedStats.windowDays,
291
+ frozenBefore: savedStats.frozenBefore,
292
+ frozenSessions: sessions,
293
+ lastFullReconcile: savedStats.lastFullReconcile,
294
+ ...lastTotals !== void 0 ? { lastTotals } : {}
295
+ };
296
+ }
297
+ let marketStats = void 0;
298
+ const savedMarketStats = migrated.marketStats;
299
+ if (savedMarketStats !== null && typeof savedMarketStats === "object" && typeof savedMarketStats.fetchedAt === "number" && typeof savedMarketStats.stats === "object" && savedMarketStats.stats !== null) {
300
+ const stats = {};
301
+ for (const [repo, entry] of Object.entries(savedMarketStats.stats)) if (entry !== null && typeof entry === "object" && typeof entry.stars === "number" && typeof entry.downloads === "number") stats[repo] = {
302
+ stars: entry.stars,
303
+ downloads: entry.downloads
304
+ };
305
+ marketStats = {
306
+ fetchedAt: savedMarketStats.fetchedAt,
307
+ stats
308
+ };
309
+ }
310
+ const collectionOrder = cleanStringList(migrated.collectionOrder);
311
+ const sourceGroupOrder = cleanStringList(migrated.sourceGroupOrder);
312
+ return {
313
+ entries,
314
+ config,
315
+ tagsById,
316
+ sourcesByRepo,
317
+ marketSources,
318
+ trashByName,
319
+ skillStats,
320
+ marketStats,
321
+ collectionOrder,
322
+ sourceGroupOrder
323
+ };
324
+ }
325
+ //#endregion
326
+ //#region src/store/store.ts
189
327
  /** Sidecar state owner. */
190
328
  var SkillHubStore = class {
191
329
  file;
@@ -196,6 +334,7 @@ var SkillHubStore = class {
196
334
  marketSources = [];
197
335
  trashByName = /* @__PURE__ */ new Map();
198
336
  skillStats = void 0;
337
+ marketStats = void 0;
199
338
  collectionOrder = [];
200
339
  sourceGroupOrder = [];
201
340
  loaded = false;
@@ -213,97 +352,17 @@ var SkillHubStore = class {
213
352
  const migrated = migrateStore(JSON.parse(raw));
214
353
  if (migrated === null) console.warn("[dsh-skill-hub] sidecar state uses a newer schema than this plugin supports; starting empty");
215
354
  else {
216
- if (Array.isArray(migrated.disabled)) {
217
- for (const entry of migrated.disabled) if (typeof entry?.name === "string" && typeof entry?.path === "string") this.entries.set(entry.name, entry);
218
- }
219
- const saved = migrated.config;
220
- if (typeof saved === "object" && saved !== null) {
221
- if (typeof saved.enabled === "boolean") this.config.enabled = saved.enabled;
222
- if (typeof saved.announceToAgent === "boolean") this.config.announceToAgent = saved.announceToAgent;
223
- if (typeof saved.dotModelColor === "string" && saved.dotModelColor !== "") this.config.dotModelColor = saved.dotModelColor;
224
- if (typeof saved.dotUserColor === "string" && saved.dotUserColor !== "") this.config.dotUserColor = saved.dotUserColor;
225
- if (typeof saved.showUseCount === "boolean") this.config.showUseCount = saved.showUseCount;
226
- if (typeof saved.showUseTime === "boolean") this.config.showUseTime = saved.showUseTime;
227
- if (typeof saved.showGroupSummary === "boolean") this.config.showGroupSummary = saved.showGroupSummary;
228
- }
229
- if (Array.isArray(migrated.tags)) for (const entry of migrated.tags) {
230
- const tag = entry;
231
- if (tag !== null && typeof tag === "object" && typeof tag.id === "string" && typeof tag.name === "string" && Array.isArray(tag.skillNames)) this.tagsById.set(tag.id, {
232
- id: tag.id,
233
- name: tag.name,
234
- skillNames: tag.skillNames.filter((n) => typeof n === "string"),
235
- ...tag.default === true ? { default: true } : {}
236
- });
237
- }
238
- if (Array.isArray(migrated.sources)) for (const entry of migrated.sources) {
239
- const source = entry;
240
- if (source !== null && typeof source === "object" && typeof source.repo === "string" && source.repo !== "" && Array.isArray(source.skills)) {
241
- const manifest = source.manifest;
242
- const rawRoot = typeof source.root === "string" && source.root !== "" && /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(source.root) ? source.root : "skills";
243
- this.sourcesByRepo.set(source.repo, {
244
- repo: source.repo,
245
- ...typeof source.ref === "string" && source.ref !== "" ? { ref: source.ref } : {},
246
- root: rawRoot,
247
- commitSha: typeof source.commitSha === "string" ? source.commitSha : "",
248
- skills: source.skills.filter((n) => typeof n === "string"),
249
- ...manifest !== null && typeof manifest === "object" && !Array.isArray(manifest) ? { manifest: Object.fromEntries(Object.entries(manifest).filter(([, size]) => typeof size === "number")) } : {}
250
- });
251
- }
252
- }
253
- if (Array.isArray(migrated.marketSources)) for (const entry of migrated.marketSources) {
254
- const item = entry;
255
- if (item !== null && typeof item === "object" && typeof item.repo === "string" && item.repo !== "" && !this.marketSources.some((s) => s.repo === item.repo)) this.marketSources.push({
256
- repo: item.repo,
257
- ...typeof item.ref === "string" && item.ref !== "" ? { ref: item.ref } : {},
258
- ...typeof item.commitSha === "string" && item.commitSha !== "" ? { commitSha: item.commitSha } : {}
259
- });
260
- }
261
- if (Array.isArray(migrated.trash)) for (const entry of migrated.trash) {
262
- const item = entry;
263
- if (item !== null && typeof item === "object" && typeof item.name === "string" && typeof item.path === "string") {
264
- const origin = item.origin;
265
- const tagIds = item.tagIds;
266
- this.trashByName.set(item.name, {
267
- name: item.name,
268
- path: item.path,
269
- movedAt: typeof item.movedAt === "number" ? item.movedAt : 0,
270
- ...typeof item.sourcePath === "string" && item.sourcePath !== "" ? { sourcePath: item.sourcePath } : {},
271
- ...origin !== null && typeof origin === "object" && typeof origin.repo === "string" && origin.repo !== "" && typeof origin.root === "string" && origin.root !== "" && /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(origin.root) ? { origin: {
272
- repo: origin.repo,
273
- root: origin.root,
274
- ...typeof origin.ref === "string" && origin.ref !== "" ? { ref: origin.ref } : {},
275
- commitSha: typeof origin.commitSha === "string" ? origin.commitSha : ""
276
- } } : {},
277
- ...Array.isArray(tagIds) ? { tagIds: tagIds.filter((id) => typeof id === "string") } : {}
278
- });
279
- }
280
- }
281
- const savedStats = migrated.skillStats;
282
- if (savedStats !== null && typeof savedStats === "object" && typeof savedStats.frozenBefore === "number" && typeof savedStats.lastFullReconcile === "number" && typeof savedStats.windowDays === "number" && typeof savedStats.frozenSessions === "object" && savedStats.frozenSessions !== null) {
283
- const sessions = {};
284
- for (const [id, entry] of Object.entries(savedStats.frozenSessions)) {
285
- if (entry === null || typeof entry !== "object" || typeof entry.createdAt !== "number" || typeof entry.counts !== "object" || entry.counts === null) continue;
286
- const counts = {};
287
- for (const [name, stat] of Object.entries(entry.counts)) if (stat !== null && typeof stat === "object" && typeof stat.count === "number" && typeof stat.lastUsed === "number") counts[name] = {
288
- count: stat.count,
289
- lastUsed: stat.lastUsed
290
- };
291
- sessions[id] = {
292
- createdAt: entry.createdAt,
293
- counts
294
- };
295
- }
296
- this.skillStats = {
297
- windowDays: savedStats.windowDays,
298
- frozenBefore: savedStats.frozenBefore,
299
- frozenSessions: sessions,
300
- lastFullReconcile: savedStats.lastFullReconcile
301
- };
302
- }
303
- const rawColOrder = migrated.collectionOrder;
304
- if (Array.isArray(rawColOrder)) this.collectionOrder = rawColOrder.filter((n) => typeof n === "string" && n !== "");
305
- const rawSrcOrder = migrated.sourceGroupOrder;
306
- if (Array.isArray(rawSrcOrder)) this.sourceGroupOrder = rawSrcOrder.filter((n) => typeof n === "string" && n !== "");
355
+ const state = hydrateMigratedState(migrated);
356
+ this.entries = state.entries;
357
+ this.config = state.config;
358
+ this.tagsById = state.tagsById;
359
+ this.sourcesByRepo = state.sourcesByRepo;
360
+ this.marketSources = state.marketSources;
361
+ this.trashByName = state.trashByName;
362
+ this.skillStats = state.skillStats;
363
+ this.marketStats = state.marketStats;
364
+ this.collectionOrder = state.collectionOrder;
365
+ this.sourceGroupOrder = state.sourceGroupOrder;
307
366
  }
308
367
  } catch (error) {
309
368
  if (error.code !== "ENOENT") console.warn("[dsh-skill-hub] sidecar state unreadable, starting empty:", error instanceof Error ? error.message : error);
@@ -697,17 +756,36 @@ var SkillHubStore = class {
697
756
  await this.ensureLoaded();
698
757
  return this.skillStats !== void 0 ? {
699
758
  ...this.skillStats,
700
- frozenSessions: { ...this.skillStats.frozenSessions }
759
+ frozenSessions: { ...this.skillStats.frozenSessions },
760
+ ...this.skillStats.lastTotals !== void 0 ? { lastTotals: [...this.skillStats.lastTotals] } : {}
701
761
  } : void 0;
702
762
  }
703
- /** Persist a usage-statistics checkpoint (written at most ~once a day, on full reconciliations). */
763
+ /** Persist a usage-statistics checkpoint (after every completed scan; cadence follows the scan TTL). */
704
764
  async saveSkillStatsState(state) {
705
765
  await this.ensureLoaded();
706
766
  this.skillStats = {
707
767
  windowDays: state.windowDays,
708
768
  frozenBefore: state.frozenBefore,
709
769
  frozenSessions: { ...state.frozenSessions },
710
- lastFullReconcile: state.lastFullReconcile
770
+ lastFullReconcile: state.lastFullReconcile,
771
+ ...state.lastTotals !== void 0 ? { lastTotals: [...state.lastTotals] } : {}
772
+ };
773
+ await this.persist();
774
+ }
775
+ /** The persisted market-stats snapshot (undefined until first saved). */
776
+ async getMarketStatsState() {
777
+ await this.ensureLoaded();
778
+ return this.marketStats !== void 0 ? {
779
+ fetchedAt: this.marketStats.fetchedAt,
780
+ stats: { ...this.marketStats.stats }
781
+ } : void 0;
782
+ }
783
+ /** Persist a market-stats snapshot (written when a refresh fetched anything new). */
784
+ async saveMarketStatsState(state) {
785
+ await this.ensureLoaded();
786
+ this.marketStats = {
787
+ fetchedAt: state.fetchedAt,
788
+ stats: { ...state.stats }
711
789
  };
712
790
  await this.persist();
713
791
  }
@@ -722,6 +800,7 @@ var SkillHubStore = class {
722
800
  ...this.marketSources.length > 0 ? { marketSources: [...this.marketSources] } : {},
723
801
  ...this.trashByName.size > 0 ? { trash: [...this.trashByName.values()] } : {},
724
802
  ...this.skillStats !== void 0 ? { skillStats: this.skillStats } : {},
803
+ ...this.marketStats !== void 0 ? { marketStats: this.marketStats } : {},
725
804
  ...this.collectionOrder.length > 0 ? { collectionOrder: [...this.collectionOrder] } : {},
726
805
  ...this.sourceGroupOrder.length > 0 ? { sourceGroupOrder: [...this.sourceGroupOrder] } : {}
727
806
  };
@@ -867,10 +946,18 @@ function parseFrontmatter(text) {
867
946
  const match = /^---\s*\r?\n([\s\S]*?)\r?\n---\s*(?:\r?\n([\s\S]*))?$/.exec(text);
868
947
  if (match === null) return { error: "missing YAML frontmatter (--- block)" };
869
948
  let data;
949
+ let rawFrontmatter = match[1];
870
950
  try {
871
- data = load(match[1]);
951
+ data = load(rawFrontmatter);
872
952
  } catch (error) {
873
- return { error: "invalid YAML frontmatter: " + (error instanceof Error ? error.message : String(error)) };
953
+ const repaired = repairFrontmatterScalarFields(rawFrontmatter);
954
+ if (repaired !== null) try {
955
+ data = load(repaired);
956
+ rawFrontmatter = repaired;
957
+ } catch {
958
+ return { error: "invalid YAML frontmatter: " + (error instanceof Error ? error.message : String(error)) };
959
+ }
960
+ else return { error: "invalid YAML frontmatter: " + (error instanceof Error ? error.message : String(error)) };
874
961
  }
875
962
  if (typeof data !== "object" || data === null || Array.isArray(data)) return { error: "frontmatter must be a YAML mapping" };
876
963
  const record = data;
@@ -902,6 +989,111 @@ function parseFrontmatter(text) {
902
989
  content: (match[2] ?? "").trim()
903
990
  } };
904
991
  }
992
+ /**
993
+ * Repair bare scalar fields that contain an unquoted colon (e.g. `description: Build for AWS: ECS`)
994
+ * or an invalid flow-like scalar (`@`, `` ` ``, `[`, `{`). Mirrors codex `repair_frontmatter_scalar_fields`:
995
+ * line-oriented, only touches lines where quoting makes the YAML valid, and preserves block scalars.
996
+ * Returns the repaired frontmatter string when at least one line was quoted, otherwise null.
997
+ */
998
+ function repairFrontmatterScalarFields(frontmatter) {
999
+ let changed = false;
1000
+ let blockScalarIndent = null;
1001
+ const repairedLines = [];
1002
+ for (const line of frontmatter.split("\n")) {
1003
+ const indent = line.search(/[^ ]/);
1004
+ const effectiveIndent = indent === -1 ? line.length : indent;
1005
+ if (blockScalarIndent !== null) {
1006
+ if (line.trim() === "" || effectiveIndent > blockScalarIndent) {
1007
+ repairedLines.push(line);
1008
+ continue;
1009
+ }
1010
+ blockScalarIndent = null;
1011
+ }
1012
+ const colonIndex = line.indexOf(":");
1013
+ if (colonIndex === -1) {
1014
+ repairedLines.push(line);
1015
+ continue;
1016
+ }
1017
+ const key = line.slice(0, colonIndex);
1018
+ const value = line.slice(colonIndex + 1);
1019
+ if (key.trim() === "" || value.length === 0 || !/^\s/.test(value)) {
1020
+ repairedLines.push(line);
1021
+ continue;
1022
+ }
1023
+ const trimmedStart = value.trimStart();
1024
+ const leadingWhitespace = value.slice(0, value.length - trimmedStart.length);
1025
+ let scalar = trimmedStart;
1026
+ let comment = "";
1027
+ for (let idx = 0; idx < trimmedStart.length; idx += 1) if (trimmedStart[idx] === "#") {
1028
+ const prev = idx === 0 ? " " : trimmedStart[idx - 1];
1029
+ const next = idx + 1 < trimmedStart.length ? trimmedStart[idx + 1] : " ";
1030
+ if (/\s/.test(prev) && /\s/.test(next)) {
1031
+ const commentStart = trimmedStart.slice(0, idx).trimEnd().length;
1032
+ scalar = trimmedStart.slice(0, commentStart);
1033
+ comment = trimmedStart.slice(commentStart);
1034
+ break;
1035
+ }
1036
+ }
1037
+ scalar = scalar.trimEnd();
1038
+ if (scalar === "") {
1039
+ repairedLines.push(line);
1040
+ continue;
1041
+ }
1042
+ const firstChar = scalar[0];
1043
+ if (firstChar === "|" || firstChar === ">") {
1044
+ blockScalarIndent = effectiveIndent;
1045
+ repairedLines.push(line);
1046
+ continue;
1047
+ }
1048
+ if (firstChar === "'" || firstChar === "\"") {
1049
+ repairedLines.push(line);
1050
+ continue;
1051
+ }
1052
+ let hasColonSeparator = false;
1053
+ for (let i = 0; i < scalar.length - 1; i += 1) if (scalar[i] === ":" && /\s/.test(scalar[i + 1])) {
1054
+ hasColonSeparator = true;
1055
+ break;
1056
+ }
1057
+ let invalidFlowLike = false;
1058
+ if (firstChar === "[" || firstChar === "{" || firstChar === "@" || firstChar === "`") try {
1059
+ load(scalar);
1060
+ } catch {
1061
+ invalidFlowLike = true;
1062
+ }
1063
+ if (!hasColonSeparator && !invalidFlowLike) {
1064
+ repairedLines.push(line);
1065
+ continue;
1066
+ }
1067
+ const quotedScalar = "'" + scalar.replace(/'/g, "''") + "'";
1068
+ repairedLines.push(key + ":" + leadingWhitespace + quotedScalar + comment);
1069
+ changed = true;
1070
+ }
1071
+ return changed ? repairedLines.join("\n") : null;
1072
+ }
1073
+ /** Try to repair a SKILL.md's frontmatter in place (unquoted colon etc.). Returns the new file text or null when not fixable. */
1074
+ function repairFrontmatterFileText(text) {
1075
+ const match = /^---\s*\r?\n([\s\S]*?)\r?\n---\s*(?:\r?\n([\s\S]*))?$/.exec(text);
1076
+ if (match === null) return null;
1077
+ const repaired = repairFrontmatterScalarFields(match[1]);
1078
+ if (repaired === null) return null;
1079
+ try {
1080
+ load(repaired);
1081
+ } catch {
1082
+ return null;
1083
+ }
1084
+ const body = match[2] ?? "";
1085
+ return "---\n" + repaired + "\n---" + (body !== "" ? "\n" + body : "");
1086
+ }
1087
+ /** Repair one file on disk when its frontmatter is auto-fixable. Returns the new text. */
1088
+ async function fixDiagnosticFile(path, home = dshHome()) {
1089
+ if (rootOfPath(path, home) === void 0) throw new TypeError("not a hub writable skill path: " + path);
1090
+ const repairedText = repairFrontmatterFileText(await readFile(path, "utf8"));
1091
+ if (repairedText === null) throw new TypeError("diagnostic is not auto-fixable: " + path);
1092
+ const parsed = parseFrontmatter(repairedText);
1093
+ if ("error" in parsed) throw new TypeError("repaired frontmatter still invalid: " + parsed.error);
1094
+ await writeFile(path, repairedText, "utf8");
1095
+ return path;
1096
+ }
905
1097
  /** Official boolean grammar: true/false, 1/0, and the common string spellings. */
906
1098
  function frontmatterBoolean(data, key) {
907
1099
  if (!Object.hasOwn(data, key)) return void 0;
@@ -959,6 +1151,59 @@ async function scanRoot(base) {
959
1151
  function listSkillEntries(root, home = dshHome()) {
960
1152
  return scanRoot(rootPath(root, home));
961
1153
  }
1154
+ /** Read UI metadata from `agents/openai.yaml` beside a directory skill (mirrors codex SkillInterface). */
1155
+ async function readSkillInterface(directory) {
1156
+ const yamlPath = join(directory, "agents", "openai.yaml");
1157
+ let text;
1158
+ try {
1159
+ text = await readFile(yamlPath, "utf8");
1160
+ } catch {
1161
+ return;
1162
+ }
1163
+ let data;
1164
+ try {
1165
+ data = load(text);
1166
+ } catch {
1167
+ return;
1168
+ }
1169
+ if (typeof data !== "object" || data === null || Array.isArray(data)) return void 0;
1170
+ const record = data;
1171
+ const iface = record.interface ?? record;
1172
+ const ifaceObj = typeof record.interface === "object" && record.interface !== null && !Array.isArray(record.interface) ? record.interface : iface;
1173
+ const cleanStr = (value, maxLen) => {
1174
+ if (typeof value !== "string") return void 0;
1175
+ const cleaned = value.split(/\s+/).join(" ").trim();
1176
+ if (cleaned === "" || cleaned.length > maxLen) return void 0;
1177
+ return cleaned;
1178
+ };
1179
+ const hexColor = (value) => {
1180
+ if (typeof value !== "string") return void 0;
1181
+ const trimmed = value.trim();
1182
+ return /^#[0-9a-fA-F]{6}$/.test(trimmed) ? trimmed : void 0;
1183
+ };
1184
+ const iconPath = (value) => {
1185
+ if (typeof value !== "string" || value.trim() === "") return void 0;
1186
+ const trimmed = value.trim().replace(/\\/g, "/");
1187
+ if (trimmed.startsWith("/") || trimmed.includes("..")) return void 0;
1188
+ if (!trimmed.startsWith("assets/")) return void 0;
1189
+ return trimmed;
1190
+ };
1191
+ const displayName = cleanStr(ifaceObj.display_name, 64);
1192
+ const shortDescription = cleanStr(ifaceObj.short_description, 1024);
1193
+ const brandColor = hexColor(ifaceObj.brand_color);
1194
+ const defaultPrompt = cleanStr(ifaceObj.default_prompt, 1024);
1195
+ const iconSmall = iconPath(ifaceObj.icon_small);
1196
+ const iconLarge = iconPath(ifaceObj.icon_large);
1197
+ if (!(displayName !== void 0 || shortDescription !== void 0 || brandColor !== void 0 || iconSmall !== void 0 || iconLarge !== void 0 || defaultPrompt !== void 0)) return void 0;
1198
+ return {
1199
+ ...displayName !== void 0 ? { displayName } : {},
1200
+ ...shortDescription !== void 0 ? { shortDescription } : {},
1201
+ ...brandColor !== void 0 ? { brandColor } : {},
1202
+ ...iconSmall !== void 0 ? { iconSmall } : {},
1203
+ ...iconLarge !== void 0 ? { iconLarge } : {},
1204
+ ...defaultPrompt !== void 0 ? { defaultPrompt } : {}
1205
+ };
1206
+ }
962
1207
  /**
963
1208
  * Walk up from cwd (max 32 levels) for a project marker (.dsh or .git);
964
1209
  * falls back to cwd itself. The provider roots project skills here.
@@ -995,13 +1240,21 @@ async function scanDiagnostics(root, home = dshHome()) {
995
1240
  }
996
1241
  const parsed = parseFrontmatter(text);
997
1242
  if ("error" in parsed) {
1243
+ const fixable = repairFrontmatterFileText(text) !== null;
998
1244
  diagnostics.push({
999
1245
  path: entry.path,
1000
1246
  root,
1001
- reason: parsed.error
1247
+ reason: parsed.error,
1248
+ ...fixable ? { fixable: true } : {}
1002
1249
  });
1003
1250
  continue;
1004
1251
  }
1252
+ if (repairFrontmatterFileText(text) !== null) diagnostics.push({
1253
+ path: entry.path,
1254
+ root,
1255
+ reason: "frontmatter contains unquoted colon/bracket (auto-repaired in memory; click Fix to persist)",
1256
+ fixable: true
1257
+ });
1005
1258
  const { value } = parsed;
1006
1259
  const pathName = entry.kind === "directory" ? basename(entry.directory) : basename(entry.path, ".md");
1007
1260
  if (value.name !== pathName) diagnostics.push({
@@ -1173,17 +1426,12 @@ var SkillHubProvider = class {
1173
1426
  }
1174
1427
  };
1175
1428
  //#endregion
1176
- //#region src/repo.ts
1429
+ //#region src/repo/github-client.ts
1177
1430
  /**
1178
- * GitHub repository skill discovery/import helpers.
1179
- *
1180
- * Kept dependency-free and mostly pure so the root/origin rules are easy to
1181
- * test. Roots are auto-derived: any top-level directory that contains a
1182
- * `**\/SKILL.md` (e.g. `skills/**`, `design-templates/**`, `templates/**`,
1183
- * `workflows/**`) is treated as a skill root. No hard-coded allowlist.
1431
+ * GitHub API 统一请求层:token 管理、鉴权头、ETag 缓存、错误映射、
1432
+ * 带缓存的 JSON GET。从 repo.ts 抽出,原 6 处重复的 try/catch +
1433
+ * headers + json 解析收敛到 fetchJson / fetchJsonCached 两个入口。
1184
1434
  */
1185
- /** Top-level directory pattern for a skill root: visible, non-dot, safe chars. First char must be alphanum. */
1186
- const ROOT_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
1187
1435
  /** Fetch failure carrying a useful HTTP status for the route layer. */
1188
1436
  var RepoFetchError = class extends Error {
1189
1437
  status;
@@ -1196,13 +1444,41 @@ var RepoFetchError = class extends Error {
1196
1444
  /**
1197
1445
  * GitHub token for authenticated API calls. Read from GITHUB_TOKEN /
1198
1446
  * GH_TOKEN at module load; setGithubToken() overrides it at runtime (the
1199
- * plugin config route calls it when a saved token exists).
1447
+ * host calls it from the settings sync whenever the card value changes;
1448
+ * an absent value falls back to the env var).
1200
1449
  */
1201
1450
  let githubToken = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN ?? "";
1451
+ /** Override the GitHub token at runtime ('' falls back to the env var). */
1452
+ function setGithubToken(token) {
1453
+ githubToken = token !== void 0 && token !== "" ? token : process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN ?? "";
1454
+ }
1202
1455
  /** Authorization header for api.github.com / raw.githubusercontent.com calls. */
1203
1456
  function githubAuthHeaders() {
1204
1457
  return githubToken === "" ? {} : { authorization: "Bearer " + githubToken };
1205
1458
  }
1459
+ /** JSON API 默认请求头(含鉴权)。 */
1460
+ function apiHeaders() {
1461
+ return {
1462
+ accept: "application/vnd.github+json",
1463
+ ...githubAuthHeaders()
1464
+ };
1465
+ }
1466
+ /** ETag cache for GitHub API JSON (mirrors codex lib.rs marker+fingerprint). In-memory only, saves 304 for daily checks. */
1467
+ const etagCache = /* @__PURE__ */ new Map();
1468
+ const ETAG_MAX = 200;
1469
+ function etagCacheGet(url) {
1470
+ return etagCache.get(url);
1471
+ }
1472
+ function etagCacheSet(url, etag, json) {
1473
+ if (etagCache.size >= ETAG_MAX) {
1474
+ const first = etagCache.keys().next().value;
1475
+ if (first !== void 0) etagCache.delete(first);
1476
+ }
1477
+ etagCache.set(url, {
1478
+ etag,
1479
+ json
1480
+ });
1481
+ }
1206
1482
  /**
1207
1483
  * Build a RepoFetchError; when the response shows an exhausted rate limit,
1208
1484
  * report the reset time instead of a bare HTTP status.
@@ -1215,6 +1491,78 @@ function fetchError(context, response, fallbackStatus = 502) {
1215
1491
  }
1216
1492
  return new RepoFetchError(context + " (HTTP " + response.status + ")", response.status === 404 ? 404 : fallbackStatus);
1217
1493
  }
1494
+ /** 是否限流/中断类错误(调用方可直接透出,不再包装)。 */
1495
+ function isAbortError(error) {
1496
+ return error instanceof Error && (error.name === "AbortError" || error.message.includes("aborted") || error.message.includes("Abort"));
1497
+ }
1498
+ /**
1499
+ * 不带缓存的统一 GET+JSON:网络异常、非 2xx、坏 JSON 全部映射为
1500
+ * RepoFetchError,调用方无需重复写 try/catch。
1501
+ */
1502
+ async function fetchJson(url, fetchImpl, context, headers = apiHeaders()) {
1503
+ let response;
1504
+ try {
1505
+ response = await fetchImpl(url, { headers });
1506
+ } catch (error) {
1507
+ if (isAbortError(error)) throw error;
1508
+ throw new RepoFetchError(context + ": " + (error instanceof Error ? error.message : String(error)));
1509
+ }
1510
+ if (!response.ok) throw fetchError(context, response);
1511
+ try {
1512
+ return {
1513
+ json: await response.json(),
1514
+ response
1515
+ };
1516
+ } catch {
1517
+ throw new RepoFetchError("invalid github response for " + url);
1518
+ }
1519
+ }
1520
+ /**
1521
+ * Load repo metadata and the recursive git tree at the given ref. The meta
1522
+ * request always resolves the default branch (used when ref is absent); the
1523
+ * tree is fetched at `ref ?? default_branch` so an explicit branch/tag scans
1524
+ * the exact same content a later download would fetch.
1525
+ */
1526
+ async function fetchJsonCached(url, fetchImpl, context) {
1527
+ const cached = etagCacheGet(url);
1528
+ const headers = { ...apiHeaders() };
1529
+ if (cached !== void 0) headers["if-none-match"] = cached.etag;
1530
+ let response;
1531
+ try {
1532
+ response = await fetchImpl(url, { headers });
1533
+ } catch (error) {
1534
+ throw new RepoFetchError(context + ": " + (error instanceof Error ? error.message : String(error)));
1535
+ }
1536
+ if (response.status === 304 && cached !== void 0) return {
1537
+ json: cached.json,
1538
+ response
1539
+ };
1540
+ if (!response.ok) throw fetchError(context, response);
1541
+ let json;
1542
+ try {
1543
+ json = await response.json();
1544
+ } catch {
1545
+ throw new RepoFetchError("invalid github response for " + url);
1546
+ }
1547
+ const etag = response.headers.get("etag");
1548
+ if (etag !== null && etag !== "") etagCacheSet(url, etag, json);
1549
+ return {
1550
+ json,
1551
+ response
1552
+ };
1553
+ }
1554
+ //#endregion
1555
+ //#region src/repo.ts
1556
+ /**
1557
+ * GitHub repository skill discovery/import helpers.
1558
+ *
1559
+ * Kept dependency-free and mostly pure so the root/origin rules are easy to
1560
+ * test. Roots are auto-derived: any top-level directory that contains a
1561
+ * `**\/SKILL.md` (e.g. `skills/**`, `design-templates/**`, `templates/**`,
1562
+ * `workflows/**`) is treated as a skill root. No hard-coded allowlist.
1563
+ */
1564
+ /** Top-level directory pattern for a skill root: visible, non-dot, safe chars. First char must be alphanum. */
1565
+ const ROOT_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
1218
1566
  /** `owner/repo` slug for a parsed reference. */
1219
1567
  function repoSlug(ref) {
1220
1568
  return `${ref.owner}/${ref.repo}`;
@@ -1251,53 +1599,19 @@ function normalizeRepoInput(input) {
1251
1599
  ...ref !== void 0 && ref !== "" ? { ref } : {}
1252
1600
  };
1253
1601
  }
1254
- /**
1255
- * Load repo metadata and the recursive git tree at the given ref. The meta
1256
- * request always resolves the default branch (used when ref is absent); the
1257
- * tree is fetched at `ref ?? default_branch` so an explicit branch/tag scans
1258
- * the exact same content a later download would fetch.
1259
- */
1260
1602
  async function loadRepoTree(repo, ref, fetchImpl = fetch) {
1261
- let metaResponse;
1262
- try {
1263
- metaResponse = await fetchImpl(`https://api.github.com/repos/${repo}`, { headers: {
1264
- accept: "application/vnd.github+json",
1265
- ...githubAuthHeaders()
1266
- } });
1267
- } catch (error) {
1268
- throw new RepoFetchError("github request failed: " + (error instanceof Error ? error.message : String(error)));
1269
- }
1270
- if (!metaResponse.ok) throw fetchError("github repo not found or unavailable", metaResponse);
1271
- let meta;
1272
- try {
1273
- meta = await metaResponse.json();
1274
- } catch {
1275
- throw new RepoFetchError("invalid github repo response");
1276
- }
1603
+ const { json: metaJson } = await fetchJsonCached(`https://api.github.com/repos/${repo}`, fetchImpl, "github repo not found or unavailable");
1604
+ let meta = metaJson;
1277
1605
  const record = typeof meta === "object" && meta !== null ? meta : {};
1278
1606
  const defaultBranch = typeof record.default_branch === "string" && record.default_branch !== "" ? record.default_branch : "main";
1279
1607
  const treeRef = ref !== void 0 && ref !== "" ? ref : defaultBranch;
1280
- let treeResponse;
1281
- try {
1282
- treeResponse = await fetchImpl(`https://api.github.com/repos/${repo}/git/trees/${encodeURIComponent(treeRef)}?recursive=1`, { headers: {
1283
- accept: "application/vnd.github+json",
1284
- ...githubAuthHeaders()
1285
- } });
1286
- } catch (error) {
1287
- throw new RepoFetchError("github tree request failed: " + (error instanceof Error ? error.message : String(error)));
1288
- }
1289
- if (!treeResponse.ok) throw fetchError("github tree not found", treeResponse);
1290
- let payload;
1291
- try {
1292
- payload = await treeResponse.json();
1293
- } catch {
1294
- throw new RepoFetchError("invalid github tree response");
1295
- }
1608
+ const { json: payload } = await fetchJsonCached(`https://api.github.com/repos/${repo}/git/trees/${encodeURIComponent(treeRef)}?recursive=1`, fetchImpl, "github tree not found");
1296
1609
  const treeRecord = typeof payload === "object" && payload !== null ? payload : {};
1297
- if (treeRecord.truncated === true) throw new RepoFetchError("repo tree is too large to scan");
1610
+ const truncated = treeRecord.truncated === true;
1298
1611
  return {
1299
1612
  ref: treeRef,
1300
- tree: Array.isArray(treeRecord.tree) ? treeRecord.tree : []
1613
+ tree: Array.isArray(treeRecord.tree) ? treeRecord.tree : [],
1614
+ truncated
1301
1615
  };
1302
1616
  }
1303
1617
  /** Collect every file inside a skill directory, including SKILL.md itself. */
@@ -1350,7 +1664,7 @@ function discoverRepoEntries(tree, repo, existingNames = /* @__PURE__ */ new Set
1350
1664
  }).sort((a, b) => a.name.localeCompare(b.name));
1351
1665
  }
1352
1666
  /** Run an async worker over items with a bounded concurrency. */
1353
- async function mapConcurrent(items, limit, worker) {
1667
+ async function mapConcurrent$1(items, limit, worker) {
1354
1668
  const results = new Array(items.length);
1355
1669
  let cursor = 0;
1356
1670
  const runners = Array.from({ length: Math.min(limit, items.length) }, async () => {
@@ -1363,10 +1677,6 @@ async function mapConcurrent(items, limit, worker) {
1363
1677
  await Promise.all(runners);
1364
1678
  return results;
1365
1679
  }
1366
- /** Whether an error is an abort (AbortError / signal). */
1367
- function isAbortError(error) {
1368
- return error instanceof Error && (error.name === "AbortError" || error.message.includes("aborted") || error.message.includes("Abort"));
1369
- }
1370
1680
  /**
1371
1681
  * Download one GitHub file as a buffer. Tries raw.githubusercontent.com
1372
1682
  * first (no API quota); on any failure falls back to the api.github.com
@@ -1424,7 +1734,7 @@ async function downloadRepoSkill(repo, ref, entry, files, targetRoot, fetchImpl
1424
1734
  const tempDir = await mkdtemp(join(targetRoot, "." + entry.name + ".import-"));
1425
1735
  let renamed = false;
1426
1736
  try {
1427
- await mapConcurrent(files, 6, async (file) => {
1737
+ await mapConcurrent$1(files, 6, async (file) => {
1428
1738
  if (signal?.aborted) throw new DOMException("aborted", "AbortError");
1429
1739
  const relative = file.path.slice(entry.dir.length + 1);
1430
1740
  if (relative === "" || relative.includes("..")) throw new RepoFetchError("unsafe repo path: " + file.path);
@@ -1505,44 +1815,48 @@ async function cleanupLeftoverImportDirs(targetRoot) {
1505
1815
  * repo has no releases (GitHub's /releases/latest skips prereleases/drafts).
1506
1816
  */
1507
1817
  async function getLatestReleaseTag(repo, fetchImpl = fetch) {
1508
- let response;
1509
- try {
1510
- response = await fetchImpl(`https://api.github.com/repos/${repo}/releases/latest`, { headers: {
1511
- accept: "application/vnd.github+json",
1512
- ...githubAuthHeaders()
1513
- } });
1514
- } catch (error) {
1515
- throw new RepoFetchError("github release request failed: " + (error instanceof Error ? error.message : String(error)));
1516
- }
1517
- if (response.status === 404) return void 0;
1518
- if (!response.ok) throw fetchError("github release lookup failed", response);
1519
1818
  let payload;
1520
1819
  try {
1521
- payload = await response.json();
1522
- } catch {
1523
- throw new RepoFetchError("invalid github release response");
1820
+ ({json: payload} = await fetchJson(`https://api.github.com/repos/${repo}/releases/latest`, fetchImpl, "github release lookup failed"));
1821
+ } catch (error) {
1822
+ if (error instanceof RepoFetchError && error.status === 404) return void 0;
1823
+ throw error;
1524
1824
  }
1525
1825
  const tag = typeof payload === "object" && payload !== null ? payload.tag_name : void 0;
1526
1826
  return typeof tag === "string" && tag !== "" ? tag : void 0;
1527
1827
  }
1828
+ /** Stars + release-asset downloads of a repo (two GitHub API requests). */
1829
+ async function getRepoStats(repo, fetchImpl = fetch) {
1830
+ const headers = apiHeaders();
1831
+ const { json: meta } = await fetchJson(`https://api.github.com/repos/${repo}`, fetchImpl, "github repo not found or unavailable", headers);
1832
+ const stars = typeof meta === "object" && meta !== null && typeof meta.stargazers_count === "number" ? meta.stargazers_count : 0;
1833
+ let downloads = 0;
1834
+ try {
1835
+ const relResponse = await fetchImpl(`https://api.github.com/repos/${repo}/releases?per_page=20`, { headers });
1836
+ if (relResponse.ok) {
1837
+ const releases = await relResponse.json();
1838
+ if (Array.isArray(releases)) for (const item of releases) {
1839
+ if (typeof item !== "object" || item === null) continue;
1840
+ const assets = item.assets;
1841
+ if (!Array.isArray(assets)) continue;
1842
+ for (const asset of assets) if (typeof asset === "object" && asset !== null && typeof asset.download_count === "number") downloads += asset.download_count;
1843
+ }
1844
+ }
1845
+ } catch {}
1846
+ return {
1847
+ stars,
1848
+ downloads
1849
+ };
1850
+ }
1851
+ /** Release tags of a repo, newest first (skips drafts, keeps prereleases). */
1852
+ async function listRepoReleases(repo, fetchImpl = fetch) {
1853
+ const { json: payload } = await fetchJson(`https://api.github.com/repos/${repo}/releases?per_page=20`, fetchImpl, "github releases lookup failed");
1854
+ if (!Array.isArray(payload)) return [];
1855
+ return payload.filter((item) => typeof item === "object" && item !== null && item.draft !== true).map((item) => item.tag_name).filter((tag) => typeof tag === "string" && tag !== "");
1856
+ }
1528
1857
  /** Branch names of a repo, default branch first (one GitHub API request). */
1529
1858
  async function listRepoBranches(repo, fetchImpl = fetch) {
1530
- let response;
1531
- try {
1532
- response = await fetchImpl(`https://api.github.com/repos/${repo}/branches?per_page=100`, { headers: {
1533
- accept: "application/vnd.github+json",
1534
- ...githubAuthHeaders()
1535
- } });
1536
- } catch (error) {
1537
- throw new RepoFetchError("github branches request failed: " + (error instanceof Error ? error.message : String(error)));
1538
- }
1539
- if (!response.ok) throw fetchError("github branches lookup failed", response);
1540
- let payload;
1541
- try {
1542
- payload = await response.json();
1543
- } catch {
1544
- throw new RepoFetchError("invalid github branches response");
1545
- }
1859
+ const { json: payload } = await fetchJson(`https://api.github.com/repos/${repo}/branches?per_page=100`, fetchImpl, "github branches lookup failed");
1546
1860
  if (!Array.isArray(payload)) return [];
1547
1861
  return payload.map((item) => typeof item === "object" && item !== null ? item.name : void 0).filter((name) => typeof name === "string" && name !== "");
1548
1862
  }
@@ -1552,23 +1866,7 @@ async function listRepoBranches(repo, fetchImpl = fetch) {
1552
1866
  * upstream tree on change (one extra request).
1553
1867
  */
1554
1868
  async function getLatestCommit(repo, ref, fetchImpl = fetch) {
1555
- 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`;
1556
- let response;
1557
- try {
1558
- response = await fetchImpl(url, { headers: {
1559
- accept: "application/vnd.github+json",
1560
- ...githubAuthHeaders()
1561
- } });
1562
- } catch (error) {
1563
- throw new RepoFetchError("github request failed: " + (error instanceof Error ? error.message : String(error)));
1564
- }
1565
- if (!response.ok) throw fetchError("github commit lookup failed", response);
1566
- let payload;
1567
- try {
1568
- payload = await response.json();
1569
- } catch {
1570
- throw new RepoFetchError("invalid github commit response");
1571
- }
1869
+ const { json: payload } = await fetchJson(ref !== void 0 && ref !== "" ? `https://api.github.com/repos/${repo}/commits/${encodeURIComponent(ref)}` : `https://api.github.com/repos/${repo}/commits?per_page=1`, fetchImpl, "github commit lookup failed");
1572
1870
  const record = Array.isArray(payload) ? payload[0] ?? {} : typeof payload === "object" && payload !== null ? payload : {};
1573
1871
  const commitSha = typeof record.sha === "string" && record.sha !== "" ? record.sha : null;
1574
1872
  const tree = typeof record.commit === "object" && record.commit !== null ? record.commit.tree : void 0;
@@ -1581,22 +1879,7 @@ async function getLatestCommit(repo, ref, fetchImpl = fetch) {
1581
1879
  }
1582
1880
  /** Load the recursive git tree at an explicit tree SHA (one API request). */
1583
1881
  async function loadRepoTreeAt(repo, treeSha, fetchImpl = fetch) {
1584
- let response;
1585
- try {
1586
- response = await fetchImpl(`https://api.github.com/repos/${repo}/git/trees/${encodeURIComponent(treeSha)}?recursive=1`, { headers: {
1587
- accept: "application/vnd.github+json",
1588
- ...githubAuthHeaders()
1589
- } });
1590
- } catch (error) {
1591
- throw new RepoFetchError("github tree request failed: " + (error instanceof Error ? error.message : String(error)));
1592
- }
1593
- if (!response.ok) throw fetchError("github tree lookup failed", response);
1594
- let payload;
1595
- try {
1596
- payload = await response.json();
1597
- } catch {
1598
- throw new RepoFetchError("invalid github tree response");
1599
- }
1882
+ const { json: payload } = await fetchJson(`https://api.github.com/repos/${repo}/git/trees/${encodeURIComponent(treeSha)}?recursive=1`, fetchImpl, "github tree lookup failed");
1600
1883
  const record = typeof payload === "object" && payload !== null ? payload : {};
1601
1884
  if (record.truncated === true) throw new RepoFetchError("repo tree is too large to scan");
1602
1885
  return Array.isArray(record.tree) ? record.tree : [];
@@ -1801,25 +2084,6 @@ async function checkLatestRelease(repo = UPDATE_REPO, fetchImpl = fetch) {
1801
2084
  url: typeof record.html_url === "string" ? record.html_url : null
1802
2085
  };
1803
2086
  }
1804
- //#endregion
1805
- //#region src/routes.ts
1806
- /**
1807
- * The /api/skill-hub route family: full catalog (enabled skills from the
1808
- * official registry + hub-disabled skills + discovery diagnostics), skill
1809
- * detail, enable/disable toggle, new-skill scaffold, user groups (tags +
1810
- * origin collections), and upstream source tracking (check/sync/follow
1811
- * upstream deletion into a restorable trash). Every route carries a
1812
- * loopback-only trust fence — these endpoints rename files under the user's
1813
- * skill roots, so LAN-exposed dsh web deployments must not serve them.
1814
- *
1815
- * Every handler is declared through the local `route()` wrapper, which owns
1816
- * the shared request fences (loopback trust, HTTP method, master switch,
1817
- * JSON body) exactly once, so a new route cannot forget them.
1818
- */
1819
- /** Cap on JSON request bodies (toggle/create payloads are tiny). */
1820
- const MAX_JSON_BODY_BYTES = 64 * 1024;
1821
- /** Minimum interval between network update checks per source (GitHub rate limits). */
1822
- const MIN_CHECK_INTERVAL_MS = 5 * 6e4;
1823
2087
  /** Loopback literal check plus browser same-origin markers. */
1824
2088
  function isLoopbackRequest(request) {
1825
2089
  const address = request.socket.remoteAddress;
@@ -1880,7 +2144,7 @@ async function readJsonBody(req) {
1880
2144
  for await (const chunk of req) {
1881
2145
  const buffer = chunk;
1882
2146
  size += buffer.length;
1883
- if (size > MAX_JSON_BODY_BYTES) return void 0;
2147
+ if (size > 65536) return void 0;
1884
2148
  chunks.push(buffer);
1885
2149
  }
1886
2150
  try {
@@ -2045,15 +2309,23 @@ async function buildCatalog(deps, cwd) {
2045
2309
  title: ""
2046
2310
  }];
2047
2311
  }
2048
- const byName = /* @__PURE__ */ new Map();
2312
+ const byKey = /* @__PURE__ */ new Map();
2313
+ const identitiesByKey = /* @__PURE__ */ new Map();
2049
2314
  let complete = true;
2050
2315
  for (const ws of workspaces) {
2051
2316
  const lookup = ws.path !== "" ? { cwd: ws.path } : void 0;
2052
2317
  const snapshot = await deps.skills.snapshot(lookup);
2053
2318
  if (!snapshot.complete) complete = false;
2054
2319
  for (const skill of snapshot.skills) {
2055
- if (byName.has(skill.name)) continue;
2056
- byName.set(skill.name, {
2320
+ const logicalKey = isProjectSource(skill.source) && ws.path !== "" ? `${skill.name}\0${ws.path}\0${skill.source}` : skill.name;
2321
+ let identities = identitiesByKey.get(logicalKey);
2322
+ if (identities === void 0) {
2323
+ identities = /* @__PURE__ */ new Set();
2324
+ identitiesByKey.set(logicalKey, identities);
2325
+ }
2326
+ identities.add(skill.source + "\0" + skill.provider);
2327
+ if (byKey.has(logicalKey)) continue;
2328
+ byKey.set(logicalKey, {
2057
2329
  skill,
2058
2330
  ...isProjectSource(skill.source) && ws.path !== "" ? {
2059
2331
  workspace: ws.path,
@@ -2062,6 +2334,17 @@ async function buildCatalog(deps, cwd) {
2062
2334
  });
2063
2335
  }
2064
2336
  }
2337
+ const disabled = await deps.store.listDisabled();
2338
+ const byName = byKey;
2339
+ for (const d of disabled) {
2340
+ let identities = identitiesByKey.get(d.name);
2341
+ if (identities === void 0) {
2342
+ identities = /* @__PURE__ */ new Set();
2343
+ identitiesByKey.set(d.name, identities);
2344
+ }
2345
+ identities.add("hub-disabled\0" + d.root);
2346
+ }
2347
+ const duplicateNames = [...identitiesByKey.entries()].filter(([, identities]) => identities.size > 1).map(([key]) => key.split("\0")[0]).filter((name, idx, arr) => arr.indexOf(name) === idx).sort((a, b) => a.localeCompare(b));
2065
2348
  const timesByName = /* @__PURE__ */ new Map();
2066
2349
  await Promise.all([...byName.values()].map(async ({ skill }) => {
2067
2350
  if (!isWritableSource(skill.source)) return;
@@ -2079,6 +2362,22 @@ async function buildCatalog(deps, cwd) {
2079
2362
  return;
2080
2363
  } catch {}
2081
2364
  }));
2365
+ const interfaceByName = /* @__PURE__ */ new Map();
2366
+ await Promise.all([...byName.values()].map(async ({ skill, workspace }) => {
2367
+ const candidates = [];
2368
+ if (isWritableSource(skill.source)) candidates.push(join(rootPath(skill.source, home), skill.name));
2369
+ else if (isProjectSource(skill.source) && workspace !== void 0) candidates.push(join(workspace, skill.source === "project-dsh" ? ".dsh/skills" : ".agents/skills", skill.name));
2370
+ else if (isProjectSource(skill.source)) {
2371
+ for (const ws of workspaces) if (ws.path !== "") candidates.push(join(ws.path, skill.source === "project-dsh" ? ".dsh/skills" : ".agents/skills", skill.name));
2372
+ }
2373
+ for (const dir of candidates) try {
2374
+ const iface = await readSkillInterface(dir);
2375
+ if (iface !== void 0) {
2376
+ interfaceByName.set(skill.name, iface);
2377
+ return;
2378
+ }
2379
+ } catch {}
2380
+ }));
2082
2381
  const skills = [...byName.values()].map(({ skill, workspace, workspaceTitle }) => {
2083
2382
  const row = {
2084
2383
  name: skill.name,
@@ -2101,9 +2400,17 @@ async function buildCatalog(deps, cwd) {
2101
2400
  row.addedAt = times.addedAt;
2102
2401
  row.updatedAt = times.updatedAt;
2103
2402
  }
2403
+ const iface = interfaceByName.get(skill.name);
2404
+ if (iface !== void 0) {
2405
+ if (iface.displayName !== void 0) row.displayName = iface.displayName;
2406
+ if (iface.shortDescription !== void 0) row.shortDescription = iface.shortDescription;
2407
+ if (iface.brandColor !== void 0) row.brandColor = iface.brandColor;
2408
+ if (iface.iconSmall !== void 0) row.iconSmall = iface.iconSmall;
2409
+ if (iface.iconLarge !== void 0) row.iconLarge = iface.iconLarge;
2410
+ if (iface.defaultPrompt !== void 0) row.defaultPrompt = iface.defaultPrompt;
2411
+ }
2104
2412
  return row;
2105
2413
  });
2106
- const disabled = await deps.store.listDisabled();
2107
2414
  const diagnostics = [...await scanDiagnostics("user-dsh", home), ...await scanDiagnostics("user-agents", home)];
2108
2415
  return {
2109
2416
  ok: true,
@@ -2111,7 +2418,8 @@ async function buildCatalog(deps, cwd) {
2111
2418
  complete,
2112
2419
  skills,
2113
2420
  disabled,
2114
- diagnostics
2421
+ diagnostics,
2422
+ ...duplicateNames.length > 0 ? { duplicateNames } : {}
2115
2423
  };
2116
2424
  }
2117
2425
  /** Map a loaded definition onto the wire shape. */
@@ -2129,50 +2437,14 @@ function toDetail(skill) {
2129
2437
  content: skill.content
2130
2438
  };
2131
2439
  }
2132
- /** Last successful check time per repo (in-memory throttle for GitHub rate limits). */
2133
- const lastSourceCheck = /* @__PURE__ */ new Map();
2134
- /** Last successful market update check per repo (same throttle). */
2135
- const lastMarketCheck = /* @__PURE__ */ new Map();
2136
- const importJobs = /* @__PURE__ */ new Map();
2137
- const IMPORT_JOB_TTL_MS = 5 * 6e4;
2138
- const IMPORT_JOB_MAX = 100;
2139
- function gcImportJobs() {
2140
- if (importJobs.size > 500) importJobs.clear();
2141
- const now = Date.now();
2142
- for (const [id, job] of importJobs) if (job.status !== "running" && now - job.createdAt > IMPORT_JOB_TTL_MS) importJobs.delete(id);
2143
- if (importJobs.size > IMPORT_JOB_MAX) {
2144
- const sorted = [...importJobs.entries()].sort((a, b) => a[1].createdAt - b[1].createdAt);
2145
- for (let i = 0; i < sorted.length - IMPORT_JOB_MAX; i++) importJobs.delete(sorted[i][0]);
2146
- }
2147
- }
2148
- /** Replace one skill directory with a fresh download; restores the old dir on failure. */
2149
- async function replaceSkillDir(targetDir, download) {
2150
- const backup = join(dirname(targetDir), "." + basename(targetDir) + ".sync-bak-" + Date.now());
2151
- await rename(targetDir, backup);
2152
- try {
2153
- await download();
2154
- } catch (error) {
2155
- await rename(backup, targetDir).catch(() => {});
2156
- throw error;
2157
- }
2158
- await rm(backup, {
2159
- recursive: true,
2160
- force: true
2161
- });
2162
- }
2163
2440
  /**
2164
- * Build every /api/skill-hub route.
2165
- * @param deps - skill registry view + sidecar store.
2166
- * @returns the exact-path routes.
2441
+ * Wrap one handler in the shared request fences. Order matches the
2442
+ * original per-handler prologue: loopback trust → HTTP method → master
2443
+ * switch → JSON body. The outer catch maps errors onto the JSON error
2444
+ * body (preserving RepoFetchError status codes).
2167
2445
  */
2168
- function makeRoutes(deps) {
2169
- /**
2170
- * Wrap one handler in the shared request fences. Order matches the
2171
- * original per-handler prologue: loopback trust → HTTP method → master
2172
- * switch → JSON body. The outer catch maps errors onto the JSON error
2173
- * body (preserving RepoFetchError status codes).
2174
- */
2175
- const route = (spec) => ({
2446
+ function createRoute(deps, spec) {
2447
+ return {
2176
2448
  kind: "exact",
2177
2449
  path: spec.path,
2178
2450
  handler: async (req, res) => {
@@ -2206,16 +2478,25 @@ function makeRoutes(deps) {
2206
2478
  writeRouteError(res, error);
2207
2479
  }
2208
2480
  }
2209
- });
2481
+ };
2482
+ }
2483
+ //#endregion
2484
+ //#region src/routes/catalog.ts
2485
+ /**
2486
+ * 目录域路由:catalog / skill 详情 / skill 删除 / toggle / toggle-batch /
2487
+ * create / stats。从 routes.ts 原样搬出,handler 逻辑不变。
2488
+ */
2489
+ /** 目录域全部路由 spec(由 routes.ts 经 createRoute 包上统一围栏)。 */
2490
+ function catalogRoutes(deps) {
2210
2491
  return [
2211
- route({
2492
+ {
2212
2493
  path: SKILL_HUB_API.catalog,
2213
2494
  methods: ["GET"],
2214
2495
  handler: async ({ res, url }) => {
2215
2496
  writeJson(res, 200, await buildCatalog(deps, queryParam(url, "cwd")));
2216
2497
  }
2217
- }),
2218
- route({
2498
+ },
2499
+ {
2219
2500
  path: SKILL_HUB_API.skill,
2220
2501
  methods: ["GET"],
2221
2502
  handler: async ({ res, url }) => {
@@ -2235,22 +2516,64 @@ function makeRoutes(deps) {
2235
2516
  if (skill === void 0) skill = await deps.skills.get(name);
2236
2517
  }
2237
2518
  if (skill === void 0) {
2519
+ const record = await deps.store.getDisabled(name);
2520
+ if (record !== void 0) try {
2521
+ const parsed = parseFrontmatter(await readFile(record.path, "utf8"));
2522
+ if (!("error" in parsed)) {
2523
+ const disabledDetail = {
2524
+ name: record.name,
2525
+ description: parsed.value.description,
2526
+ ...parsed.value.whenToUse !== void 0 ? { whenToUse: parsed.value.whenToUse } : {},
2527
+ invocation: { ...parsed.value.invocation },
2528
+ provider: "skill-hub (disabled)",
2529
+ path: record.path,
2530
+ content: parsed.value.content
2531
+ };
2532
+ try {
2533
+ const times = await stat(record.path);
2534
+ disabledDetail.addedAt = times.birthtimeMs;
2535
+ disabledDetail.updatedAt = times.mtimeMs;
2536
+ } catch {}
2537
+ writeJson(res, 200, {
2538
+ ok: true,
2539
+ skill: disabledDetail
2540
+ });
2541
+ return;
2542
+ }
2543
+ } catch {}
2238
2544
  writeError(res, 404, "skill not found: " + name);
2239
2545
  return;
2240
2546
  }
2241
2547
  const detail = toDetail(skill);
2242
- if (skill.path !== void 0) try {
2243
- const times = await stat(skill.path);
2244
- detail.addedAt = times.birthtimeMs;
2245
- detail.updatedAt = times.mtimeMs;
2246
- } catch {}
2548
+ if (skill.path !== void 0) {
2549
+ try {
2550
+ const times = await stat(skill.path);
2551
+ detail.addedAt = times.birthtimeMs;
2552
+ detail.updatedAt = times.mtimeMs;
2553
+ } catch {}
2554
+ try {
2555
+ const rb = skill.resourceBase;
2556
+ const dir = rb?.kind === "directory" && typeof rb.path === "string" ? rb.path : skill.path.endsWith("SKILL.md") ? dirname(skill.path) : void 0;
2557
+ if (dir !== void 0) {
2558
+ const iface = await readSkillInterface(dir);
2559
+ if (iface !== void 0) {
2560
+ if (iface.displayName !== void 0) detail.displayName = iface.displayName;
2561
+ if (iface.shortDescription !== void 0) detail.shortDescription = iface.shortDescription;
2562
+ if (iface.brandColor !== void 0) detail.brandColor = iface.brandColor;
2563
+ if (iface.iconSmall !== void 0) detail.iconSmall = iface.iconSmall;
2564
+ if (iface.iconLarge !== void 0) detail.iconLarge = iface.iconLarge;
2565
+ if (iface.defaultPrompt !== void 0) detail.defaultPrompt = iface.defaultPrompt;
2566
+ }
2567
+ }
2568
+ } catch {}
2569
+ }
2247
2570
  writeJson(res, 200, {
2248
2571
  ok: true,
2249
2572
  skill: detail
2250
2573
  });
2251
2574
  }
2252
- }),
2253
- route({
2575
+ },
2576
+ {
2254
2577
  path: SKILL_HUB_API.skillDelete,
2255
2578
  methods: ["POST"],
2256
2579
  jsonBody: true,
@@ -2306,8 +2629,8 @@ function makeRoutes(deps) {
2306
2629
  path
2307
2630
  });
2308
2631
  }
2309
- }),
2310
- route({
2632
+ },
2633
+ {
2311
2634
  path: SKILL_HUB_API.toggle,
2312
2635
  methods: ["POST"],
2313
2636
  jsonBody: true,
@@ -2356,8 +2679,8 @@ function makeRoutes(deps) {
2356
2679
  catalog: await buildCatalog(deps, lookup?.cwd)
2357
2680
  });
2358
2681
  }
2359
- }),
2360
- route({
2682
+ },
2683
+ {
2361
2684
  path: SKILL_HUB_API.toggleBatch,
2362
2685
  methods: ["POST"],
2363
2686
  jsonBody: true,
@@ -2408,8 +2731,8 @@ function makeRoutes(deps) {
2408
2731
  failures
2409
2732
  });
2410
2733
  }
2411
- }),
2412
- route({
2734
+ },
2735
+ {
2413
2736
  path: SKILL_HUB_API.create,
2414
2737
  methods: ["POST"],
2415
2738
  jsonBody: true,
@@ -2447,8 +2770,8 @@ function makeRoutes(deps) {
2447
2770
  root
2448
2771
  });
2449
2772
  }
2450
- }),
2451
- route({
2773
+ },
2774
+ {
2452
2775
  path: SKILL_HUB_API.stats,
2453
2776
  methods: ["GET"],
2454
2777
  handler: async ({ res }) => {
@@ -2466,8 +2789,19 @@ function makeRoutes(deps) {
2466
2789
  stats: await deps.stats()
2467
2790
  });
2468
2791
  }
2469
- }),
2470
- route({
2792
+ }
2793
+ ];
2794
+ }
2795
+ //#endregion
2796
+ //#region src/routes/config.ts
2797
+ /**
2798
+ * 配置域路由:config(总开关开着也可用,设置卡自救入口)/ update 自身更新
2799
+ * 检查 / diagnosticFix。从 routes.ts 原样搬出,handler 逻辑不变。
2800
+ */
2801
+ /** 配置域全部路由 spec(由 routes.ts 经 createRoute 包上统一围栏)。 */
2802
+ function configRoutes(deps) {
2803
+ return [
2804
+ {
2471
2805
  path: SKILL_HUB_API.config,
2472
2806
  methods: ["GET", "POST"],
2473
2807
  jsonBody: true,
@@ -2524,6 +2858,15 @@ function makeRoutes(deps) {
2524
2858
  }
2525
2859
  patch[key] = value;
2526
2860
  }
2861
+ if (raw.githubToken !== void 0) {
2862
+ const value = raw.githubToken;
2863
+ if (value === null) patch.githubToken = void 0;
2864
+ else if (typeof value !== "string" || value.trim() === "") patch.githubToken = void 0;
2865
+ else if (!GITHUB_TOKEN_RE.test(value.trim())) {
2866
+ writeError(res, 400, "githubToken looks invalid (expected a personal access token)");
2867
+ return;
2868
+ } else patch.githubToken = value.trim();
2869
+ }
2527
2870
  let config;
2528
2871
  if (deps.updateConfig === void 0) {
2529
2872
  const merged = { ...configOf(deps) };
@@ -2538,8 +2881,96 @@ function makeRoutes(deps) {
2538
2881
  saved: savedOf(deps)
2539
2882
  });
2540
2883
  }
2541
- }),
2542
- route({
2884
+ },
2885
+ {
2886
+ path: SKILL_HUB_API.update,
2887
+ methods: ["GET"],
2888
+ handler: async ({ res }) => {
2889
+ writeJson(res, 200, await checkLatestRelease());
2890
+ }
2891
+ },
2892
+ {
2893
+ path: SKILL_HUB_API.diagnosticFix,
2894
+ methods: ["POST"],
2895
+ jsonBody: true,
2896
+ handler: async ({ res, body }) => {
2897
+ const request = body;
2898
+ const rawPath = typeof request.path === "string" ? request.path.trim() : "";
2899
+ if (rawPath === "") {
2900
+ writeError(res, 400, "path is required");
2901
+ return;
2902
+ }
2903
+ try {
2904
+ await fixDiagnosticFile(rawPath, homeOf(deps));
2905
+ deps.invalidate?.();
2906
+ writeJson(res, 200, {
2907
+ ok: true,
2908
+ path: rawPath
2909
+ });
2910
+ } catch (error) {
2911
+ writeRouteError(res, error);
2912
+ }
2913
+ }
2914
+ }
2915
+ ];
2916
+ }
2917
+ /** Last successful check time per repo (in-memory throttle for GitHub rate limits). */
2918
+ const lastSourceCheck = /* @__PURE__ */ new Map();
2919
+ /** Last successful market update check per repo (same throttle). */
2920
+ const lastMarketCheck = /* @__PURE__ */ new Map();
2921
+ /** Market stats (stars/downloads) throttle: hourly, plus a fallback cache. */
2922
+ const lastMarketStats = /* @__PURE__ */ new Map();
2923
+ const marketStatsCache = /* @__PURE__ */ new Map();
2924
+ /** One-time seed of the in-memory stats cache from the sidecar (survives restarts). */
2925
+ let marketStatsSeeded = false;
2926
+ async function seedMarketStats(deps) {
2927
+ if (marketStatsSeeded) return;
2928
+ marketStatsSeeded = true;
2929
+ try {
2930
+ const saved = await deps.store.getMarketStatsState();
2931
+ if (saved === void 0) return;
2932
+ for (const [repo, stats] of Object.entries(saved.stats)) if (!marketStatsCache.has(repo)) {
2933
+ marketStatsCache.set(repo, stats);
2934
+ lastMarketStats.set(repo, saved.fetchedAt);
2935
+ }
2936
+ } catch {}
2937
+ }
2938
+ const importJobs = /* @__PURE__ */ new Map();
2939
+ function gcImportJobs() {
2940
+ if (importJobs.size > 500) importJobs.clear();
2941
+ const now = Date.now();
2942
+ for (const [id, job] of importJobs) if (job.status !== "running" && now - job.createdAt > 3e5) importJobs.delete(id);
2943
+ if (importJobs.size > 100) {
2944
+ const sorted = [...importJobs.entries()].sort((a, b) => a[1].createdAt - b[1].createdAt);
2945
+ for (let i = 0; i < sorted.length - 100; i++) importJobs.delete(sorted[i][0]);
2946
+ }
2947
+ }
2948
+ /** Replace one skill directory with a fresh download; restores the old dir on failure. */
2949
+ async function replaceSkillDir(targetDir, download) {
2950
+ const backup = join(dirname(targetDir), "." + basename(targetDir) + ".sync-bak-" + Date.now());
2951
+ await rename(targetDir, backup);
2952
+ try {
2953
+ await download();
2954
+ } catch (error) {
2955
+ await rename(backup, targetDir).catch(() => {});
2956
+ throw error;
2957
+ }
2958
+ await rm(backup, {
2959
+ recursive: true,
2960
+ force: true
2961
+ });
2962
+ }
2963
+ //#endregion
2964
+ //#region src/routes/market.ts
2965
+ /**
2966
+ * 市场域路由:market 列表 / source 增删 / ref 锁定 / versions 选择器 /
2967
+ * check 更新检查 / stats 星标统计 / sync 对齐版本。从 routes.ts 原样搬出,
2968
+ * handler 逻辑不变。
2969
+ */
2970
+ /** 市场域全部路由 spec(由 routes.ts 经 createRoute 包上统一围栏)。 */
2971
+ function marketRoutes(deps) {
2972
+ return [
2973
+ {
2543
2974
  path: SKILL_HUB_API.market,
2544
2975
  methods: ["GET"],
2545
2976
  handler: async ({ res }) => {
@@ -2548,8 +2979,8 @@ function makeRoutes(deps) {
2548
2979
  repos: await deps.store.listMarketSources()
2549
2980
  });
2550
2981
  }
2551
- }),
2552
- route({
2982
+ },
2983
+ {
2553
2984
  path: SKILL_HUB_API.marketSource,
2554
2985
  methods: ["POST"],
2555
2986
  jsonBody: true,
@@ -2566,8 +2997,8 @@ function makeRoutes(deps) {
2566
2997
  repos: await deps.store.addMarketSource(repoSlug(parsed), ref)
2567
2998
  });
2568
2999
  }
2569
- }),
2570
- route({
3000
+ },
3001
+ {
2571
3002
  path: SKILL_HUB_API.marketSourceDelete,
2572
3003
  methods: ["POST"],
2573
3004
  jsonBody: true,
@@ -2583,8 +3014,8 @@ function makeRoutes(deps) {
2583
3014
  repos: await deps.store.removeMarketSource(repoSlug(parsed))
2584
3015
  });
2585
3016
  }
2586
- }),
2587
- route({
3017
+ },
3018
+ {
2588
3019
  path: SKILL_HUB_API.marketSourceRef,
2589
3020
  methods: ["POST"],
2590
3021
  jsonBody: true,
@@ -2609,8 +3040,33 @@ function makeRoutes(deps) {
2609
3040
  repos: await deps.store.listMarketSources()
2610
3041
  });
2611
3042
  }
2612
- }),
2613
- route({
3043
+ },
3044
+ {
3045
+ path: SKILL_HUB_API.marketSourceVersions,
3046
+ methods: ["GET"],
3047
+ handler: async ({ res, url }) => {
3048
+ const parsed = normalizeRepoInput(queryParam(url, "repo") ?? "");
3049
+ if (parsed === null) {
3050
+ writeError(res, 400, "repo must be owner/repo or a github.com URL");
3051
+ return;
3052
+ }
3053
+ const repo = repoSlug(parsed);
3054
+ const source = await deps.store.getMarketSource(repo);
3055
+ if (source === void 0) {
3056
+ writeError(res, 404, "market source not found: " + repo);
3057
+ return;
3058
+ }
3059
+ const [releases, branches] = await Promise.all([listRepoReleases(repo), listRepoBranches(repo)]);
3060
+ writeJson(res, 200, {
3061
+ ok: true,
3062
+ repo,
3063
+ ...source.ref !== void 0 && source.ref !== "" ? { current: source.ref } : {},
3064
+ releases,
3065
+ branches
3066
+ });
3067
+ }
3068
+ },
3069
+ {
2614
3070
  path: SKILL_HUB_API.marketCheck,
2615
3071
  methods: ["GET"],
2616
3072
  handler: async ({ res }) => {
@@ -2623,7 +3079,7 @@ function makeRoutes(deps) {
2623
3079
  ...source.ref !== void 0 ? { ref: source.ref } : {}
2624
3080
  };
2625
3081
  const now = Date.now();
2626
- if (now - (lastMarketCheck.get(source.repo) ?? 0) < MIN_CHECK_INTERVAL_MS) {
3082
+ if (now - (lastMarketCheck.get(source.repo) ?? 0) < 3e5) {
2627
3083
  results.push({
2628
3084
  ...base,
2629
3085
  updateAvailable: false,
@@ -2667,8 +3123,65 @@ function makeRoutes(deps) {
2667
3123
  results
2668
3124
  });
2669
3125
  }
2670
- }),
2671
- route({
3126
+ },
3127
+ {
3128
+ path: SKILL_HUB_API.marketStats,
3129
+ methods: ["GET"],
3130
+ handler: async ({ res, url }) => {
3131
+ const refresh = queryParam(url, "refresh") === "1";
3132
+ await seedMarketStats(deps);
3133
+ const sources = await deps.store.listMarketSources();
3134
+ if (lastMarketStats.size > 500) lastMarketStats.clear();
3135
+ const results = [];
3136
+ const now = Date.now();
3137
+ let fetchedAny = false;
3138
+ for (const source of sources) {
3139
+ const cached = marketStatsCache.get(source.repo);
3140
+ const age = now - (lastMarketStats.get(source.repo) ?? 0);
3141
+ if (cached !== void 0 && (!refresh || age < 36e5)) {
3142
+ results.push({
3143
+ repo: source.repo,
3144
+ stars: cached.stars,
3145
+ downloads: cached.downloads,
3146
+ ...age >= 36e5 ? { stale: true } : refresh ? { throttled: true } : {}
3147
+ });
3148
+ continue;
3149
+ }
3150
+ try {
3151
+ const stats = await getRepoStats(source.repo);
3152
+ marketStatsCache.set(source.repo, stats);
3153
+ lastMarketStats.set(source.repo, now);
3154
+ fetchedAny = true;
3155
+ results.push({
3156
+ repo: source.repo,
3157
+ ...stats
3158
+ });
3159
+ } catch (error) {
3160
+ if (cached !== void 0) results.push({
3161
+ repo: source.repo,
3162
+ stars: cached.stars,
3163
+ downloads: cached.downloads,
3164
+ stale: true
3165
+ });
3166
+ else results.push({
3167
+ repo: source.repo,
3168
+ stars: 0,
3169
+ downloads: 0,
3170
+ error: error instanceof Error ? error.message : String(error)
3171
+ });
3172
+ }
3173
+ }
3174
+ if (fetchedAny) deps.store.saveMarketStatsState({
3175
+ fetchedAt: now,
3176
+ stats: Object.fromEntries(marketStatsCache)
3177
+ }).catch(() => {});
3178
+ writeJson(res, 200, {
3179
+ ok: true,
3180
+ results
3181
+ });
3182
+ }
3183
+ },
3184
+ {
2672
3185
  path: SKILL_HUB_API.marketSync,
2673
3186
  methods: ["POST"],
2674
3187
  jsonBody: true,
@@ -2707,8 +3220,19 @@ function makeRoutes(deps) {
2707
3220
  skills: tracked !== void 0 ? [...tracked.skills] : []
2708
3221
  });
2709
3222
  }
2710
- }),
2711
- route({
3223
+ }
3224
+ ];
3225
+ }
3226
+ //#endregion
3227
+ //#region src/routes/repo-import.ts
3228
+ /**
3229
+ * 仓库导入域路由:repo 发现 / repoImport 异步导入任务 / progress 轮询 /
3230
+ * cancel 取消。从 routes.ts 原样搬出,handler 逻辑不变。
3231
+ */
3232
+ /** 仓库导入域全部路由 spec(由 routes.ts 经 createRoute 包上统一围栏)。 */
3233
+ function repoImportRoutes(deps) {
3234
+ return [
3235
+ {
2712
3236
  path: SKILL_HUB_API.repo,
2713
3237
  methods: ["GET"],
2714
3238
  handler: async ({ res, url }) => {
@@ -2725,8 +3249,11 @@ function makeRoutes(deps) {
2725
3249
  }
2726
3250
  if (ref === void 0 || ref === "") {
2727
3251
  const releaseTag = await getLatestReleaseTag(repo);
2728
- if (releaseTag !== void 0) ref = releaseTag;
2729
- else {
3252
+ if (releaseTag !== void 0) {
3253
+ ref = releaseTag;
3254
+ const existing = await deps.store.getMarketSource(repo);
3255
+ if (existing !== void 0 && (existing.ref === void 0 || existing.ref === "")) await deps.store.setMarketSourceRef(repo, releaseTag);
3256
+ } else {
2730
3257
  const branches = await listRepoBranches(repo);
2731
3258
  if (branches.length === 0) {
2732
3259
  writeError(res, 404, "repo has no branches to scan");
@@ -2742,16 +3269,17 @@ function makeRoutes(deps) {
2742
3269
  return;
2743
3270
  }
2744
3271
  }
2745
- const { ref: resolvedRef, tree } = await loadRepoTree(repo, ref);
3272
+ const { ref: resolvedRef, tree, truncated } = await loadRepoTree(repo, ref);
2746
3273
  writeJson(res, 200, {
2747
3274
  ok: true,
2748
3275
  repo,
2749
3276
  ref: resolvedRef,
2750
- entries: discoverRepoEntries(tree, repo, await knownSkillNames(deps))
3277
+ entries: discoverRepoEntries(tree, repo, await knownSkillNames(deps)),
3278
+ ...truncated ? { truncated: true } : {}
2751
3279
  });
2752
3280
  }
2753
- }),
2754
- route({
3281
+ },
3282
+ {
2755
3283
  path: SKILL_HUB_API.repoImport,
2756
3284
  methods: ["POST"],
2757
3285
  jsonBody: true,
@@ -2889,8 +3417,8 @@ function makeRoutes(deps) {
2889
3417
  totalBytes
2890
3418
  });
2891
3419
  }
2892
- }),
2893
- route({
3420
+ },
3421
+ {
2894
3422
  path: SKILL_HUB_API.repoImportProgress,
2895
3423
  methods: ["GET"],
2896
3424
  handler: async ({ res, url }) => {
@@ -2923,8 +3451,8 @@ function makeRoutes(deps) {
2923
3451
  ...job.error !== void 0 ? { error: job.error } : {}
2924
3452
  });
2925
3453
  }
2926
- }),
2927
- route({
3454
+ },
3455
+ {
2928
3456
  path: SKILL_HUB_API.repoImportCancel,
2929
3457
  methods: ["POST"],
2930
3458
  jsonBody: true,
@@ -2950,22 +3478,26 @@ function makeRoutes(deps) {
2950
3478
  status: job.status
2951
3479
  });
2952
3480
  }
2953
- }),
2954
- route({
2955
- path: SKILL_HUB_API.update,
2956
- methods: ["GET"],
2957
- handler: async ({ res }) => {
2958
- writeJson(res, 200, await checkLatestRelease());
2959
- }
2960
- }),
2961
- route({
3481
+ }
3482
+ ];
3483
+ }
3484
+ //#endregion
3485
+ //#region src/routes/groups.ts
3486
+ /**
3487
+ * 分组域路由:groups / tag 新建重命名 / tag 删除 / tag 成员 / tag 重排 /
3488
+ * collection 重排 / source-group 重排。从 routes.ts 原样搬出,handler 逻辑不变。
3489
+ */
3490
+ /** 分组域全部路由 spec(由 routes.ts 经 createRoute 包上统一围栏)。 */
3491
+ function groupRoutes(deps) {
3492
+ return [
3493
+ {
2962
3494
  path: SKILL_HUB_API.groups,
2963
3495
  methods: ["GET"],
2964
3496
  handler: async ({ res }) => {
2965
3497
  writeJson(res, 200, await buildGroups(deps));
2966
3498
  }
2967
- }),
2968
- route({
3499
+ },
3500
+ {
2969
3501
  path: SKILL_HUB_API.tag,
2970
3502
  methods: ["POST"],
2971
3503
  jsonBody: true,
@@ -2985,8 +3517,8 @@ function makeRoutes(deps) {
2985
3517
  tags: await deps.store.listTags()
2986
3518
  });
2987
3519
  }
2988
- }),
2989
- route({
3520
+ },
3521
+ {
2990
3522
  path: SKILL_HUB_API.tagDelete,
2991
3523
  methods: ["POST"],
2992
3524
  jsonBody: true,
@@ -3006,8 +3538,8 @@ function makeRoutes(deps) {
3006
3538
  tags: await deps.store.listTags()
3007
3539
  });
3008
3540
  }
3009
- }),
3010
- route({
3541
+ },
3542
+ {
3011
3543
  path: SKILL_HUB_API.tagMembers,
3012
3544
  methods: ["POST"],
3013
3545
  jsonBody: true,
@@ -3029,8 +3561,8 @@ function makeRoutes(deps) {
3029
3561
  tags: await deps.store.listTags()
3030
3562
  });
3031
3563
  }
3032
- }),
3033
- route({
3564
+ },
3565
+ {
3034
3566
  path: SKILL_HUB_API.tagReorder,
3035
3567
  methods: ["POST"],
3036
3568
  jsonBody: true,
@@ -3050,8 +3582,8 @@ function makeRoutes(deps) {
3050
3582
  throw error;
3051
3583
  }
3052
3584
  }
3053
- }),
3054
- route({
3585
+ },
3586
+ {
3055
3587
  path: SKILL_HUB_API.collectionReorder,
3056
3588
  methods: ["POST"],
3057
3589
  jsonBody: true,
@@ -3065,8 +3597,8 @@ function makeRoutes(deps) {
3065
3597
  order
3066
3598
  });
3067
3599
  }
3068
- }),
3069
- route({
3600
+ },
3601
+ {
3070
3602
  path: SKILL_HUB_API.sourceGroupReorder,
3071
3603
  methods: ["POST"],
3072
3604
  jsonBody: true,
@@ -3078,8 +3610,20 @@ function makeRoutes(deps) {
3078
3610
  order: await deps.store.reorderSourceGroups(orderedKeys)
3079
3611
  });
3080
3612
  }
3081
- }),
3082
- route({
3613
+ }
3614
+ ];
3615
+ }
3616
+ //#endregion
3617
+ //#region src/routes/sources.ts
3618
+ /**
3619
+ * 来源跟踪域路由:sources 列表 / check 上游更新 / sync 同步 / delete 跟进
3620
+ * 删除 / restore 恢复 / trash/clear 清空回收站。从 routes.ts 原样搬出,
3621
+ * handler 逻辑不变。
3622
+ */
3623
+ /** 来源跟踪域全部路由 spec(由 routes.ts 经 createRoute 包上统一围栏)。 */
3624
+ function sourceRoutes(deps) {
3625
+ return [
3626
+ {
3083
3627
  path: SKILL_HUB_API.sources,
3084
3628
  methods: ["GET"],
3085
3629
  handler: async ({ res }) => {
@@ -3112,8 +3656,8 @@ function makeRoutes(deps) {
3112
3656
  trash
3113
3657
  });
3114
3658
  }
3115
- }),
3116
- route({
3659
+ },
3660
+ {
3117
3661
  path: SKILL_HUB_API.sourceCheck,
3118
3662
  methods: ["POST"],
3119
3663
  jsonBody: true,
@@ -3138,7 +3682,7 @@ function makeRoutes(deps) {
3138
3682
  ...item.ref !== void 0 ? { ref: item.ref } : {}
3139
3683
  };
3140
3684
  const now = Date.now();
3141
- if (now - (lastSourceCheck.get(item.repo) ?? 0) < MIN_CHECK_INTERVAL_MS) {
3685
+ if (now - (lastSourceCheck.get(item.repo) ?? 0) < 3e5) {
3142
3686
  results.push({
3143
3687
  ...base,
3144
3688
  changed: false,
@@ -3195,8 +3739,8 @@ function makeRoutes(deps) {
3195
3739
  results
3196
3740
  });
3197
3741
  }
3198
- }),
3199
- route({
3742
+ },
3743
+ {
3200
3744
  path: SKILL_HUB_API.sourceSync,
3201
3745
  methods: ["POST"],
3202
3746
  jsonBody: true,
@@ -3267,8 +3811,8 @@ function makeRoutes(deps) {
3267
3811
  failed
3268
3812
  });
3269
3813
  }
3270
- }),
3271
- route({
3814
+ },
3815
+ {
3272
3816
  path: SKILL_HUB_API.sourceDelete,
3273
3817
  methods: ["POST"],
3274
3818
  jsonBody: true,
@@ -3352,8 +3896,8 @@ function makeRoutes(deps) {
3352
3896
  failed
3353
3897
  });
3354
3898
  }
3355
- }),
3356
- route({
3899
+ },
3900
+ {
3357
3901
  path: SKILL_HUB_API.sourceRestore,
3358
3902
  methods: ["POST"],
3359
3903
  jsonBody: true,
@@ -3385,8 +3929,8 @@ function makeRoutes(deps) {
3385
3929
  path
3386
3930
  });
3387
3931
  }
3388
- }),
3389
- route({
3932
+ },
3933
+ {
3390
3934
  path: SKILL_HUB_API.sourceTrashClear,
3391
3935
  methods: ["POST"],
3392
3936
  handler: async ({ res }) => {
@@ -3412,10 +3956,27 @@ function makeRoutes(deps) {
3412
3956
  failed
3413
3957
  });
3414
3958
  }
3415
- })
3959
+ }
3416
3960
  ];
3417
3961
  }
3418
3962
  //#endregion
3963
+ //#region src/routes.ts
3964
+ /**
3965
+ * Build every /api/skill-hub route.
3966
+ * @param deps - skill registry view + sidecar store.
3967
+ * @returns the exact-path routes.
3968
+ */
3969
+ function makeRoutes(deps) {
3970
+ return [
3971
+ ...catalogRoutes(deps),
3972
+ ...configRoutes(deps),
3973
+ ...marketRoutes(deps),
3974
+ ...repoImportRoutes(deps),
3975
+ ...groupRoutes(deps),
3976
+ ...sourceRoutes(deps)
3977
+ ].map((spec) => createRoute(deps, spec));
3978
+ }
3979
+ //#endregion
3419
3980
  //#region src/stats.ts
3420
3981
  /** Fallback freeze horizon when no rolling window is configured (14 days). */
3421
3982
  const STATS_FREEZE_AFTER_MS = 336 * 60 * 60 * 1e3;
@@ -3454,6 +4015,22 @@ function isFrozen(record, watermark) {
3454
4015
  const created = record.header.createdAt;
3455
4016
  return typeof created === "number" && created > 0 && created < watermark;
3456
4017
  }
4018
+ /** Bounded-concurrency map: session-log reads are independent decompressions, so wall time scales ~1/limit. Merge stays sequential in index order. */
4019
+ async function mapConcurrent(items, limit, worker) {
4020
+ const results = new Array(items.length);
4021
+ let cursor = 0;
4022
+ const runners = Array.from({ length: Math.min(Math.max(limit, 1), items.length) }, async () => {
4023
+ while (cursor < items.length) {
4024
+ const index = cursor;
4025
+ cursor += 1;
4026
+ results[index] = await worker(items[index], index);
4027
+ }
4028
+ });
4029
+ await Promise.all(runners);
4030
+ return results;
4031
+ }
4032
+ /** Max parallel session-log reads per scan (read-only; merge is order-independent). */
4033
+ const SCAN_READ_CONCURRENCY = 6;
3457
4034
  function mergeInto(totals, counted) {
3458
4035
  for (const [name, stat] of counted) {
3459
4036
  const total = totals[name];
@@ -3499,20 +4076,23 @@ async function scan(query, checkpoint, nowMs, windowDays) {
3499
4076
  if (nowMs - checkpoint.lastFullReconcile >= 864e5 || checkpoint.windowDays !== windowDays) {
3500
4077
  const cache = {};
3501
4078
  const totals = {};
3502
- for (const record of sessions) {
3503
- let counted;
4079
+ const countedList = await mapConcurrent(sessions, SCAN_READ_CONCURRENCY, async (record) => {
3504
4080
  try {
3505
- counted = countSkillInvocations((await query.readSession(record.header.id)).events);
4081
+ return countSkillInvocations((await query.readSession(record.header.id)).events);
3506
4082
  } catch {
3507
- continue;
4083
+ return;
3508
4084
  }
4085
+ });
4086
+ sessions.forEach((record, index) => {
4087
+ const counted = countedList[index];
4088
+ if (counted === void 0) return;
3509
4089
  const created = record.header.createdAt;
3510
4090
  if (isFrozen(record, cutoff) && counted.size > 0 && typeof created === "number") cache[record.header.id] = {
3511
4091
  createdAt: created,
3512
4092
  counts: Object.fromEntries(counted)
3513
4093
  };
3514
4094
  if (inWindow(created, windowDays, nowMs)) mergeInto(totals, counted);
3515
- }
4095
+ });
3516
4096
  checkpoint.frozenSessions = cache;
3517
4097
  checkpoint.frozenBefore = cutoff;
3518
4098
  checkpoint.windowDays = windowDays;
@@ -3523,13 +4103,16 @@ async function scan(query, checkpoint, nowMs, windowDays) {
3523
4103
  };
3524
4104
  }
3525
4105
  const recent = {};
3526
- for (const record of sessions) {
3527
- if (isFrozen(record, checkpoint.frozenBefore)) continue;
4106
+ const recentList = await mapConcurrent(sessions.filter((record) => !isFrozen(record, checkpoint.frozenBefore)), SCAN_READ_CONCURRENCY, async (record) => {
3528
4107
  try {
3529
- mergeInto(recent, countSkillInvocations((await query.readSession(record.header.id)).events));
4108
+ return countSkillInvocations((await query.readSession(record.header.id)).events);
3530
4109
  } catch {
3531
- continue;
4110
+ return;
3532
4111
  }
4112
+ });
4113
+ for (const counted of recentList) {
4114
+ if (counted === void 0) continue;
4115
+ mergeInto(recent, counted);
3533
4116
  }
3534
4117
  const totals = {};
3535
4118
  for (const [id, entry] of Object.entries(checkpoint.frozenSessions)) {
@@ -3567,7 +4150,7 @@ function createSkillStatsReader(query, ttlMs = 3e5, options = {}) {
3567
4150
  lastFullReconcile: 0
3568
4151
  };
3569
4152
  const now = options.now ?? (() => Date.now());
3570
- let cached;
4153
+ let cached = checkpoint.lastTotals !== void 0 ? [...checkpoint.lastTotals] : void 0;
3571
4154
  let cachedAt = 0;
3572
4155
  let refreshing = null;
3573
4156
  let lastScanDurationMs = 0;
@@ -3578,11 +4161,12 @@ function createSkillStatsReader(query, ttlMs = 3e5, options = {}) {
3578
4161
  if (cached !== void 0 && startedAt - cachedAt < ttl) return cached;
3579
4162
  if (refreshing === null) {
3580
4163
  const windowDays = options.windowDays?.() ?? 0;
3581
- refreshing = scan(query, checkpoint, startedAt, windowDays).then(({ stats, mutated }) => {
4164
+ refreshing = scan(query, checkpoint, startedAt, windowDays).then(({ stats }) => {
3582
4165
  cached = stats;
3583
4166
  cachedAt = now();
3584
4167
  lastScanDurationMs = Math.max(0, cachedAt - startedAt);
3585
- if (mutated) options.onCheckpoint?.({
4168
+ checkpoint.lastTotals = stats;
4169
+ options.onCheckpoint?.({
3586
4170
  ...checkpoint,
3587
4171
  frozenSessions: { ...checkpoint.frozenSessions }
3588
4172
  });
@@ -3630,6 +4214,7 @@ const HubSettingsSchema = z.object({
3630
4214
  showGroupSummary: z.boolean().default(HUB_CONFIG_DEFAULTS.showGroupSummary),
3631
4215
  dotModelColor: z.string().pattern(HEX_COLOR_RE),
3632
4216
  dotUserColor: z.string().pattern(HEX_COLOR_RE),
4217
+ githubToken: z.string(),
3633
4218
  statsWindowDays: z.number().min(0).max(3650).default(HUB_CONFIG_DEFAULTS.statsWindowDays),
3634
4219
  statsScanMinutes: z.number().min(1).max(1440).default(HUB_CONFIG_DEFAULTS.statsScanMinutes)
3635
4220
  });
@@ -3672,6 +4257,7 @@ function apply(ctx, config) {
3672
4257
  disposeProvider = void 0;
3673
4258
  }
3674
4259
  const value = current();
4260
+ setGithubToken(value.githubToken);
3675
4261
  if (value.announceToAgent) disposeSection = ctx.systemPrompt.section({
3676
4262
  name: "plugin:dsh-skill-hub",
3677
4263
  order: SECTION_ORDER,
@@ -3738,7 +4324,7 @@ function apply(ctx, config) {
3738
4324
  const value = current().statsWindowDays;
3739
4325
  return typeof value === "number" && value >= 0 ? Math.floor(value) : HUB_CONFIG_DEFAULTS.statsWindowDays;
3740
4326
  };
3741
- stats = createSkillStatsReader(sctx.sessionQuery, () => scanMinutes() * 6e4, {
4327
+ const reader = createSkillStatsReader(sctx.sessionQuery, () => scanMinutes() * 6e4, {
3742
4328
  checkpoint: saved,
3743
4329
  windowDays,
3744
4330
  onCheckpoint: (next) => {
@@ -3747,7 +4333,9 @@ function apply(ctx, config) {
3747
4333
  });
3748
4334
  }
3749
4335
  });
4336
+ stats = reader;
3750
4337
  sync();
4338
+ reader();
3751
4339
  })();
3752
4340
  });
3753
4341
  }