dsh-skill-hub 0.3.7 → 0.3.9

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 (109) hide show
  1. package/README.md +40 -50
  2. package/README.zh.md +38 -48
  3. package/lib/client.js +2209 -1820
  4. package/lib/client.js.map +1 -1
  5. package/lib/index.js +653 -419
  6. package/lib/types/client/SkillHubSettingsCard.d.ts +1 -0
  7. package/lib/types/client/api.d.ts +12 -6
  8. package/lib/types/client/helpers.d.ts +0 -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 +124 -118
  16. package/lib/types/client/panel/MarketSourceRow.d.ts +12 -0
  17. package/lib/types/client/panel/MarketView.d.ts +1 -0
  18. package/lib/types/client/panel/RepoScanCard.d.ts +10 -0
  19. package/lib/types/client/panel/hooks/shared.d.ts +72 -0
  20. package/lib/types/client/panel/hooks/useCatalogFlow.d.ts +62 -0
  21. package/lib/types/client/panel/hooks/useGroupFlow.d.ts +41 -0
  22. package/lib/types/client/panel/hooks/useMarketFlow.d.ts +73 -0
  23. package/lib/types/client/panel/hooks/useMetaFlow.d.ts +18 -0
  24. package/lib/types/client/panel/hooks/useSourceFlow.d.ts +44 -0
  25. package/lib/types/client/panel/market-format.d.ts +6 -0
  26. package/lib/types/client/panel/useSkillHub.d.ts +45 -73
  27. package/lib/types/client/settings-card.d.ts +20 -0
  28. package/lib/types/client/settings-form.d.ts +6 -0
  29. package/lib/types/protocol/api.d.ts +38 -0
  30. package/lib/types/protocol/catalog.d.ts +155 -0
  31. package/lib/types/protocol/config.d.ts +113 -0
  32. package/lib/types/protocol/groups.d.ts +95 -0
  33. package/lib/types/protocol/market.d.ts +94 -0
  34. package/lib/types/protocol/repo.d.ts +107 -0
  35. package/lib/types/protocol/sources.d.ts +139 -0
  36. package/lib/types/protocol/stats.d.ts +46 -0
  37. package/lib/types/protocol.d.ts +13 -743
  38. package/lib/types/repo/github-client.d.ts +42 -0
  39. package/lib/types/repo.d.ts +6 -17
  40. package/lib/types/routes/catalog.d.ts +7 -0
  41. package/lib/types/routes/config.d.ts +7 -0
  42. package/lib/types/routes/groups.d.ts +7 -0
  43. package/lib/types/routes/helpers.d.ts +145 -0
  44. package/lib/types/routes/market.d.ts +8 -0
  45. package/lib/types/routes/repo-import.d.ts +7 -0
  46. package/lib/types/routes/route-state.d.ts +55 -0
  47. package/lib/types/routes/sources.d.ts +8 -0
  48. package/lib/types/routes.d.ts +5 -34
  49. package/lib/types/stats.d.ts +3 -2
  50. package/lib/types/store/errors.d.ts +10 -0
  51. package/lib/types/store/migrate.d.ts +44 -0
  52. package/lib/types/store/paths.d.ts +32 -0
  53. package/lib/types/store/store.d.ts +133 -0
  54. package/lib/types/store.d.ts +7 -146
  55. package/package.json +30 -28
  56. package/src/client/SkillHubSettingsCard.tsx +17 -3
  57. package/src/client/api.ts +101 -195
  58. package/src/client/helpers.ts +10 -1
  59. package/src/client/locales/common.ts +102 -0
  60. package/src/client/locales/detail.ts +30 -0
  61. package/src/client/locales/market.ts +144 -0
  62. package/src/client/locales/settings.ts +80 -0
  63. package/src/client/locales/skills.ts +128 -0
  64. package/src/client/locales/sources.ts +68 -0
  65. package/src/client/locales.ts +26 -512
  66. package/src/client/panel/MarketSourceRow.tsx +108 -0
  67. package/src/client/panel/MarketView.tsx +12 -253
  68. package/src/client/panel/RepoScanCard.tsx +171 -0
  69. package/src/client/panel/hooks/shared.ts +81 -0
  70. package/src/client/panel/hooks/useCatalogFlow.ts +211 -0
  71. package/src/client/panel/hooks/useGroupFlow.ts +240 -0
  72. package/src/client/panel/hooks/useMarketFlow.ts +421 -0
  73. package/src/client/panel/hooks/useMetaFlow.ts +58 -0
  74. package/src/client/panel/hooks/useSourceFlow.ts +193 -0
  75. package/src/client/panel/market-format.ts +22 -0
  76. package/src/client/panel/useSkillHub.ts +141 -1032
  77. package/src/client/settings-card.tsx +51 -0
  78. package/src/client/settings-form.ts +18 -0
  79. package/src/index.ts +14 -4
  80. package/src/protocol/api.ts +38 -0
  81. package/src/protocol/catalog.ts +168 -0
  82. package/src/protocol/config.ts +145 -0
  83. package/src/protocol/groups.ts +106 -0
  84. package/src/protocol/market.ts +100 -0
  85. package/src/protocol/repo.ts +104 -0
  86. package/src/protocol/sources.ts +146 -0
  87. package/src/protocol/stats.ts +42 -0
  88. package/src/protocol.ts +83 -809
  89. package/src/repo/github-client.ts +131 -0
  90. package/src/repo.test.ts +24 -1
  91. package/src/repo.ts +50 -170
  92. package/src/routes/catalog.ts +309 -0
  93. package/src/routes/config.ts +119 -0
  94. package/src/routes/groups.ts +133 -0
  95. package/src/routes/helpers.ts +489 -0
  96. package/src/routes/market.ts +255 -0
  97. package/src/routes/repo-import.ts +267 -0
  98. package/src/routes/route-state.ts +89 -0
  99. package/src/routes/sources.ts +318 -0
  100. package/src/routes.test.ts +20 -0
  101. package/src/routes.ts +21 -1699
  102. package/src/stats.test.ts +58 -3
  103. package/src/stats.ts +45 -16
  104. package/src/store/errors.ts +14 -0
  105. package/src/store/migrate.ts +272 -0
  106. package/src/store/paths.ts +43 -0
  107. package/src/store/store.ts +532 -0
  108. package/src/store.test.ts +18 -0
  109. 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",
@@ -48,8 +42,11 @@ const SKILL_HUB_API = {
48
42
  sourceRestore: "/api/skill-hub/sources/restore",
49
43
  sourceTrashClear: "/api/skill-hub/sources/trash/clear",
50
44
  diagnosticFix: "/api/skill-hub/diagnostic/fix",
51
- marketSourceVersions: "/api/skill-hub/market/source/versions"
45
+ marketSourceVersions: "/api/skill-hub/market/source/versions",
46
+ marketStats: "/api/skill-hub/market/stats"
52
47
  };
