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