@nanmicoder/dsh-skills-hub 0.0.1
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/AGENTS.md +9 -0
- package/CONTRIBUTING.md +11 -0
- package/LICENSE +21 -0
- package/README.md +120 -0
- package/README_EN.md +120 -0
- package/README_ZH.md +5 -0
- package/assets/readme/hero-en.png +0 -0
- package/assets/readme/hero-zh.png +0 -0
- package/assets/readme/marketplace.png +0 -0
- package/compatibility.json +10 -0
- package/cordis.patch.yml +5 -0
- package/docs/COMPATIBILITY.md +31 -0
- package/docs/CONTRACT.md +125 -0
- package/docs/RELEASE.md +24 -0
- package/lib/client.js +25115 -0
- package/lib/client.js.map +1 -0
- package/lib/config.js +51 -0
- package/lib/index.js +176 -0
- package/lib/market/cache.js +113 -0
- package/lib/market/clawhub-provider.js +598 -0
- package/lib/market/install-service.js +394 -0
- package/lib/market/market-service.js +312 -0
- package/lib/market/provider-fetch.js +216 -0
- package/lib/market/skillhub-provider.js +277 -0
- package/lib/market/types.js +82 -0
- package/lib/skills/installed.js +426 -0
- package/lib/skills/management.js +36 -0
- package/lib/skills/read-text.js +23 -0
- package/lib/skills/root.js +112 -0
- package/lib/types/client/api.d.ts +106 -0
- package/lib/types/client/components/FilterBar.d.ts +20 -0
- package/lib/types/client/components/FrontmatterPanel.d.ts +15 -0
- package/lib/types/client/components/InstallConfirmDialog.d.ts +31 -0
- package/lib/types/client/components/InstallStateBadge.d.ts +11 -0
- package/lib/types/client/components/InstalledSkills.d.ts +3 -0
- package/lib/types/client/components/MarkdownView.d.ts +4 -0
- package/lib/types/client/components/MarketDisclaimer.d.ts +10 -0
- package/lib/types/client/components/MarketHome.d.ts +12 -0
- package/lib/types/client/components/SecurityBadge.d.ts +16 -0
- package/lib/types/client/components/SkillAvatar.d.ts +14 -0
- package/lib/types/client/components/SkillCard.d.ts +17 -0
- package/lib/types/client/components/SkillDetailView.d.ts +21 -0
- package/lib/types/client/components/SourceStatusBar.d.ts +13 -0
- package/lib/types/client/icons.d.ts +71 -0
- package/lib/types/client/index.d.ts +56 -0
- package/lib/types/client/locale-context.d.ts +31 -0
- package/lib/types/client/locales.d.ts +117 -0
- package/lib/types/client/page/MarketPage.d.ts +21 -0
- package/lib/types/client/skill-markdown.d.ts +2 -0
- package/lib/types/client/state.d.ts +119 -0
- package/lib/types/config.d.ts +51 -0
- package/lib/types/index.d.ts +42 -0
- package/lib/types/market/cache.d.ts +49 -0
- package/lib/types/market/clawhub-provider.d.ts +28 -0
- package/lib/types/market/install-service.d.ts +75 -0
- package/lib/types/market/market-service.d.ts +74 -0
- package/lib/types/market/provider-fetch.d.ts +61 -0
- package/lib/types/market/skillhub-provider.d.ts +13 -0
- package/lib/types/market/types.d.ts +163 -0
- package/lib/types/skills/installed.d.ts +117 -0
- package/lib/types/skills/management.d.ts +7 -0
- package/lib/types/skills/read-text.d.ts +5 -0
- package/lib/types/skills/root.d.ts +48 -0
- package/lib/types/web-routes.d.ts +74 -0
- package/lib/web-routes.js +427 -0
- package/package.json +160 -0
- package/release-notes/v0.0.1.md +11 -0
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skills Hub HTTP surface.
|
|
3
|
+
*
|
|
4
|
+
* One `prefix` route (`/api/skills-hub`) owns the seven endpoints the panel
|
|
5
|
+
* needs; every response is JSON with `Cache-Control: no-store`, because the
|
|
6
|
+
* panel polls and every payload is either live upstream data or live disk state.
|
|
7
|
+
*
|
|
8
|
+
* Two properties matter more than the routing itself:
|
|
9
|
+
*
|
|
10
|
+
* - **Nothing escapes as a non-JSON error.** The handler catches its own
|
|
11
|
+
* rejections, the web server's own catch-all would answer a thrown handler
|
|
12
|
+
* with a bare `400` and no body, and the browser client parses the
|
|
13
|
+
* `{ error: { code, message } }` envelope.
|
|
14
|
+
* - **The browser-trust gate runs first.** Raw `webServer` routes do not inherit
|
|
15
|
+
* the Connection service's Host/Origin fence, so a route registered here would
|
|
16
|
+
* otherwise be reachable by a cross-site request or a DNS-rebound page.
|
|
17
|
+
*/
|
|
18
|
+
import { installMarketSkill, MarketInstallError, uninstallMarketSkill, } from "./market/install-service.js";
|
|
19
|
+
import { getMarketFileContent, getMarketSkillDetail, getMarketStatus, isValidMarketFilePath, listMarketSkills, } from "./market/market-service.js";
|
|
20
|
+
import { readInstalledSkill, removeInstalledSkill } from "./skills/management.js";
|
|
21
|
+
import { scanInstalledSkills } from "./skills/installed.js";
|
|
22
|
+
import { resolveSkillsScanRoots } from "./skills/root.js";
|
|
23
|
+
import { MARKET_ERROR_CODES, MARKET_SOURCES, MarketUpstreamError, parseSkillId, } from "./market/types.js";
|
|
24
|
+
/** Path prefix of the whole Skills Hub API. */
|
|
25
|
+
export const ROUTE_PREFIX = '/api/skills-hub';
|
|
26
|
+
/** Request bodies here are two-field JSON objects; anything larger is an attack. */
|
|
27
|
+
const MAX_BODY_BYTES = 64 * 1024;
|
|
28
|
+
/** Default `limit` for `GET /skills`; `apply()` overwrites it from `Config.pageSize`. */
|
|
29
|
+
const DEFAULT_PAGE_SIZE = 24;
|
|
30
|
+
/** Hard cap on `limit`, mirroring the reference API. */
|
|
31
|
+
const MAX_PAGE_SIZE = 100;
|
|
32
|
+
/** Longest accepted `q` parameter. */
|
|
33
|
+
const MAX_QUERY_LENGTH = 200;
|
|
34
|
+
/** Longest accepted opaque `cursor`. */
|
|
35
|
+
const MAX_CURSOR_LENGTH = 512;
|
|
36
|
+
/** Longest accepted slug. */
|
|
37
|
+
const MAX_SLUG_LENGTH = 200;
|
|
38
|
+
const SECURITY_FILTERS = ['verified', 'benign', 'unknown', 'flagged'];
|
|
39
|
+
const INSTALLED_FILTERS = ['all', 'installed', 'installable'];
|
|
40
|
+
/** Module-scoped default page size, set once by `apply()` (frozen deps carry no config). */
|
|
41
|
+
let defaultPageSize = DEFAULT_PAGE_SIZE;
|
|
42
|
+
/**
|
|
43
|
+
* Set the default `/skills` page size from `Config.pageSize`.
|
|
44
|
+
*
|
|
45
|
+
* The frozen `SkillsHubRoutesDeps` has no config seat, and widening it would
|
|
46
|
+
* change a frozen interface; this setter is the additive seam instead.
|
|
47
|
+
*/
|
|
48
|
+
export function setSkillsHubPageSize(pageSize) {
|
|
49
|
+
if (Number.isFinite(pageSize) && pageSize >= 1)
|
|
50
|
+
defaultPageSize = Math.min(MAX_PAGE_SIZE, Math.floor(pageSize));
|
|
51
|
+
}
|
|
52
|
+
/** A rejection produced by this module itself: status, envelope code and headers. */
|
|
53
|
+
class RouteError extends Error {
|
|
54
|
+
status;
|
|
55
|
+
code;
|
|
56
|
+
allow;
|
|
57
|
+
constructor(status, code, message, allow) {
|
|
58
|
+
super(message);
|
|
59
|
+
this.name = 'RouteError';
|
|
60
|
+
this.status = status;
|
|
61
|
+
this.code = code;
|
|
62
|
+
this.allow = allow;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/** One JSON response; the only place a body is written. */
|
|
66
|
+
function sendJson(res, status, payload, headers) {
|
|
67
|
+
if (res.headersSent) {
|
|
68
|
+
res.destroy();
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
res.writeHead(status, {
|
|
72
|
+
'content-type': 'application/json; charset=utf-8',
|
|
73
|
+
'cache-control': 'no-store',
|
|
74
|
+
...headers,
|
|
75
|
+
});
|
|
76
|
+
res.end(JSON.stringify(payload));
|
|
77
|
+
}
|
|
78
|
+
function errorPayload(code, message) {
|
|
79
|
+
return { error: { code, message } };
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Map any thrown value onto the envelope's status/code pair.
|
|
83
|
+
*
|
|
84
|
+
* The install service decides its own status (409 conflict, 400 not
|
|
85
|
+
* installable, 502 checksum, 500 disk) and travels with it: the route never
|
|
86
|
+
* re-derives a status from a code, so there is exactly one place that can get
|
|
87
|
+
* the mapping wrong.
|
|
88
|
+
*/
|
|
89
|
+
function toHttpFailure(error) {
|
|
90
|
+
if (error instanceof RouteError || error instanceof MarketInstallError) {
|
|
91
|
+
const allow = error instanceof RouteError ? error.allow : undefined;
|
|
92
|
+
return { status: error.status, code: error.code, message: error.message, allow };
|
|
93
|
+
}
|
|
94
|
+
if (error instanceof MarketUpstreamError) {
|
|
95
|
+
// A malformed/absent upstream payload is "this skill does not exist";
|
|
96
|
+
// every other upstream problem is a bad gateway.
|
|
97
|
+
const status = error.code === MARKET_ERROR_CODES.upstreamBadResponse ? 404 : 502;
|
|
98
|
+
return { status, code: error.code, message: error.message, allow: undefined };
|
|
99
|
+
}
|
|
100
|
+
// Unexpected failure: answer with a fixed sentence. Raw `error.message` values
|
|
101
|
+
// are Node filesystem errors carrying absolute paths ("EEXIST: … mkdir
|
|
102
|
+
// '/var/folders/…'"), which must not be echoed to the browser. Documented
|
|
103
|
+
// MarketInstallError / MarketUpstreamError messages above are passed through
|
|
104
|
+
// verbatim; the diagnostic detail of an *unexpected* error has no logger seat
|
|
105
|
+
// on the frozen deps surface, so it is dropped here.
|
|
106
|
+
return {
|
|
107
|
+
status: 500,
|
|
108
|
+
code: 'INTERNAL_ERROR',
|
|
109
|
+
message: 'internal error',
|
|
110
|
+
allow: undefined,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
/** Answer one thrown value; the single exit for every failure path. */
|
|
114
|
+
function sendFailure(res, error) {
|
|
115
|
+
const failure = toHttpFailure(error);
|
|
116
|
+
sendJson(res, failure.status, errorPayload(failure.code, failure.message), failure.allow === undefined ? undefined : { allow: failure.allow });
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Run the browser-trust gate and return the rejection status, if any.
|
|
120
|
+
*
|
|
121
|
+
* The frozen gate type says `boolean`, the real service answers `401 | 403 |
|
|
122
|
+
* undefined`; both are accepted so the envelope can report the true status when
|
|
123
|
+
* the plugin is wired to the real Connection service, while a plain predicate
|
|
124
|
+
* still works.
|
|
125
|
+
*/
|
|
126
|
+
function evaluateGate(gate, req, res) {
|
|
127
|
+
const outcome = gate.requestRejection(req, res);
|
|
128
|
+
if (outcome === undefined || outcome === false || outcome === null)
|
|
129
|
+
return undefined;
|
|
130
|
+
return typeof outcome === 'number' ? outcome : 403;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Read a bounded JSON object body.
|
|
134
|
+
*
|
|
135
|
+
* Mirrors `readJsonRequest` in `dsh-agent-teams/src/web-routes.ts`: an oversized
|
|
136
|
+
* body stops being buffered immediately and the socket is drained, so a hostile
|
|
137
|
+
* `Content-Length` cannot pin memory in the host process.
|
|
138
|
+
*/
|
|
139
|
+
async function readJsonBody(req, maxBytes = MAX_BODY_BYTES) {
|
|
140
|
+
const raw = await new Promise((resolve, reject) => {
|
|
141
|
+
let size = 0;
|
|
142
|
+
let settled = false;
|
|
143
|
+
const chunks = [];
|
|
144
|
+
const finish = (error) => {
|
|
145
|
+
if (settled)
|
|
146
|
+
return;
|
|
147
|
+
settled = true;
|
|
148
|
+
req.off('data', onData);
|
|
149
|
+
req.off('end', onEnd);
|
|
150
|
+
req.off('aborted', onAborted);
|
|
151
|
+
req.off('error', onError);
|
|
152
|
+
if (error !== undefined) {
|
|
153
|
+
chunks.length = 0;
|
|
154
|
+
req.once('error', () => undefined);
|
|
155
|
+
req.resume();
|
|
156
|
+
reject(error);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
resolve(Buffer.concat(chunks).toString('utf8'));
|
|
160
|
+
};
|
|
161
|
+
const onData = (chunk) => {
|
|
162
|
+
const part = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
163
|
+
size += part.length;
|
|
164
|
+
if (size > maxBytes) {
|
|
165
|
+
finish(new RouteError(400, 'BAD_REQUEST', `Request body exceeds ${maxBytes} bytes`));
|
|
166
|
+
}
|
|
167
|
+
else {
|
|
168
|
+
chunks.push(part);
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
const onEnd = () => finish();
|
|
172
|
+
const onAborted = () => finish(new RouteError(400, 'BAD_REQUEST', 'Request body was aborted'));
|
|
173
|
+
const onError = () => finish(new RouteError(400, 'BAD_REQUEST', 'Invalid request body'));
|
|
174
|
+
req.on('data', onData);
|
|
175
|
+
req.once('end', onEnd);
|
|
176
|
+
req.once('aborted', onAborted);
|
|
177
|
+
req.once('error', onError);
|
|
178
|
+
});
|
|
179
|
+
let value;
|
|
180
|
+
try {
|
|
181
|
+
value = raw.trim() === '' ? {} : JSON.parse(raw);
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
throw new RouteError(400, 'BAD_REQUEST', 'Invalid JSON body');
|
|
185
|
+
}
|
|
186
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
187
|
+
throw new RouteError(400, 'BAD_REQUEST', 'Body must be a JSON object');
|
|
188
|
+
}
|
|
189
|
+
return value;
|
|
190
|
+
}
|
|
191
|
+
/** Decode the path segments below {@link ROUTE_PREFIX}, rejecting malformed encoding. */
|
|
192
|
+
function parseSegments(pathname) {
|
|
193
|
+
if (pathname !== ROUTE_PREFIX && !pathname.startsWith(`${ROUTE_PREFIX}/`)) {
|
|
194
|
+
throw new RouteError(404, 'NOT_FOUND', `Unknown route: ${pathname}`);
|
|
195
|
+
}
|
|
196
|
+
return pathname
|
|
197
|
+
.slice(ROUTE_PREFIX.length)
|
|
198
|
+
.split('/')
|
|
199
|
+
.filter((segment) => segment !== '')
|
|
200
|
+
.map((segment) => {
|
|
201
|
+
try {
|
|
202
|
+
return decodeURIComponent(segment);
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
throw new RouteError(400, 'BAD_REQUEST', 'Malformed percent-encoding in the request path');
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
function requireMethod(method, expected) {
|
|
210
|
+
if (method !== expected) {
|
|
211
|
+
throw new RouteError(405, 'METHOD_NOT_ALLOWED', `Method ${method} is not allowed here`, expected);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
/** Validate one path segment against the frozen market source list. */
|
|
215
|
+
function parseSourceSegment(segment) {
|
|
216
|
+
if (segment !== undefined && MARKET_SOURCES.includes(segment))
|
|
217
|
+
return segment;
|
|
218
|
+
throw new RouteError(400, 'BAD_REQUEST', `Invalid market source: ${segment ?? ''}`);
|
|
219
|
+
}
|
|
220
|
+
/** Reject anything that could escape a path when joined onto a URL or a file path. */
|
|
221
|
+
function parseSlug(segment) {
|
|
222
|
+
if (segment === undefined || segment === '')
|
|
223
|
+
throw new RouteError(400, 'BAD_REQUEST', 'Missing skill slug');
|
|
224
|
+
if (segment.length > MAX_SLUG_LENGTH)
|
|
225
|
+
throw new RouteError(400, 'BAD_REQUEST', 'Skill slug is too long');
|
|
226
|
+
if (segment.includes('/') || segment.includes('\\') || segment.includes('..')) {
|
|
227
|
+
throw new RouteError(400, 'BAD_REQUEST', `Invalid skill slug: ${segment}`);
|
|
228
|
+
}
|
|
229
|
+
return segment;
|
|
230
|
+
}
|
|
231
|
+
/** Bounded optional string query parameter. */
|
|
232
|
+
function optionalQuery(url, name, maxLength) {
|
|
233
|
+
const value = url.searchParams.get(name)?.trim();
|
|
234
|
+
if (value === undefined || value === '')
|
|
235
|
+
return undefined;
|
|
236
|
+
if (value.length > maxLength) {
|
|
237
|
+
throw new RouteError(400, 'BAD_REQUEST', `Query parameter "${name}" exceeds ${maxLength} characters`);
|
|
238
|
+
}
|
|
239
|
+
return value;
|
|
240
|
+
}
|
|
241
|
+
/** Closed-set query parameter with a fallback. */
|
|
242
|
+
function enumQuery(url, name, allowed, fallback) {
|
|
243
|
+
const raw = url.searchParams.get(name);
|
|
244
|
+
if (raw === null || raw === '')
|
|
245
|
+
return fallback;
|
|
246
|
+
if (allowed.includes(raw))
|
|
247
|
+
return raw;
|
|
248
|
+
throw new RouteError(400, 'BAD_REQUEST', `Invalid ${name} filter: ${raw}`);
|
|
249
|
+
}
|
|
250
|
+
/** `limit` query parameter, clamped into the documented range. */
|
|
251
|
+
function parseLimit(url) {
|
|
252
|
+
const raw = url.searchParams.get('limit');
|
|
253
|
+
if (raw === null || raw.trim() === '')
|
|
254
|
+
return defaultPageSize;
|
|
255
|
+
const parsed = Number.parseInt(raw, 10);
|
|
256
|
+
if (!Number.isFinite(parsed))
|
|
257
|
+
throw new RouteError(400, 'BAD_REQUEST', `Invalid limit: ${raw}`);
|
|
258
|
+
return Math.min(MAX_PAGE_SIZE, Math.max(1, parsed));
|
|
259
|
+
}
|
|
260
|
+
/** `{ id: "source:slug" }` body of the install/uninstall endpoints. */
|
|
261
|
+
function parseBodyId(body) {
|
|
262
|
+
const id = typeof body['id'] === 'string' ? body['id'] : '';
|
|
263
|
+
const parsed = parseSkillId(id);
|
|
264
|
+
if (parsed === null)
|
|
265
|
+
throw new RouteError(400, 'BAD_REQUEST', `Invalid skill id: ${id === '' ? '(missing)' : id}`);
|
|
266
|
+
return parsed;
|
|
267
|
+
}
|
|
268
|
+
async function handleStatus(res) {
|
|
269
|
+
sendJson(res, 200, { sources: getMarketStatus() });
|
|
270
|
+
}
|
|
271
|
+
async function handleList(url, res) {
|
|
272
|
+
const params = {
|
|
273
|
+
q: optionalQuery(url, 'q', MAX_QUERY_LENGTH),
|
|
274
|
+
source: enumQuery(url, 'source', ['all', ...MARKET_SOURCES], 'all'),
|
|
275
|
+
security: enumQuery(url, 'security', ['all', ...SECURITY_FILTERS], 'all'),
|
|
276
|
+
installed: enumQuery(url, 'installed', INSTALLED_FILTERS, 'all'),
|
|
277
|
+
cursor: optionalQuery(url, 'cursor', MAX_CURSOR_LENGTH),
|
|
278
|
+
limit: parseLimit(url),
|
|
279
|
+
};
|
|
280
|
+
const result = await listMarketSkills(params);
|
|
281
|
+
sendJson(res, 200, result);
|
|
282
|
+
}
|
|
283
|
+
async function handleDetail(source, slug, res) {
|
|
284
|
+
sendJson(res, 200, await getMarketSkillDetail(source, slug));
|
|
285
|
+
}
|
|
286
|
+
async function handleFile(source, slug, url, res) {
|
|
287
|
+
const filePath = url.searchParams.get('path') ?? '';
|
|
288
|
+
if (!isValidMarketFilePath(filePath)) {
|
|
289
|
+
throw new RouteError(400, 'BAD_REQUEST', `Invalid file path: ${filePath}`);
|
|
290
|
+
}
|
|
291
|
+
sendJson(res, 200, { file: await getMarketFileContent(source, slug, filePath) });
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* List installed skills from disk.
|
|
295
|
+
*
|
|
296
|
+
* The frozen deps surface exposes the resolved root rather than the in-memory
|
|
297
|
+
* index, so the list is produced by scanning the same roots the lookup uses:
|
|
298
|
+
* disk is the single source of truth, and a skill the user copied in by hand
|
|
299
|
+
* shows up next to the ones this plugin installed.
|
|
300
|
+
*/
|
|
301
|
+
async function handleInstalled(deps, res) {
|
|
302
|
+
const roots = await resolveSkillsScanRoots(await deps.skillsRoot());
|
|
303
|
+
sendJson(res, 200, { items: (await scanInstalledSkills(roots)).map(item => ({ ...item, removable: deps.allowUninstall() })) });
|
|
304
|
+
}
|
|
305
|
+
async function handleInstall(req, deps, res) {
|
|
306
|
+
const { source, slug } = parseBodyId(await readJsonBody(req));
|
|
307
|
+
const result = await installMarketSkill(source, slug, {
|
|
308
|
+
skillsRoot: await deps.skillsRoot(),
|
|
309
|
+
allowUninstall: deps.allowUninstall(),
|
|
310
|
+
});
|
|
311
|
+
// Refresh before answering, so the next /skills or /installed call is correct.
|
|
312
|
+
await deps.rescan().catch(() => undefined);
|
|
313
|
+
sendJson(res, 200, { ok: true, installedPath: result.installedPath, skill: result.skill });
|
|
314
|
+
}
|
|
315
|
+
async function handleUninstall(req, deps, res) {
|
|
316
|
+
const { source, slug } = parseBodyId(await readJsonBody(req));
|
|
317
|
+
const result = await uninstallMarketSkill(source, slug, {
|
|
318
|
+
skillsRoot: await deps.skillsRoot(),
|
|
319
|
+
allowUninstall: deps.allowUninstall(),
|
|
320
|
+
});
|
|
321
|
+
await deps.rescan().catch(() => undefined);
|
|
322
|
+
sendJson(res, 200, { ok: true, removedPath: result.removedPath, skill: result.skill });
|
|
323
|
+
}
|
|
324
|
+
async function handle(req, res, gate, deps) {
|
|
325
|
+
// Raw Web routes bypass the Connection fence; ask it before doing any work.
|
|
326
|
+
// The source is resolved per request: the Connection service can be
|
|
327
|
+
// contributed after this route was registered (Loader rows activate
|
|
328
|
+
// concurrently), and a gate captured once would silently stay absent —
|
|
329
|
+
// leaving the whole marketplace readable and installable from any web page.
|
|
330
|
+
const resolvedGate = gate();
|
|
331
|
+
if (resolvedGate !== undefined) {
|
|
332
|
+
const rejection = evaluateGate(resolvedGate, req, res);
|
|
333
|
+
if (rejection !== undefined) {
|
|
334
|
+
sendJson(res, rejection, errorPayload(rejection === 401 ? 'UNAUTHORIZED' : 'FORBIDDEN', rejection === 401 ? 'unauthorized' : 'forbidden'));
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
339
|
+
const segments = parseSegments(url.pathname);
|
|
340
|
+
const method = req.method ?? 'GET';
|
|
341
|
+
const [head, second, third, fourth] = segments;
|
|
342
|
+
if (head === 'status' && segments.length === 1) {
|
|
343
|
+
requireMethod(method, 'GET');
|
|
344
|
+
await handleStatus(res);
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
if (head === 'installed' && segments.length === 2 && second === 'detail') {
|
|
348
|
+
requireMethod(method, 'GET');
|
|
349
|
+
const roots = await resolveSkillsScanRoots(await deps.skillsRoot());
|
|
350
|
+
sendJson(res, 200, await readInstalledSkill(roots, url.searchParams.get('key') ?? ''));
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
if (head === 'installed' && segments.length === 2 && second === 'uninstall') {
|
|
354
|
+
requireMethod(method, 'POST');
|
|
355
|
+
const body = await readJsonBody(req);
|
|
356
|
+
const roots = await resolveSkillsScanRoots(await deps.skillsRoot());
|
|
357
|
+
const item = await removeInstalledSkill(roots, typeof body['key'] === 'string' ? body['key'] : '', deps.allowUninstall());
|
|
358
|
+
await deps.rescan().catch(() => undefined);
|
|
359
|
+
sendJson(res, 200, { ok: true, removedPath: item.dirPath, item });
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
if (head === 'installed' && segments.length === 1) {
|
|
363
|
+
requireMethod(method, 'GET');
|
|
364
|
+
await handleInstalled(deps, res);
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
if (head === 'install' && segments.length === 1) {
|
|
368
|
+
requireMethod(method, 'POST');
|
|
369
|
+
await handleInstall(req, deps, res);
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
if (head === 'uninstall' && segments.length === 1) {
|
|
373
|
+
requireMethod(method, 'POST');
|
|
374
|
+
await handleUninstall(req, deps, res);
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
if (head === 'skills') {
|
|
378
|
+
if (segments.length === 1) {
|
|
379
|
+
requireMethod(method, 'GET');
|
|
380
|
+
await handleList(url, res);
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
// Shape first: an unknown depth stays a 404 even when the segments that
|
|
384
|
+
// would have been parsed happen to be invalid.
|
|
385
|
+
if (segments.length === 3 || (segments.length === 4 && fourth === 'file')) {
|
|
386
|
+
const source = parseSourceSegment(second);
|
|
387
|
+
const slug = parseSlug(third);
|
|
388
|
+
requireMethod(method, 'GET');
|
|
389
|
+
if (segments.length === 3) {
|
|
390
|
+
await handleDetail(source, slug, res);
|
|
391
|
+
}
|
|
392
|
+
else {
|
|
393
|
+
await handleFile(source, slug, url, res);
|
|
394
|
+
}
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
throw new RouteError(404, 'NOT_FOUND', `Unknown route: ${url.pathname}`);
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* Register the Skills Hub prefix route.
|
|
402
|
+
*
|
|
403
|
+
* @param webServer - the `webServer`/`httpServer` service (`ctx.get` guarded by the caller).
|
|
404
|
+
* @param gate - resolves the browser-trust gate (`ctx.get('connection')`) *per
|
|
405
|
+
* request*; `undefined` means the composition has no Connection fence to
|
|
406
|
+
* consult. It is a function rather than a snapshot because the Connection row
|
|
407
|
+
* may activate after this route is registered.
|
|
408
|
+
* @param deps - resolved skills root, the uninstall switch, and the index refresh.
|
|
409
|
+
* @returns the route disposer (yield it from `ctx.effect`).
|
|
410
|
+
*/
|
|
411
|
+
export function registerSkillsHubRoutes(webServer, gate, deps) {
|
|
412
|
+
return webServer.register({
|
|
413
|
+
kind: 'prefix',
|
|
414
|
+
path: ROUTE_PREFIX,
|
|
415
|
+
handler: async (req, res) => {
|
|
416
|
+
try {
|
|
417
|
+
await handle(req, res, gate, deps);
|
|
418
|
+
}
|
|
419
|
+
catch (error) {
|
|
420
|
+
// Every rejection becomes the documented envelope: the transport keeps
|
|
421
|
+
// working for the next request instead of letting the web server write
|
|
422
|
+
// its own bodyless 400.
|
|
423
|
+
sendFailure(res, error);
|
|
424
|
+
}
|
|
425
|
+
},
|
|
426
|
+
});
|
|
427
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nanmicoder/dsh-skills-hub",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Skills Hub for DeepSeek Harness: browse, preview, install and remove third-party skills from ClawHub and SkillHub inside the DSH Web GUI.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "lib/index.js",
|
|
7
|
+
"types": "lib/types/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./lib/types/index.d.ts",
|
|
11
|
+
"default": "./lib/index.js"
|
|
12
|
+
},
|
|
13
|
+
"./client": {
|
|
14
|
+
"types": "./lib/types/client/index.d.ts",
|
|
15
|
+
"default": "./lib/client.js"
|
|
16
|
+
},
|
|
17
|
+
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
18
|
+
"./package.json": "./package.json"
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"lib",
|
|
22
|
+
"!lib/client",
|
|
23
|
+
"cordis.patch.yml",
|
|
24
|
+
"README.md",
|
|
25
|
+
"LICENSE",
|
|
26
|
+
"README_ZH.md",
|
|
27
|
+
"README_EN.md",
|
|
28
|
+
"assets/readme/*.png",
|
|
29
|
+
"compatibility.json",
|
|
30
|
+
"release-notes",
|
|
31
|
+
"docs/*.md",
|
|
32
|
+
"CONTRIBUTING.md",
|
|
33
|
+
"AGENTS.md"
|
|
34
|
+
],
|
|
35
|
+
"license": "MIT",
|
|
36
|
+
"engines": {
|
|
37
|
+
"node": "^22.19.0 || >=24"
|
|
38
|
+
},
|
|
39
|
+
"dsh": {
|
|
40
|
+
"bundle": {
|
|
41
|
+
"patch": "./cordis.patch.yml"
|
|
42
|
+
},
|
|
43
|
+
"client": {
|
|
44
|
+
"platform": "web",
|
|
45
|
+
"inject": [
|
|
46
|
+
"@deepseek-ai/dsh-client-locale",
|
|
47
|
+
"@deepseek-ai/dsh-client-ui-layout",
|
|
48
|
+
"@deepseek-ai/dsh-client-ui-primitives",
|
|
49
|
+
"@deepseek-ai/dsh-client-ui-slots"
|
|
50
|
+
]
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
"scripts": {
|
|
54
|
+
"build": "node scripts/clean-build.mjs && tsc -p tsconfig.json && tsc -p tsconfig.client.json && tsdown",
|
|
55
|
+
"typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.client.json --noEmit",
|
|
56
|
+
"test": "node --test tests/*.test.mjs",
|
|
57
|
+
"verify": "node scripts/verify.mjs",
|
|
58
|
+
"verify:release": "node scripts/release-check.mjs",
|
|
59
|
+
"check": "pnpm typecheck && pnpm build && pnpm verify && pnpm verify:release",
|
|
60
|
+
"prepack": "pnpm check",
|
|
61
|
+
"prepublishOnly": "pnpm check",
|
|
62
|
+
"pack:check": "pnpm check && node scripts/pack-check.mjs"
|
|
63
|
+
},
|
|
64
|
+
"peerDependencies": {
|
|
65
|
+
"@deepseek-ai/cordis": "^4.0.2",
|
|
66
|
+
"@deepseek-ai/dsh-client-connection": "0.2.0-rc.2",
|
|
67
|
+
"@deepseek-ai/dsh-client-locale": "0.2.0-rc.2",
|
|
68
|
+
"@deepseek-ai/dsh-client-ui-layout": "0.2.0-rc.2",
|
|
69
|
+
"@deepseek-ai/dsh-client-ui-primitives": "0.2.0-rc.2",
|
|
70
|
+
"@deepseek-ai/dsh-client-ui-slots": "0.2.0-rc.2",
|
|
71
|
+
"@deepseek-ai/dsh-host-webserver": "0.2.0-rc.2",
|
|
72
|
+
"@deepseek-ai/schemastery": "^3.18.2",
|
|
73
|
+
"react": "^18.2.0"
|
|
74
|
+
},
|
|
75
|
+
"peerDependenciesMeta": {
|
|
76
|
+
"@deepseek-ai/cordis": {
|
|
77
|
+
"optional": true
|
|
78
|
+
},
|
|
79
|
+
"@deepseek-ai/dsh-client-connection": {
|
|
80
|
+
"optional": true
|
|
81
|
+
},
|
|
82
|
+
"@deepseek-ai/dsh-client-locale": {
|
|
83
|
+
"optional": true
|
|
84
|
+
},
|
|
85
|
+
"@deepseek-ai/dsh-client-ui-layout": {
|
|
86
|
+
"optional": true
|
|
87
|
+
},
|
|
88
|
+
"@deepseek-ai/dsh-client-ui-primitives": {
|
|
89
|
+
"optional": true
|
|
90
|
+
},
|
|
91
|
+
"@deepseek-ai/dsh-client-ui-slots": {
|
|
92
|
+
"optional": true
|
|
93
|
+
},
|
|
94
|
+
"@deepseek-ai/dsh-host-webserver": {
|
|
95
|
+
"optional": true
|
|
96
|
+
},
|
|
97
|
+
"@deepseek-ai/schemastery": {
|
|
98
|
+
"optional": true
|
|
99
|
+
},
|
|
100
|
+
"react": {
|
|
101
|
+
"optional": true
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
"devDependencies": {
|
|
105
|
+
"@deepseek-ai/cordis": "4.0.4",
|
|
106
|
+
"@deepseek-ai/dsh-client-connection": "0.2.0-rc.2",
|
|
107
|
+
"@deepseek-ai/dsh-client-locale": "0.2.0-rc.2",
|
|
108
|
+
"@deepseek-ai/dsh-client-store": "0.2.0-rc.2",
|
|
109
|
+
"@deepseek-ai/dsh-client-ui-layout": "0.2.0-rc.2",
|
|
110
|
+
"@deepseek-ai/dsh-client-ui-primitives": "0.2.0-rc.2",
|
|
111
|
+
"@deepseek-ai/dsh-client-ui-slots": "0.2.0-rc.2",
|
|
112
|
+
"@deepseek-ai/dsh-fs": "0.2.0-rc.2",
|
|
113
|
+
"@deepseek-ai/dsh-home-paths": "0.2.0-rc.2",
|
|
114
|
+
"@deepseek-ai/dsh-host-webserver": "0.2.0-rc.2",
|
|
115
|
+
"@deepseek-ai/dsh-invariants": "0.2.0-rc.2",
|
|
116
|
+
"@deepseek-ai/dsh-llm": "0.2.0-rc.2",
|
|
117
|
+
"@deepseek-ai/dsh-sandbox": "0.2.0-rc.2",
|
|
118
|
+
"@deepseek-ai/dsh-scope": "0.2.0-rc.2",
|
|
119
|
+
"@deepseek-ai/dsh-session": "0.2.0-rc.2",
|
|
120
|
+
"@deepseek-ai/dsh-skill": "0.2.0-rc.2",
|
|
121
|
+
"@deepseek-ai/dsh-skill-filesystem": "0.2.0-rc.2",
|
|
122
|
+
"@deepseek-ai/schemastery": "3.18.4",
|
|
123
|
+
"@types/node": "^24.13.3",
|
|
124
|
+
"@types/react": "~18.3.1",
|
|
125
|
+
"@types/react-dom": "^18.3.7",
|
|
126
|
+
"lightningcss": "^1.33.0",
|
|
127
|
+
"react": "^18.2.0",
|
|
128
|
+
"react-dom": "^18.2.0",
|
|
129
|
+
"react-markdown": "^10.1.0",
|
|
130
|
+
"remark-gfm": "^4.0.1",
|
|
131
|
+
"semver": "^7.7.4",
|
|
132
|
+
"tsdown": "0.22.2",
|
|
133
|
+
"typescript": "^5.9.3"
|
|
134
|
+
},
|
|
135
|
+
"packageManager": "pnpm@10.33.0",
|
|
136
|
+
"author": {
|
|
137
|
+
"name": "程序员阿江 (Relakkes)",
|
|
138
|
+
"url": "https://github.com/NanmiCoder"
|
|
139
|
+
},
|
|
140
|
+
"homepage": "https://github.com/NanmiCoder/dsh-skills-hub#readme",
|
|
141
|
+
"repository": {
|
|
142
|
+
"type": "git",
|
|
143
|
+
"url": "git+https://github.com/NanmiCoder/dsh-skills-hub.git"
|
|
144
|
+
},
|
|
145
|
+
"bugs": {
|
|
146
|
+
"url": "https://github.com/NanmiCoder/dsh-skills-hub/issues"
|
|
147
|
+
},
|
|
148
|
+
"keywords": [
|
|
149
|
+
"dsh",
|
|
150
|
+
"dsh-plugin",
|
|
151
|
+
"deepseek-harness",
|
|
152
|
+
"skills",
|
|
153
|
+
"marketplace"
|
|
154
|
+
],
|
|
155
|
+
"publishConfig": {
|
|
156
|
+
"access": "public",
|
|
157
|
+
"tag": "latest",
|
|
158
|
+
"registry": "https://registry.npmjs.org/"
|
|
159
|
+
}
|
|
160
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Skills Hub 0.0.1
|
|
2
|
+
|
|
3
|
+
Initial release of `@nanmicoder/dsh-skills-hub` for DeepSeek Harness's 0.2.0 desktop line (exact package baseline: `0.2.0-rc.2`).
|
|
4
|
+
|
|
5
|
+
- ClawHub and SkillHub browsing, search, deduplication and source health.
|
|
6
|
+
- Skeleton loading for marketplace and skill details, bundled Markdown rendering and automatic scroll pagination.
|
|
7
|
+
- Marketplace installation, local skill discovery and an Installed management view with confirmed removal.
|
|
8
|
+
- Atomic installation, existing-directory conflict protection and restricted filesystem removal.
|
|
9
|
+
- Scoped npm package, bilingual documentation, pinned compatibility metadata and reproducible build/package/release checks.
|
|
10
|
+
|
|
11
|
+
Install: `dsh plugin --profile desktop add @nanmicoder/dsh-skills-hub@0.0.1`, then fully restart the desktop app. Installing the plugin does not migrate or delete existing skills.
|