48
+ //#endregion
49
+ //#region src/protocol/config.ts
53
50
  /**
54
51
  * Hub config defaults — the single source every layer reads: the cordis
55
52
  * schema (index.ts), the host's saved-override merge, and the routes'
@@ -74,6 +71,7 @@ const HUB_CONFIG_DEFAULTS = {
74
71
  function resolveHubConfig(saved, base = {}) {
75
72
  const dotModelColor = saved.dotModelColor !== void 0 ? saved.dotModelColor : base.dotModelColor;
76
73
  const dotUserColor = saved.dotUserColor !== void 0 ? saved.dotUserColor : base.dotUserColor;
74
+ const githubToken = saved.githubToken !== void 0 ? saved.githubToken : base.githubToken;
77
75
  const windowDays = clampNumber(saved.statsWindowDays ?? base.statsWindowDays, 0) ?? HUB_CONFIG_DEFAULTS.statsWindowDays;
78
76
  const scanMinutes = clampNumber(saved.statsScanMinutes ?? base.statsScanMinutes, 1) ?? HUB_CONFIG_DEFAULTS.statsScanMinutes;
79
77
  return {
@@ -85,7 +83,8 @@ function resolveHubConfig(saved, base = {}) {
85
83
  statsWindowDays: windowDays,
86
84
  statsScanMinutes: scanMinutes,
87
85
  ...dotModelColor !== void 0 ? { dotModelColor } : {},
88
- ...dotUserColor !== void 0 ? { dotUserColor } : {}
86
+ ...dotUserColor !== void 0 ? { dotUserColor } : {},
87
+ ...githubToken !== void 0 ? { githubToken } : {}
89
88
  };
90
89
  }
91
90
  /** Clamp a numeric override into a valid value; undefined/invalid stays undefined. */
@@ -95,25 +94,16 @@ function clampNumber(value, min) {
95
94
  }
96
95
  /** HEX color validation shared by host routes and the settings card. */
97
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
98
101
  /** 项目级技能来源(它们有 workspace 归属,不属于「个人」组)。 */
99
102
  function isProjectSource(source) {
100
103
  return source === "project-dsh" || source === "project-agents";
101
104
  }
102
105
  //#endregion
103
- //#region src/store.ts
104
- /**
105
- * Hub sidecar store: remembers which skills the hub toggled off and the
106
- * user's organization/tracking records. Disabling renames the skill's
107
- * SKILL.md (or flat .md) out of the filesystem provider's discovery shapes,
108
- * so the provider catalog alone cannot see disabled skills; this store keeps
109
- * name/path/root so the GUI can list them and re-enable. It also persists
110
- * user tag groups, upstream source records (repo + commit snapshot for
111
- * update checks), the market source list, and the trash (skills removed
112
- * after upstream deletion).
113
- *
114
- * State file: $DSH_HOME/dsh-skill-hub.json — a small JSON document written
115
- * atomically (tmp file + rename).
116
- */
106
+ //#region src/store/paths.ts
117
107
  /** 默认场景名(系统预置的兜底场景,新技能自动归入)。 */
118
108
  const DEFAULT_SCENE_NAME = "通用";
119
109
  /** Resolve the DSH home directory (the filesystem provider's user-dsh root base). */
@@ -124,6 +114,8 @@ function dshHome() {
124
114
  function statePath(home = dshHome()) {
125
115
  return join(home, "dsh-skill-hub.json");
126
116
  }
117
+ //#endregion
118
+ //#region src/store/errors.ts
127
119
  /**
128
120
  * Business-rule failure the routes layer can map onto a 4xx status instead
129
121
  * of a blanket 500: user input is invalid (validation → 400), the target
@@ -138,6 +130,8 @@ var StoreError = class extends Error {
138
130
  this.kind = kind;
139
131
  }
140
132
  };
133
+ //#endregion
134
+ //#region src/store/migrate.ts
141
135
  /**
142
136
  * Normalize an arbitrary parsed sidecar document to the current schema.
143
137
  * v1 to v2: scenes are dropped (the one-click role merged into group
@@ -184,10 +178,152 @@ function migrateStore(parsed) {
184
178
  ...marketSources !== void 0 ? { marketSources } : {},
185
179
  ...trash !== void 0 ? { trash } : {},
186
180
  ...record.skillStats !== void 0 ? { skillStats: record.skillStats } : {},
181
+ ...record.marketStats !== void 0 ? { marketStats: record.marketStats } : {},
187
182
  ...Array.isArray(record.collectionOrder) ? { collectionOrder: record.collectionOrder } : {},
188
183
  ...Array.isArray(record.sourceGroupOrder) ? { sourceGroupOrder: record.sourceGroupOrder } : {}
189
184
  };
190
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
191
327
  /** Sidecar state owner. */
192
328
  var SkillHubStore = class {
193
329
  file;
@@ -198,6 +334,7 @@ var SkillHubStore = class {
198
334
  marketSources = [];
199
335
  trashByName = /* @__PURE__ */ new Map();
200
336
  skillStats = void 0;
337
+ marketStats = void 0;
201
338
  collectionOrder = [];
202
339
  sourceGroupOrder = [];
203
340
  loaded = false;
@@ -215,97 +352,17 @@ var SkillHubStore = class {
215
352
  const migrated = migrateStore(JSON.parse(raw));
216
353
  if (migrated === null) console.warn("[dsh-skill-hub] sidecar state uses a newer schema than this plugin supports; starting empty");
217
354
  else {
218
- if (Array.isArray(migrated.disabled)) {
219
- for (const entry of migrated.disabled) if (typeof entry?.name === "string" && typeof entry?.path === "string") this.entries.set(entry.name, entry);
220
- }
221
- const saved = migrated.config;
222
- if (typeof saved === "object" && saved !== null) {
223
- if (typeof saved.enabled === "boolean") this.config.enabled = saved.enabled;
224
- if (typeof saved.announceToAgent === "boolean") this.config.announceToAgent = saved.announceToAgent;
225
- if (typeof saved.dotModelColor === "string" && saved.dotModelColor !== "") this.config.dotModelColor = saved.dotModelColor;
226
- if (typeof saved.dotUserColor === "string" && saved.dotUserColor !== "") this.config.dotUserColor = saved.dotUserColor;
227
- if (typeof saved.showUseCount === "boolean") this.config.showUseCount = saved.showUseCount;
228
- if (typeof saved.showUseTime === "boolean") this.config.showUseTime = saved.showUseTime;
229
- if (typeof saved.showGroupSummary === "boolean") this.config.showGroupSummary = saved.showGroupSummary;
230
- }
231
- if (Array.isArray(migrated.tags)) for (const entry of migrated.tags) {
232
- const tag = entry;
233
- if (tag !== null && typeof tag === "object" && typeof tag.id === "string" && typeof tag.name === "string" && Array.isArray(tag.skillNames)) this.tagsById.set(tag.id, {
234
- id: tag.id,
235
- name: tag.name,
236
- skillNames: tag.skillNames.filter((n) => typeof n === "string"),
237
- ...tag.default === true ? { default: true } : {}
238
- });
239
- }
240
- if (Array.isArray(migrated.sources)) for (const entry of migrated.sources) {
241
- const source = entry;
242
- if (source !== null && typeof source === "object" && typeof source.repo === "string" && source.repo !== "" && Array.isArray(source.skills)) {
243
- const manifest = source.manifest;
244
- const rawRoot = typeof source.root === "string" && source.root !== "" && /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(source.root) ? source.root : "skills";
245
- this.sourcesByRepo.set(source.repo, {
246
- repo: source.repo,
247
- ...typeof source.ref === "string" && source.ref !== "" ? { ref: source.ref } : {},
248
- root: rawRoot,
249
- commitSha: typeof source.commitSha === "string" ? source.commitSha : "",
250
- skills: source.skills.filter((n) => typeof n === "string"),
251
- ...manifest !== null && typeof manifest === "object" && !Array.isArray(manifest) ? { manifest: Object.fromEntries(Object.entries(manifest).filter(([, size]) => typeof size === "number")) } : {}
252
- });
253
- }
254
- }
255
- if (Array.isArray(migrated.marketSources)) for (const entry of migrated.marketSources) {
256
- const item = entry;
257
- if (item !== null && typeof item === "object" && typeof item.repo === "string" && item.repo !== "" && !this.marketSources.some((s) => s.repo === item.repo)) this.marketSources.push({
258
- repo: item.repo,
259
- ...typeof item.ref === "string" && item.ref !== "" ? { ref: item.ref } : {},
260
- ...typeof item.commitSha === "string" && item.commitSha !== "" ? { commitSha: item.commitSha } : {}
261
- });
262
- }
263
- if (Array.isArray(migrated.trash)) for (const entry of migrated.trash) {
264
- const item = entry;
265
- if (item !== null && typeof item === "object" && typeof item.name === "string" && typeof item.path === "string") {
266
- const origin = item.origin;
267
- const tagIds = item.tagIds;
268
- this.trashByName.set(item.name, {
269
- name: item.name,
270
- path: item.path,
271
- movedAt: typeof item.movedAt === "number" ? item.movedAt : 0,
272
- ...typeof item.sourcePath === "string" && item.sourcePath !== "" ? { sourcePath: item.sourcePath } : {},
273
- ...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: {
274
- repo: origin.repo,
275
- root: origin.root,
276
- ...typeof origin.ref === "string" && origin.ref !== "" ? { ref: origin.ref } : {},
277
- commitSha: typeof origin.commitSha === "string" ? origin.commitSha : ""
278
- } } : {},
279
- ...Array.isArray(tagIds) ? { tagIds: tagIds.filter((id) => typeof id === "string") } : {}
280
- });
281
- }
282
- }
283
- const savedStats = migrated.skillStats;
284
- 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) {
285
- const sessions = {};
286
- for (const [id, entry] of Object.entries(savedStats.frozenSessions)) {
287
- if (entry === null || typeof entry !== "object" || typeof entry.createdAt !== "number" || typeof entry.counts !== "object" || entry.counts === null) continue;
288
- const counts = {};
289
- 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] = {
290
- count: stat.count,
291
- lastUsed: stat.lastUsed
292
- };
293
- sessions[id] = {
294
- createdAt: entry.createdAt,
295
- counts
296
- };
297
- }
298
- this.skillStats = {
299
- windowDays: savedStats.windowDays,
300
- frozenBefore: savedStats.frozenBefore,
301
- frozenSessions: sessions,
302
- lastFullReconcile: savedStats.lastFullReconcile
303
- };
304
- }
305
- const rawColOrder = migrated.collectionOrder;
306
- if (Array.isArray(rawColOrder)) this.collectionOrder = rawColOrder.filter((n) => typeof n === "string" && n !== "");
307
- const rawSrcOrder = migrated.sourceGroupOrder;
308
- 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;
309
366
  }
310
367
  } catch (error) {
311
368
  if (error.code !== "ENOENT") console.warn("[dsh-skill-hub] sidecar state unreadable, starting empty:", error instanceof Error ? error.message : error);
@@ -699,17 +756,36 @@ var SkillHubStore = class {
699
756
  await this.ensureLoaded();
700
757
  return this.skillStats !== void 0 ? {
701
758
  ...this.skillStats,
702
- frozenSessions: { ...this.skillStats.frozenSessions }
759
+ frozenSessions: { ...this.skillStats.frozenSessions },
760
+ ...this.skillStats.lastTotals !== void 0 ? { lastTotals: [...this.skillStats.lastTotals] } : {}
703
761
  } : void 0;
704
762
  }
705
- /** 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). */
706
764
  async saveSkillStatsState(state) {
707
765
  await this.ensureLoaded();
708
766
  this.skillStats = {
709
767
  windowDays: state.windowDays,
710
768
  frozenBefore: state.frozenBefore,
711
769
  frozenSessions: { ...state.frozenSessions },
712
- 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 }
713
789
  };
714
790
  await this.persist();
715
791
  }
