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.
- package/README.md +40 -50
- package/README.zh.md +38 -48
- package/lib/client.js +2569 -1646
- package/lib/client.js.map +1 -1
- package/lib/index.js +988 -400
- package/lib/types/client/SkillHubSettingsCard.d.ts +1 -2
- package/lib/types/client/api.d.ts +15 -5
- package/lib/types/client/helpers.d.ts +10 -1
- package/lib/types/client/locales/common.d.ts +52 -0
- package/lib/types/client/locales/detail.d.ts +16 -0
- package/lib/types/client/locales/market.d.ts +73 -0
- package/lib/types/client/locales/settings.d.ts +41 -0
- package/lib/types/client/locales/skills.d.ts +65 -0
- package/lib/types/client/locales/sources.d.ts +35 -0
- package/lib/types/client/locales.d.ts +147 -111
- package/lib/types/client/panel/DisabledRow.d.ts +2 -0
- package/lib/types/client/panel/MarketSourceRow.d.ts +12 -0
- package/lib/types/client/panel/MarketView.d.ts +1 -0
- package/lib/types/client/panel/RepoScanCard.d.ts +10 -0
- package/lib/types/client/panel/SkillDetailView.d.ts +5 -1
- package/lib/types/client/panel/dialogs.d.ts +19 -0
- package/lib/types/client/panel/hooks/shared.d.ts +72 -0
- package/lib/types/client/panel/hooks/useCatalogFlow.d.ts +62 -0
- package/lib/types/client/panel/hooks/useGroupFlow.d.ts +41 -0
- package/lib/types/client/panel/hooks/useMarketFlow.d.ts +73 -0
- package/lib/types/client/panel/hooks/useMetaFlow.d.ts +18 -0
- package/lib/types/client/panel/hooks/useSourceFlow.d.ts +44 -0
- package/lib/types/client/panel/market-format.d.ts +6 -0
- package/lib/types/client/panel/useSkillHub.d.ts +54 -71
- package/lib/types/client/settings-card.d.ts +20 -17
- package/lib/types/client/settings-form.d.ts +6 -1
- package/lib/types/protocol/api.d.ts +38 -0
- package/lib/types/protocol/catalog.d.ts +155 -0
- package/lib/types/protocol/config.d.ts +113 -0
- package/lib/types/protocol/groups.d.ts +95 -0
- package/lib/types/protocol/market.d.ts +94 -0
- package/lib/types/protocol/repo.d.ts +107 -0
- package/lib/types/protocol/sources.d.ts +139 -0
- package/lib/types/protocol/stats.d.ts +46 -0
- package/lib/types/protocol.d.ts +13 -720
- package/lib/types/repo/github-client.d.ts +42 -0
- package/lib/types/repo.d.ts +9 -22
- package/lib/types/routes/catalog.d.ts +7 -0
- package/lib/types/routes/config.d.ts +7 -0
- package/lib/types/routes/groups.d.ts +7 -0
- package/lib/types/routes/helpers.d.ts +145 -0
- package/lib/types/routes/market.d.ts +8 -0
- package/lib/types/routes/repo-import.d.ts +7 -0
- package/lib/types/routes/route-state.d.ts +55 -0
- package/lib/types/routes/sources.d.ts +8 -0
- package/lib/types/routes.d.ts +5 -34
- package/lib/types/skillfs.d.ts +13 -0
- package/lib/types/stats.d.ts +3 -2
- package/lib/types/store/errors.d.ts +10 -0
- package/lib/types/store/migrate.d.ts +44 -0
- package/lib/types/store/paths.d.ts +32 -0
- package/lib/types/store/store.d.ts +133 -0
- package/lib/types/store.d.ts +7 -146
- package/package.json +30 -28
- package/src/client/SkillHubSettingsCard.tsx +17 -5
- package/src/client/api.ts +108 -184
- package/src/client/helpers.ts +50 -1
- package/src/client/locales/common.ts +102 -0
- package/src/client/locales/detail.ts +30 -0
- package/src/client/locales/market.ts +144 -0
- package/src/client/locales/settings.ts +80 -0
- package/src/client/locales/skills.ts +128 -0
- package/src/client/locales/sources.ts +68 -0
- package/src/client/locales.ts +26 -452
- package/src/client/panel/DisabledRow.tsx +22 -7
- package/src/client/panel/MarketSourceRow.tsx +108 -0
- package/src/client/panel/MarketView.tsx +12 -246
- package/src/client/panel/RepoScanCard.tsx +171 -0
- package/src/client/panel/ScenesView.tsx +1 -1
- package/src/client/panel/SkillDetailView.tsx +35 -11
- package/src/client/panel/SkillHubPanel.tsx +42 -4
- package/src/client/panel/SkillRow.tsx +5 -2
- package/src/client/panel/SourcesView.tsx +2 -17
- package/src/client/panel/dialogs.tsx +61 -0
- package/src/client/panel/hooks/shared.ts +81 -0
- package/src/client/panel/hooks/useCatalogFlow.ts +211 -0
- package/src/client/panel/hooks/useGroupFlow.ts +240 -0
- package/src/client/panel/hooks/useMarketFlow.ts +421 -0
- package/src/client/panel/hooks/useMetaFlow.ts +58 -0
- package/src/client/panel/hooks/useSourceFlow.ts +193 -0
- package/src/client/panel/market-format.ts +22 -0
- package/src/client/panel/panel.module.css +1 -8
- package/src/client/panel/useSkillHub.ts +142 -948
- package/src/client/settings-card.tsx +51 -42
- package/src/client/settings-form.ts +19 -5
- package/src/index.ts +14 -4
- package/src/protocol/api.ts +38 -0
- package/src/protocol/catalog.ts +168 -0
- package/src/protocol/config.ts +145 -0
- package/src/protocol/groups.ts +106 -0
- package/src/protocol/market.ts +100 -0
- package/src/protocol/repo.ts +104 -0
- package/src/protocol/sources.ts +146 -0
- package/src/protocol/stats.ts +42 -0
- package/src/protocol.ts +83 -775
- package/src/repo/github-client.ts +131 -0
- package/src/repo.test.ts +24 -1
- package/src/repo.ts +69 -147
- package/src/routes/catalog.ts +309 -0
- package/src/routes/config.ts +119 -0
- package/src/routes/groups.ts +133 -0
- package/src/routes/helpers.ts +489 -0
- package/src/routes/market.ts +255 -0
- package/src/routes/repo-import.ts +267 -0
- package/src/routes/route-state.ts +89 -0
- package/src/routes/sources.ts +318 -0
- package/src/routes.test.ts +42 -0
- package/src/routes.ts +21 -1528
- package/src/skillfs.ts +188 -3
- package/src/stats.test.ts +58 -3
- package/src/stats.ts +45 -16
- package/src/store/errors.ts +14 -0
- package/src/store/migrate.ts +272 -0
- package/src/store/paths.ts +43 -0
- package/src/store/store.ts +532 -0
- package/src/store.test.ts +18 -0
- 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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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 (
|
|
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(
|
|
951
|
+
data = load(rawFrontmatter);
|
|
872
952
|
} catch (error) {
|
|
873
|
-
|
|
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
|
|
1179
|
-
*
|
|
1180
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
1262
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1522
|
-
} catch {
|
|
1523
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 >
|
|
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
|
|
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
|
-
|
|
2056
|
-
|
|
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
|
-
*
|
|
2165
|
-
*
|
|
2166
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) <
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
2729
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2955
|
-
|
|
2956
|
-
|
|
2957
|
-
|
|
2958
|
-
|
|
2959
|
-
|
|
2960
|
-
|
|
2961
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) <
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3503
|
-
let counted;
|
|
4079
|
+
const countedList = await mapConcurrent(sessions, SCAN_READ_CONCURRENCY, async (record) => {
|
|
3504
4080
|
try {
|
|
3505
|
-
|
|
4081
|
+
return countSkillInvocations((await query.readSession(record.header.id)).events);
|
|
3506
4082
|
} catch {
|
|
3507
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4108
|
+
return countSkillInvocations((await query.readSession(record.header.id)).events);
|
|
3530
4109
|
} catch {
|
|
3531
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|