@@ -724,6 +800,7 @@ var SkillHubStore = class {
724
800
  ...this.marketSources.length > 0 ? { marketSources: [...this.marketSources] } : {},
725
801
  ...this.trashByName.size > 0 ? { trash: [...this.trashByName.values()] } : {},
726
802
  ...this.skillStats !== void 0 ? { skillStats: this.skillStats } : {},
803
+ ...this.marketStats !== void 0 ? { marketStats: this.marketStats } : {},
727
804
  ...this.collectionOrder.length > 0 ? { collectionOrder: [...this.collectionOrder] } : {},
728
805
  ...this.sourceGroupOrder.length > 0 ? { sourceGroupOrder: [...this.sourceGroupOrder] } : {}
729
806
  };
@@ -1349,17 +1426,12 @@ var SkillHubProvider = class {
1349
1426
  }
1350
1427
  };
1351
1428
  //#endregion
1352
- //#region src/repo.ts
1429
+ //#region src/repo/github-client.ts
1353
1430
  /**
1354
- * GitHub repository skill discovery/import helpers.
1355
- *
1356
- * Kept dependency-free and mostly pure so the root/origin rules are easy to
1357
- * test. Roots are auto-derived: any top-level directory that contains a
1358
- * `**\/SKILL.md` (e.g. `skills/**`, `design-templates/**`, `templates/**`,
1359
- * `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 两个入口。
1360
1434
  */
1361
- /** Top-level directory pattern for a skill root: visible, non-dot, safe chars. First char must be alphanum. */
1362
- const ROOT_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
1363
1435
  /** Fetch failure carrying a useful HTTP status for the route layer. */
1364
1436
  var RepoFetchError = class extends Error {
1365
1437
  status;
@@ -1372,13 +1444,25 @@ var RepoFetchError = class extends Error {
1372
1444
  /**
1373
1445
  * GitHub token for authenticated API calls. Read from GITHUB_TOKEN /
1374
1446
  * GH_TOKEN at module load; setGithubToken() overrides it at runtime (the
1375
- * 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).
1376
1449
  */
1377
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
+ }
1378
1455
  /** Authorization header for api.github.com / raw.githubusercontent.com calls. */
1379
1456
  function githubAuthHeaders() {
1380
1457
  return githubToken === "" ? {} : { authorization: "Bearer " + githubToken };
1381
1458
  }
1459
+ /** JSON API 默认请求头(含鉴权)。 */
1460
+ function apiHeaders() {
1461
+ return {
1462
+ accept: "application/vnd.github+json",
1463
+ ...githubAuthHeaders()
1464
+ };
1465
+ }
1382
1466
  /** ETag cache for GitHub API JSON (mirrors codex lib.rs marker+fingerprint). In-memory only, saves 304 for daily checks. */
1383
1467
  const etagCache = /* @__PURE__ */ new Map();
1384
1468
  const ETAG_MAX = 200;
@@ -1407,41 +1491,31 @@ function fetchError(context, response, fallbackStatus = 502) {
1407
1491
  }
1408
1492
  return new RepoFetchError(context + " (HTTP " + response.status + ")", response.status === 404 ? 404 : fallbackStatus);
1409
1493
  }
1410
- /** `owner/repo` slug for a parsed reference. */
1411
- function repoSlug(ref) {
1412
- return `${ref.owner}/${ref.repo}`;
1494
+ /** 是否限流/中断类错误(调用方可直接透出,不再包装)。 */
1495
+ function isAbortError(error) {
1496
+ return error instanceof Error && (error.name === "AbortError" || error.message.includes("aborted") || error.message.includes("Abort"));
1413
1497
  }
1414
1498
  /**
1415
- * Normalize a GitHub URL or `owner/repo`/`owner/repo@ref` input.
1416
- * Only github.com URLs are accepted in v1.
1499
+ * 不带缓存的统一 GET+JSON:网络异常、非 2xx、坏 JSON 全部映射为
1500
+ * RepoFetchError,调用方无需重复写 try/catch。
1417
1501
  */
1418
- function normalizeRepoInput(input) {
1419
- let value = input.trim();
1420
- if (value === "") return null;
1421
- let ref;
1422
- const urlMatch = /^https?:\/\/github\.com\/([^/]+)\/([^/#?@]+)(?:\/tree\/([^/?#]+))?/.exec(value);
1423
- if (urlMatch !== null) {
1424
- value = `${urlMatch[1]}/${urlMatch[2]}`;
1425
- ref = urlMatch[3];
1426
- } else {
1427
- if (/^https?:\/\//.test(value)) return null;
1428
- const at = value.indexOf("@");
1429
- if (at !== -1) {
1430
- ref = value.slice(at + 1).trim();
1431
- value = value.slice(0, at).trim();
1432
- }
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);
1433
1518
  }
1434
- value = value.replace(/\.git$/, "").replace(/\/+$/, "");
1435
- const parts = value.split("/").filter(Boolean);
1436
- if (parts.length < 2) return null;
1437
- const owner = parts[0];
1438
- const repo = parts[1];
1439
- if (owner === "" || repo === "" || owner.includes("..") || repo.includes("..")) return null;
1440
- return {
1441
- owner,
1442
- repo,
1443
- ...ref !== void 0 && ref !== "" ? { ref } : {}
1444
- };
1445
1519
  }
1446
1520
  /**
1447
1521
  * Load repo metadata and the recursive git tree at the given ref. The meta
@@ -1451,10 +1525,7 @@ function normalizeRepoInput(input) {
1451
1525
  */
1452
1526
  async function fetchJsonCached(url, fetchImpl, context) {
1453
1527
  const cached = etagCacheGet(url);
1454
- const headers = {
1455
- accept: "application/vnd.github+json",
1456
- ...githubAuthHeaders()
1457
- };
1528
+ const headers = { ...apiHeaders() };
1458
1529
  if (cached !== void 0) headers["if-none-match"] = cached.etag;
1459
1530
  let response;
1460
1531
  try {
@@ -1480,6 +1551,54 @@ async function fetchJsonCached(url, fetchImpl, context) {
1480
1551
  response
1481
1552
  };
1482
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._-]*$/;
1566
+ /** `owner/repo` slug for a parsed reference. */
1567
+ function repoSlug(ref) {
1568
+ return `${ref.owner}/${ref.repo}`;
1569
+ }
1570
+ /**
1571
+ * Normalize a GitHub URL or `owner/repo`/`owner/repo@ref` input.
1572
+ * Only github.com URLs are accepted in v1.
1573
+ */
1574
+ function normalizeRepoInput(input) {
1575
+ let value = input.trim();
1576
+ if (value === "") return null;
1577
+ let ref;
1578
+ const urlMatch = /^https?:\/\/github\.com\/([^/]+)\/([^/#?@]+)(?:\/tree\/([^/?#]+))?/.exec(value);
1579
+ if (urlMatch !== null) {
1580
+ value = `${urlMatch[1]}/${urlMatch[2]}`;
1581
+ ref = urlMatch[3];
1582
+ } else {
1583
+ if (/^https?:\/\//.test(value)) return null;
1584
+ const at = value.indexOf("@");
1585
+ if (at !== -1) {
1586
+ ref = value.slice(at + 1).trim();
1587
+ value = value.slice(0, at).trim();
1588
+ }
1589
+ }
1590
+ value = value.replace(/\.git$/, "").replace(/\/+$/, "");
1591
+ const parts = value.split("/").filter(Boolean);
1592
+ if (parts.length < 2) return null;
1593
+ const owner = parts[0];
1594
+ const repo = parts[1];
1595
+ if (owner === "" || repo === "" || owner.includes("..") || repo.includes("..")) return null;
1596
+ return {
1597
+ owner,
1598
+ repo,
1599
+ ...ref !== void 0 && ref !== "" ? { ref } : {}
1600
+ };
1601
+ }
1483
1602
  async function loadRepoTree(repo, ref, fetchImpl = fetch) {
1484
1603
  const { json: metaJson } = await fetchJsonCached(`https://api.github.com/repos/${repo}`, fetchImpl, "github repo not found or unavailable");
1485
1604
  let meta = metaJson;
@@ -1545,7 +1664,7 @@ function discoverRepoEntries(tree, repo, existingNames = /* @__PURE__ */ new Set
1545
1664
  }).sort((a, b) => a.name.localeCompare(b.name));
1546
1665
  }
1547
1666
  /** Run an async worker over items with a bounded concurrency. */
1548
- async function mapConcurrent(items, limit, worker) {
1667
+ async function mapConcurrent$1(items, limit, worker) {
1549
1668
  const results = new Array(items.length);
1550
1669
  let cursor = 0;
1551
1670
  const runners = Array.from({ length: Math.min(limit, items.length) }, async () => {
@@ -1558,10 +1677,6 @@ async function mapConcurrent(items, limit, worker) {
1558
1677
  await Promise.all(runners);
1559
1678
  return results;
1560
1679
  }
1561
- /** Whether an error is an abort (AbortError / signal). */
1562
- function isAbortError(error) {
1563
- return error instanceof Error && (error.name === "AbortError" || error.message.includes("aborted") || error.message.includes("Abort"));
1564
- }
1565
1680
  /**
1566
1681
  * Download one GitHub file as a buffer. Tries raw.githubusercontent.com
1567
1682
  * first (no API quota); on any failure falls back to the api.github.com
@@ -1619,7 +1734,7 @@ async function downloadRepoSkill(repo, ref, entry, files, targetRoot, fetchImpl
1619
1734
  const tempDir = await mkdtemp(join(targetRoot, "." + entry.name + ".import-"));
1620
1735
  let renamed = false;
1621
1736
  try {
1622
- await mapConcurrent(files, 6, async (file) => {
1737
+ await mapConcurrent$1(files, 6, async (file) => {
1623
1738
  if (signal?.aborted) throw new DOMException("aborted", "AbortError");
1624
1739
  const relative = file.path.slice(entry.dir.length + 1);
1625
1740
  if (relative === "" || relative.includes("..")) throw new RepoFetchError("unsafe repo path: " + file.path);
@@ -1700,65 +1815,48 @@ async function cleanupLeftoverImportDirs(targetRoot) {
1700
1815
  * repo has no releases (GitHub's /releases/latest skips prereleases/drafts).
1701
1816
  */
1702
1817
  async function getLatestReleaseTag(repo, fetchImpl = fetch) {
1703
- let response;
1704
- try {
1705
- response = await fetchImpl(`https://api.github.com/repos/${repo}/releases/latest`, { headers: {
1706
- accept: "application/vnd.github+json",
1707
- ...githubAuthHeaders()
1708
- } });
1709
- } catch (error) {
1710
- throw new RepoFetchError("github release request failed: " + (error instanceof Error ? error.message : String(error)));
1711
- }
1712
- if (response.status === 404) return void 0;
1713
- if (!response.ok) throw fetchError("github release lookup failed", response);
1714
1818
  let payload;
1715
1819
  try {
1716
- payload = await response.json();
1717
- } catch {
1718
- 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;
1719
1824
  }
1720
1825
  const tag = typeof payload === "object" && payload !== null ? payload.tag_name : void 0;
1721
1826
  return typeof tag === "string" && tag !== "" ? tag : void 0;
1722
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
+ }
1723
1851
  /** Release tags of a repo, newest first (skips drafts, keeps prereleases). */
1724
1852
  async function listRepoReleases(repo, fetchImpl = fetch) {
1725
- let response;
1726
- try {
1727
- response = await fetchImpl(`https://api.github.com/repos/${repo}/releases?per_page=20`, { headers: {
1728
- accept: "application/vnd.github+json",
1729
- ...githubAuthHeaders()
1730
- } });
1731
- } catch (error) {
1732
- throw new RepoFetchError("github releases request failed: " + (error instanceof Error ? error.message : String(error)));
1733
- }
1734
- if (!response.ok) throw fetchError("github releases lookup failed", response);
1735
- let payload;
1736
- try {
1737
- payload = await response.json();
1738
- } catch {
1739
- throw new RepoFetchError("invalid github releases response");
1740
- }
1853
+ const { json: payload } = await fetchJson(`https://api.github.com/repos/${repo}/releases?per_page=20`, fetchImpl, "github releases lookup failed");
1741
1854
  if (!Array.isArray(payload)) return [];
1742
1855
  return payload.filter((item) => typeof item === "object" && item !== null && item.draft !== true).map((item) => item.tag_name).filter((tag) => typeof tag === "string" && tag !== "");
1743
1856
  }
1744
1857
  /** Branch names of a repo, default branch first (one GitHub API request). */
1745
1858
  async function listRepoBranches(repo, fetchImpl = fetch) {
1746
- let response;
1747
- try {
1748
- response = await fetchImpl(`https://api.github.com/repos/${repo}/branches?per_page=100`, { headers: {
1749
- accept: "application/vnd.github+json",
1750
- ...githubAuthHeaders()
1751
- } });
1752
- } catch (error) {
1753
- throw new RepoFetchError("github branches request failed: " + (error instanceof Error ? error.message : String(error)));
1754
- }
1755
- if (!response.ok) throw fetchError("github branches lookup failed", response);
1756
- let payload;
1757
- try {
1758
- payload = await response.json();
1759
- } catch {
1760
- throw new RepoFetchError("invalid github branches response");
1761
- }
1859
+ const { json: payload } = await fetchJson(`https://api.github.com/repos/${repo}/branches?per_page=100`, fetchImpl, "github branches lookup failed");
1762
1860
  if (!Array.isArray(payload)) return [];
1763
1861
  return payload.map((item) => typeof item === "object" && item !== null ? item.name : void 0).filter((name) => typeof name === "string" && name !== "");
1764
1862
  }
@@ -1768,23 +1866,7 @@ async function listRepoBranches(repo, fetchImpl = fetch) {
1768
1866
  * upstream tree on change (one extra request).
1769
1867
  */
1770
1868
  async function getLatestCommit(repo, ref, fetchImpl = fetch) {
1771
- 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`;
1772
- let response;
1773
- try {
1774
- response = await fetchImpl(url, { headers: {
1775
- accept: "application/vnd.github+json",
1776
- ...githubAuthHeaders()
1777
- } });
1778
- } catch (error) {
1779
- throw new RepoFetchError("github request failed: " + (error instanceof Error ? error.message : String(error)));
1780
- }
1781
- if (!response.ok) throw fetchError("github commit lookup failed", response);
1782
- let payload;
1783
- try {
1784
- payload = await response.json();
1785
- } catch {
1786
- throw new RepoFetchError("invalid github commit response");
1787
- }
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");
1788
1870
  const record = Array.isArray(payload) ? payload[0] ?? {} : typeof payload === "object" && payload !== null ? payload : {};
1789
1871
  const commitSha = typeof record.sha === "string" && record.sha !== "" ? record.sha : null;
1790
1872
  const tree = typeof record.commit === "object" && record.commit !== null ? record.commit.tree : void 0;
@@ -1797,22 +1879,7 @@ async function getLatestCommit(repo, ref, fetchImpl = fetch) {
1797
1879
  }
1798
1880
  /** Load the recursive git tree at an explicit tree SHA (one API request). */
1799
1881
  async function loadRepoTreeAt(repo, treeSha, fetchImpl = fetch) {
1800
- let response;
1801
- try {
1802
- response = await fetchImpl(`https://api.github.com/repos/${repo}/git/trees/${encodeURIComponent(treeSha)}?recursive=1`, { headers: {
1803
- accept: "application/vnd.github+json",
1804
- ...githubAuthHeaders()
1805
- } });
1806
- } catch (error) {
1807
- throw new RepoFetchError("github tree request failed: " + (error instanceof Error ? error.message : String(error)));
1808
- }
1809
- if (!response.ok) throw fetchError("github tree lookup failed", response);
1810
- let payload;
1811
- try {
1812
- payload = await response.json();
1813
- } catch {
1814
- throw new RepoFetchError("invalid github tree response");
1815
- }
1882
+ const { json: payload } = await fetchJson(`https://api.github.com/repos/${repo}/git/trees/${encodeURIComponent(treeSha)}?recursive=1`, fetchImpl, "github tree lookup failed");
1816
1883
  const record = typeof payload === "object" && payload !== null ? payload : {};
1817
1884
  if (record.truncated === true) throw new RepoFetchError("repo tree is too large to scan");
1818
1885
  return Array.isArray(record.tree) ? record.tree : [];
@@ -2017,25 +2084,6 @@ async function checkLatestRelease(repo = UPDATE_REPO, fetchImpl = fetch) {
2017
2084
  url: typeof record.html_url === "string" ? record.html_url : null
2018
2085
  };
2019
2086
  }
2020
- //#endregion
2021
- //#region src/routes.ts
2022
- /**
2023
- * The /api/skill-hub route family: full catalog (enabled skills from the
2024
- * official registry + hub-disabled skills + discovery diagnostics), skill
2025
- * detail, enable/disable toggle, new-skill scaffold, user groups (tags +
2026
- * origin collections), and upstream source tracking (check/sync/follow
2027
- * upstream deletion into a restorable trash). Every route carries a
2028
- * loopback-only trust fence — these endpoints rename files under the user's
2029
- * skill roots, so LAN-exposed dsh web deployments must not serve them.
2030
- *
2031
- * Every handler is declared through the local `route()` wrapper, which owns
2032
- * the shared request fences (loopback trust, HTTP method, master switch,
2033
- * JSON body) exactly once, so a new route cannot forget them.
2034
- */
2035
- /** Cap on JSON request bodies (toggle/create payloads are tiny). */
2036
- const MAX_JSON_BODY_BYTES = 64 * 1024;
2037
- /** Minimum interval between network update checks per source (GitHub rate limits). */
2038
- const MIN_CHECK_INTERVAL_MS = 5 * 6e4;
2039
2087
  /** Loopback literal check plus browser same-origin markers. */
2040
2088
  function isLoopbackRequest(request) {
2041
2089
  const address = request.socket.remoteAddress;
@@ -2096,7 +2144,7 @@ async function readJsonBody(req) {
2096
2144
  for await (const chunk of req) {
2097
2145
  const buffer = chunk;
2098
2146
  size += buffer.length;
2099
- if (size > MAX_JSON_BODY_BYTES) return void 0;
2147
+ if (size > 65536) return void 0;
2100
2148
  chunks.push(buffer);
2101
2149
  }
2102
2150
  try {
@@ -2389,50 +2437,14 @@ function toDetail(skill) {
2389
2437
  content: skill.content
2390
2438
  };
2391
2439
  }
2392
- /** Last successful check time per repo (in-memory throttle for GitHub rate limits). */
2393
- const lastSourceCheck = /* @__PURE__ */ new Map();
2394
- /** Last successful market update check per repo (same throttle). */
2395
- const lastMarketCheck = /* @__PURE__ */ new Map();
2396
- const importJobs = /* @__PURE__ */ new Map();
2397
- const IMPORT_JOB_TTL_MS = 5 * 6e4;
2398
- const IMPORT_JOB_MAX = 100;
2399
- function gcImportJobs() {
2400
- if (importJobs.size > 500) importJobs.clear();
2401
- const now = Date.now();
2402
- for (const [id, job] of importJobs) if (job.status !== "running" && now - job.createdAt > IMPORT_JOB_TTL_MS) importJobs.delete(id);
2403
- if (importJobs.size > IMPORT_JOB_MAX) {
2404
- const sorted = [...importJobs.entries()].sort((a, b) => a[1].createdAt - b[1].createdAt);
2405
- for (let i = 0; i < sorted.length - IMPORT_JOB_MAX; i++) importJobs.delete(sorted[i][0]);
2406
- }
2407
- }
2408
- /** Replace one skill directory with a fresh download; restores the old dir on failure. */
2409
- async function replaceSkillDir(targetDir, download) {
2410
- const backup = join(dirname(targetDir), "." + basename(targetDir) + ".sync-bak-" + Date.now());
2411
- await rename(targetDir, backup);
2412
- try {
2413
- await download();
2414
- } catch (error) {
2415
- await rename(backup, targetDir).catch(() => {});
2416
- throw error;
2417
- }
2418
- await rm(backup, {
2419
- recursive: true,
2420
- force: true
2421
- });
2422
- }
2423
2440
  /**
2424
- * Build every /api/skill-hub route.
2425
- * @param deps - skill registry view + sidecar store.
2426
- * @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).
2427
2445
  */
2428
- function makeRoutes(deps) {
2429
- /**
2430
- * Wrap one handler in the shared request fences. Order matches the
2431
- * original per-handler prologue: loopback trust → HTTP method → master
2432
- * switch → JSON body. The outer catch maps errors onto the JSON error
2433
- * body (preserving RepoFetchError status codes).
2434
- */
2435
- const route = (spec) => ({
2446
+ function createRoute(deps, spec) {
2447
+ return {
2436
2448
  kind: "exact",
2437
2449
  path: spec.path,
2438
2450
  handler: async (req, res) => {
@@ -2466,16 +2478,25 @@ function makeRoutes(deps) {
2466
2478
  writeRouteError(res, error);
2467
2479
  }
2468
2480
  }
2469
- });
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) {
2470
2491
  return [
2471
- route({
2492
+ {
2472
2493
  path: SKILL_HUB_API.catalog,
2473
2494
  methods: ["GET"],
2474
2495
  handler: async ({ res, url }) => {
2475
2496
  writeJson(res, 200, await buildCatalog(deps, queryParam(url, "cwd")));
2476
2497
  }
2477
- }),
2478
- route({
2498
+ },
2499
+ {
2479
2500
  path: SKILL_HUB_API.skill,
2480
2501
  methods: ["GET"],
2481
2502
  handler: async ({ res, url }) => {
@@ -2551,8 +2572,8 @@ function makeRoutes(deps) {
2551
2572
  skill: detail
2552
2573
  });
2553
2574
  }
2554
- }),
2555
- route({
2575
+ },
2576
+ {
2556
2577
  path: SKILL_HUB_API.skillDelete,
2557
2578
  methods: ["POST"],
2558
2579
  jsonBody: true,
@@ -2608,8 +2629,8 @@ function makeRoutes(deps) {
2608
2629
  path
2609
2630
  });
2610
2631
  }
2611
- }),
2612
- route({
2632
+ },
2633
+ {
2613
2634
  path: SKILL_HUB_API.toggle,
2614
2635
  methods: ["POST"],
2615
2636
  jsonBody: true,
@@ -2658,8 +2679,8 @@ function makeRoutes(deps) {
2658
2679
  catalog: await buildCatalog(deps, lookup?.cwd)
2659
2680
  });
2660
2681
  }
2661
- }),
2662
- route({
2682
+ },
2683
+ {
2663
2684
  path: SKILL_HUB_API.toggleBatch,
2664
2685
  methods: ["POST"],
2665
2686
  jsonBody: true,
@@ -2710,8 +2731,8 @@ function makeRoutes(deps) {
2710
2731
  failures
2711
2732
  });
2712
2733
  }
2713
- }),
2714
- route({
2734
+ },
2735
+ {
2715
2736
  path: SKILL_HUB_API.create,
2716
2737
  methods: ["POST"],
2717
2738
  jsonBody: true,
@@ -2749,8 +2770,8 @@ function makeRoutes(deps) {
2749
2770
  root
2750
2771
  });
2751
2772
  }
2752
- }),
2753
- route({
2773
+ },
2774
+ {
2754
2775
  path: SKILL_HUB_API.stats,
2755
2776
  methods: ["GET"],
2756
2777
  handler: async ({ res }) => {
@@ -2768,8 +2789,19 @@ function makeRoutes(deps) {
2768
2789
  stats: await deps.stats()
2769
2790
  });
2770
2791
  }
2771
- }),
2772
- 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
+ {
2773
2805
  path: SKILL_HUB_API.config,
2774
2806
  methods: ["GET", "POST"],
2775
2807
  jsonBody: true,
@@ -2826,6 +2858,15 @@ function makeRoutes(deps) {
2826
2858
  }
2827
2859
  patch[key] = value;
2828
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
+ }
2829
2870
  let config;
2830
2871
  if (deps.updateConfig === void 0) {
2831
2872
  const merged = { ...configOf(deps) };
@@ -2840,8 +2881,96 @@ function makeRoutes(deps) {
2840
2881
  saved: savedOf(deps)
2841
2882
  });
2842
2883
  }
2843
- }),
2844
- 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
+ {
2845
2974
  path: SKILL_HUB_API.market,
2846
2975
  methods: ["GET"],
2847
2976
  handler: async ({ res }) => {
@@ -2850,8 +2979,8 @@ function makeRoutes(deps) {
2850
2979
  repos: await deps.store.listMarketSources()
2851
2980
  });
2852
2981
  }
2853
- }),
2854
- route({
2982
+ },
2983
+ {
2855
2984
  path: SKILL_HUB_API.marketSource,
2856
2985
  methods: ["POST"],
2857
2986
  jsonBody: true,
@@ -2868,8 +2997,8 @@ function makeRoutes(deps) {
2868
2997
  repos: await deps.store.addMarketSource(repoSlug(parsed), ref)
2869
2998
  });
2870
2999
  }
2871
- }),
2872
- route({
3000
+ },
3001
+ {
2873
3002
  path: SKILL_HUB_API.marketSourceDelete,
2874
3003
  methods: ["POST"],
2875
3004
  jsonBody: true,
@@ -2885,8 +3014,8 @@ function makeRoutes(deps) {
2885
3014
  repos: await deps.store.removeMarketSource(repoSlug(parsed))
2886
3015
  });
2887
3016
  }
2888
- }),
2889
- route({
3017
+ },
3018
+ {
2890
3019
  path: SKILL_HUB_API.marketSourceRef,
2891
3020
  methods: ["POST"],
2892
3021
  jsonBody: true,
@@ -2911,8 +3040,8 @@ function makeRoutes(deps) {
2911
3040
  repos: await deps.store.listMarketSources()
2912
3041
  });
2913
3042
  }
2914
- }),
2915
- route({
3043
+ },
3044
+ {
2916
3045
  path: SKILL_HUB_API.marketSourceVersions,
2917
3046
  methods: ["GET"],
2918
3047
  handler: async ({ res, url }) => {
@@ -2936,8 +3065,8 @@ function makeRoutes(deps) {
2936
3065
  branches
2937
3066
  });
2938
3067
  }
2939
- }),
2940
- route({
3068
+ },
3069
+ {
2941
3070
  path: SKILL_HUB_API.marketCheck,
2942
3071
  methods: ["GET"],
2943
3072
  handler: async ({ res }) => {
@@ -2950,7 +3079,7 @@ function makeRoutes(deps) {
2950
3079
  ...source.ref !== void 0 ? { ref: source.ref } : {}
2951
3080
  };
2952
3081
  const now = Date.now();
2953
- if (now - (lastMarketCheck.get(source.repo) ?? 0) < MIN_CHECK_INTERVAL_MS) {
3082
+ if (now - (lastMarketCheck.get(source.repo) ?? 0) < 3e5) {
2954
3083
  results.push({
2955
3084
  ...base,
2956
3085
  updateAvailable: false,
@@ -2994,8 +3123,65 @@ function makeRoutes(deps) {
2994
3123
  results
2995
3124
  });
2996
3125
  }
2997
- }),
2998
- 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
+ {
2999
3185
  path: SKILL_HUB_API.marketSync,
3000
3186
  methods: ["POST"],
3001
3187
  jsonBody: true,
@@ -3034,8 +3220,19 @@ function makeRoutes(deps) {
3034
3220
  skills: tracked !== void 0 ? [...tracked.skills] : []
3035
3221
  });
3036
3222
  }
3037
- }),
3038
- 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
+ {
3039
3236
  path: SKILL_HUB_API.repo,
3040
3237
  methods: ["GET"],
3041
3238
  handler: async ({ res, url }) => {
@@ -3081,8 +3278,8 @@ function makeRoutes(deps) {
3081
3278
  ...truncated ? { truncated: true } : {}
3082
3279
  });
3083
3280
  }
3084
- }),
3085
- route({
3281
+ },
3282
+ {
3086
3283
  path: SKILL_HUB_API.repoImport,
3087
3284
  methods: ["POST"],
3088
3285
  jsonBody: true,
@@ -3220,8 +3417,8 @@ function makeRoutes(deps) {
3220
3417
  totalBytes
3221
3418
  });
3222
3419
  }
3223
- }),
3224
- route({
3420
+ },
3421
+ {
3225
3422
  path: SKILL_HUB_API.repoImportProgress,
3226
3423
  methods: ["GET"],
3227
3424
  handler: async ({ res, url }) => {
@@ -3254,8 +3451,8 @@ function makeRoutes(deps) {
3254
3451
  ...job.error !== void 0 ? { error: job.error } : {}
3255
3452
  });
3256
3453
  }
3257
- }),
3258
- route({
3454
+ },
3455
+ {
3259
3456
  path: SKILL_HUB_API.repoImportCancel,
3260
3457
  methods: ["POST"],
3261
3458
  jsonBody: true,
@@ -3281,22 +3478,26 @@ function makeRoutes(deps) {
3281
3478
  status: job.status
3282
3479
  });
3283
3480
  }
3284
- }),
3285
- route({
3286
- path: SKILL_HUB_API.update,
3287
- methods: ["GET"],
3288
- handler: async ({ res }) => {
3289
- writeJson(res, 200, await checkLatestRelease());
3290
- }
3291
- }),
3292
- 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
+ {
3293
3494
  path: SKILL_HUB_API.groups,
3294
3495
  methods: ["GET"],
3295
3496
  handler: async ({ res }) => {
3296
3497
  writeJson(res, 200, await buildGroups(deps));
3297
3498
  }
3298
- }),
3299
- route({
3499
+ },
3500
+ {
3300
3501
  path: SKILL_HUB_API.tag,
3301
3502
  methods: ["POST"],
3302
3503
  jsonBody: true,
@@ -3316,8 +3517,8 @@ function makeRoutes(deps) {
3316
3517
  tags: await deps.store.listTags()
3317
3518
  });
3318
3519
  }
3319
- }),
3320
- route({
3520
+ },
3521
+ {
3321
3522
  path: SKILL_HUB_API.tagDelete,
3322
3523
  methods: ["POST"],
3323
3524
  jsonBody: true,
@@ -3337,8 +3538,8 @@ function makeRoutes(deps) {
3337
3538
  tags: await deps.store.listTags()
3338
3539
  });
3339
3540
  }
3340
- }),
3341
- route({
3541
+ },
3542
+ {
3342
3543
  path: SKILL_HUB_API.tagMembers,
3343
3544
  methods: ["POST"],
3344
3545
  jsonBody: true,
@@ -3360,8 +3561,8 @@ function makeRoutes(deps) {
3360
3561
  tags: await deps.store.listTags()
3361
3562
  });
3362
3563
  }
3363
- }),
3364
- route({
3564
+ },
3565
+ {
3365
3566
  path: SKILL_HUB_API.tagReorder,
3366
3567
  methods: ["POST"],
3367
3568
  jsonBody: true,
@@ -3381,8 +3582,8 @@ function makeRoutes(deps) {
3381
3582
  throw error;
3382
3583
  }
3383
3584
  }
3384
- }),
3385
- route({
3585
+ },
3586
+ {
3386
3587
  path: SKILL_HUB_API.collectionReorder,
3387
3588
  methods: ["POST"],
3388
3589
  jsonBody: true,
@@ -3396,8 +3597,8 @@ function makeRoutes(deps) {
3396
3597
  order
3397
3598
  });
3398
3599
  }
3399
- }),
3400
- route({
3600
+ },
3601
+ {
3401
3602
  path: SKILL_HUB_API.sourceGroupReorder,
3402
3603
  methods: ["POST"],
3403
3604
  jsonBody: true,
@@ -3409,8 +3610,20 @@ function makeRoutes(deps) {
3409
3610
  order: await deps.store.reorderSourceGroups(orderedKeys)
3410
3611
  });
3411
3612
  }
3412
- }),
3413
- 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
+ {
3414
3627
  path: SKILL_HUB_API.sources,
3415
3628
  methods: ["GET"],
3416
3629
  handler: async ({ res }) => {
@@ -3443,8 +3656,8 @@ function makeRoutes(deps) {
3443
3656
  trash
3444
3657
  });
3445
3658
  }
3446
- }),
3447
- route({
3659
+ },
3660
+ {
3448
3661
  path: SKILL_HUB_API.sourceCheck,
3449
3662
  methods: ["POST"],
3450
3663
  jsonBody: true,
@@ -3469,7 +3682,7 @@ function makeRoutes(deps) {
3469
3682
  ...item.ref !== void 0 ? { ref: item.ref } : {}
3470
3683
  };
3471
3684
  const now = Date.now();
3472
- if (now - (lastSourceCheck.get(item.repo) ?? 0) < MIN_CHECK_INTERVAL_MS) {
3685
+ if (now - (lastSourceCheck.get(item.repo) ?? 0) < 3e5) {
3473
3686
  results.push({
3474
3687
  ...base,
3475
3688
  changed: false,
@@ -3526,8 +3739,8 @@ function makeRoutes(deps) {
3526
3739
  results
3527
3740
  });
3528
3741
  }
3529
- }),
3530
- route({
3742
+ },
3743
+ {
3531
3744
  path: SKILL_HUB_API.sourceSync,
3532
3745
  methods: ["POST"],
3533
3746
  jsonBody: true,
@@ -3598,8 +3811,8 @@ function makeRoutes(deps) {
3598
3811
  failed
3599
3812
  });
3600
3813
  }
3601
- }),
3602
- route({
3814
+ },
3815
+ {
3603
3816
  path: SKILL_HUB_API.sourceDelete,
3604
3817
  methods: ["POST"],
3605
3818
  jsonBody: true,
@@ -3683,8 +3896,8 @@ function makeRoutes(deps) {
3683
3896
  failed
3684
3897
  });
3685
3898
  }
3686
- }),
3687
- route({
3899
+ },
3900
+ {
3688
3901
  path: SKILL_HUB_API.sourceRestore,
3689
3902
  methods: ["POST"],
3690
3903
  jsonBody: true,
@@ -3716,8 +3929,8 @@ function makeRoutes(deps) {
3716
3929
  path
3717
3930
  });
3718
3931
  }
3719
- }),
3720
- route({
3932
+ },
3933
+ {
3721
3934
  path: SKILL_HUB_API.sourceTrashClear,
3722
3935
  methods: ["POST"],
3723
3936
  handler: async ({ res }) => {
@@ -3743,33 +3956,27 @@ function makeRoutes(deps) {
3743
3956
  failed
3744
3957
  });
3745
3958
  }
3746
- }),
3747
- route({
3748
- path: SKILL_HUB_API.diagnosticFix,
3749
- methods: ["POST"],
3750
- jsonBody: true,
3751
- handler: async ({ res, body }) => {
3752
- const request = body;
3753
- const rawPath = typeof request.path === "string" ? request.path.trim() : "";
3754
- if (rawPath === "") {
3755
- writeError(res, 400, "path is required");
3756
- return;
3757
- }
3758
- try {
3759
- await fixDiagnosticFile(rawPath, homeOf(deps));
3760
- deps.invalidate?.();
3761
- writeJson(res, 200, {
3762
- ok: true,
3763
- path: rawPath
3764
- });
3765
- } catch (error) {
3766
- writeRouteError(res, error);
3767
- }
3768
- }
3769
- })
3959
+ }
3770
3960
  ];
3771
3961
  }
3772
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
3773
3980
  //#region src/stats.ts
3774
3981
  /** Fallback freeze horizon when no rolling window is configured (14 days). */
3775
3982
  const STATS_FREEZE_AFTER_MS = 336 * 60 * 60 * 1e3;
@@ -3808,6 +4015,22 @@ function isFrozen(record, watermark) {
3808
4015
  const created = record.header.createdAt;
3809
4016
  return typeof created === "number" && created > 0 && created < watermark;
3810
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;
3811
4034
  function mergeInto(totals, counted) {
3812
4035
  for (const [name, stat] of counted) {
3813
4036
  const total = totals[name];
@@ -3853,20 +4076,23 @@ async function scan(query, checkpoint, nowMs, windowDays) {
3853
4076
  if (nowMs - checkpoint.lastFullReconcile >= 864e5 || checkpoint.windowDays !== windowDays) {
3854
4077
  const cache = {};
3855
4078
  const totals = {};
3856
- for (const record of sessions) {
3857
- let counted;
4079
+ const countedList = await mapConcurrent(sessions, SCAN_READ_CONCURRENCY, async (record) => {
3858
4080
  try {
3859
- counted = countSkillInvocations((await query.readSession(record.header.id)).events);
4081
+ return countSkillInvocations((await query.readSession(record.header.id)).events);
3860
4082
  } catch {
3861
- continue;
4083
+ return;
3862
4084
  }
4085
+ });
4086
+ sessions.forEach((record, index) => {
4087
+ const counted = countedList[index];
4088
+ if (counted === void 0) return;
3863
4089
  const created = record.header.createdAt;
3864
4090
  if (isFrozen(record, cutoff) && counted.size > 0 && typeof created === "number") cache[record.header.id] = {
3865
4091
  createdAt: created,
3866
4092
  counts: Object.fromEntries(counted)
3867
4093
  };
3868
4094
  if (inWindow(created, windowDays, nowMs)) mergeInto(totals, counted);
3869
- }
4095
+ });
3870
4096
  checkpoint.frozenSessions = cache;
3871
4097
  checkpoint.frozenBefore = cutoff;
3872
4098
  checkpoint.windowDays = windowDays;
@@ -3877,13 +4103,16 @@ async function scan(query, checkpoint, nowMs, windowDays) {
3877
4103
  };
3878
4104
  }
3879
4105
  const recent = {};
3880
- for (const record of sessions) {
3881
- if (isFrozen(record, checkpoint.frozenBefore)) continue;
4106
+ const recentList = await mapConcurrent(sessions.filter((record) => !isFrozen(record, checkpoint.frozenBefore)), SCAN_READ_CONCURRENCY, async (record) => {
3882
4107
  try {
3883
- mergeInto(recent, countSkillInvocations((await query.readSession(record.header.id)).events));
4108
+ return countSkillInvocations((await query.readSession(record.header.id)).events);
3884
4109
  } catch {
3885
- continue;
4110
+ return;
3886
4111
  }
4112
+ });
4113
+ for (const counted of recentList) {
4114
+ if (counted === void 0) continue;
4115
+ mergeInto(recent, counted);
3887
4116
  }
3888
4117
  const totals = {};
3889
4118
  for (const [id, entry] of Object.entries(checkpoint.frozenSessions)) {
@@ -3921,7 +4150,7 @@ function createSkillStatsReader(query, ttlMs = 3e5, options = {}) {
3921
4150
  lastFullReconcile: 0
3922
4151
  };
3923
4152
  const now = options.now ?? (() => Date.now());
3924
- let cached;
4153
+ let cached = checkpoint.lastTotals !== void 0 ? [...checkpoint.lastTotals] : void 0;
3925
4154
  let cachedAt = 0;
3926
4155
  let refreshing = null;
3927
4156
  let lastScanDurationMs = 0;
@@ -3932,11 +4161,12 @@ function createSkillStatsReader(query, ttlMs = 3e5, options = {}) {
3932
4161
  if (cached !== void 0 && startedAt - cachedAt < ttl) return cached;
3933
4162
  if (refreshing === null) {
3934
4163
  const windowDays = options.windowDays?.() ?? 0;
3935
- refreshing = scan(query, checkpoint, startedAt, windowDays).then(({ stats, mutated }) => {
4164
+ refreshing = scan(query, checkpoint, startedAt, windowDays).then(({ stats }) => {
3936
4165
  cached = stats;
3937
4166
  cachedAt = now();
3938
4167
  lastScanDurationMs = Math.max(0, cachedAt - startedAt);
3939
- if (mutated) options.onCheckpoint?.({
4168
+ checkpoint.lastTotals = stats;
4169
+ options.onCheckpoint?.({
3940
4170
  ...checkpoint,
3941
4171
  frozenSessions: { ...checkpoint.frozenSessions }
3942
4172
  });
@@ -3984,6 +4214,7 @@ const HubSettingsSchema = z.object({
3984
4214
  showGroupSummary: z.boolean().default(HUB_CONFIG_DEFAULTS.showGroupSummary),
3985
4215
  dotModelColor: z.string().pattern(HEX_COLOR_RE),
3986
4216
  dotUserColor: z.string().pattern(HEX_COLOR_RE),
4217
+ githubToken: z.string(),
3987
4218
  statsWindowDays: z.number().min(0).max(3650).default(HUB_CONFIG_DEFAULTS.statsWindowDays),
3988
4219
  statsScanMinutes: z.number().min(1).max(1440).default(HUB_CONFIG_DEFAULTS.statsScanMinutes)
3989
4220
  });
@@ -4026,6 +4257,7 @@ function apply(ctx, config) {
4026
4257
  disposeProvider = void 0;
4027
4258
  }
4028
4259
  const value = current();
4260
+ setGithubToken(value.githubToken);
4029
4261
  if (value.announceToAgent) disposeSection = ctx.systemPrompt.section({
4030
4262
  name: "plugin:dsh-skill-hub",
4031
4263
  order: SECTION_ORDER,
@@ -4092,7 +4324,7 @@ function apply(ctx, config) {
4092
4324
  const value = current().statsWindowDays;
4093
4325
  return typeof value === "number" && value >= 0 ? Math.floor(value) : HUB_CONFIG_DEFAULTS.statsWindowDays;
4094
4326
  };
4095
- stats = createSkillStatsReader(sctx.sessionQuery, () => scanMinutes() * 6e4, {
4327
+ const reader = createSkillStatsReader(sctx.sessionQuery, () => scanMinutes() * 6e4, {
4096
4328
  checkpoint: saved,
4097
4329
  windowDays,
4098
4330
  onCheckpoint: (next) => {
@@ -4101,7 +4333,9 @@ function apply(ctx, config) {
4101
4333
  });
4102
4334
  }
4103
4335
  });
4336
+ stats = reader;
4104
4337
  sync();
4338
+ reader();
4105
4339
  })();
4106
4340
  });
4107
4341
  }