minionsai 0.1.13 → 0.1.14

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.
@@ -1,13 +1,821 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { constants as fsConstants, mkdirSync } from 'node:fs';
3
+ import { access, mkdir, readFile, readdir, rename, rm, unlink, writeFile } from 'node:fs/promises';
4
+ import { homedir, tmpdir } from 'node:os';
5
+ import { basename, dirname, extname, join, relative, resolve, sep } from 'node:path';
6
+ import multer from 'multer';
7
+ import yauzl from 'yauzl';
8
+ import { parseDocument } from 'yaml';
1
9
  import { Router } from 'express';
2
- import { getBundledSkillWithContent, listBundledSkills, toSkillMeta } from '../skills/catalog.js';
10
+ import { resolveHermesHome, resolveMinionsSkillsDir } from '../paths.js';
11
+ const CLAWHUB_API_BASE = 'https://clawhub.ai/api/v1';
12
+ const SIDECAR_FILENAME = '.minions-skill.json';
13
+ const MAX_SKILL_FILES = 250;
14
+ const MAX_SKILL_FILE_BYTES = 5 * 1024 * 1024;
15
+ const MAX_SKILL_TOTAL_BYTES = 25 * 1024 * 1024;
16
+ const SKILL_IMPORT_TMP_DIR = join(tmpdir(), 'minions-skill-imports');
17
+ mkdirSync(SKILL_IMPORT_TMP_DIR, { recursive: true });
18
+ const skillImportUploadMiddleware = multer({
19
+ storage: multer.diskStorage({
20
+ destination: SKILL_IMPORT_TMP_DIR,
21
+ filename: (_req, file, callback) => {
22
+ callback(null, `${Date.now()}-${randomUUID()}-${basename(file.originalname)}`);
23
+ },
24
+ }),
25
+ limits: {
26
+ files: MAX_SKILL_FILES,
27
+ fileSize: MAX_SKILL_FILE_BYTES,
28
+ },
29
+ }).array('files');
3
30
  export const skillsRouter = Router();
31
+ class RouteError extends Error {
32
+ status;
33
+ code;
34
+ constructor(status, message, code = 'SKILLS_ERROR') {
35
+ super(message);
36
+ this.status = status;
37
+ this.code = code;
38
+ this.name = 'RouteError';
39
+ }
40
+ }
41
+ function sendError(res, error, fallback) {
42
+ if (error instanceof RouteError) {
43
+ res.status(error.status).json({ error: error.message, code: error.code });
44
+ return;
45
+ }
46
+ console.error(fallback, error);
47
+ const message = error instanceof Error ? error.message : fallback;
48
+ res.status(500).json({ error: message, code: 'SKILLS_ERROR' });
49
+ }
50
+ function isRecord(value) {
51
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
52
+ }
53
+ function stringValue(value) {
54
+ return typeof value === 'string' && value.trim() ? value.trim() : undefined;
55
+ }
56
+ function numberValue(value) {
57
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
58
+ }
59
+ function stringArrayFromField(value) {
60
+ if (value === undefined)
61
+ return [];
62
+ if (typeof value === 'string')
63
+ return [value];
64
+ if (Array.isArray(value) && value.every((item) => typeof item === 'string'))
65
+ return value;
66
+ throw new RouteError(400, 'Upload paths must be strings.', 'INVALID_UPLOAD_PATHS');
67
+ }
68
+ function ensureSafeSlug(value) {
69
+ const slug = stringValue(value);
70
+ if (!slug || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(slug)) {
71
+ throw new RouteError(400, 'Invalid ClawHub skill slug.', 'INVALID_SKILL_SLUG');
72
+ }
73
+ return slug;
74
+ }
75
+ function clampLimit(value, fallback, max = 100) {
76
+ const parsed = typeof value === 'string'
77
+ ? Number.parseInt(value, 10)
78
+ : typeof value === 'number'
79
+ ? value
80
+ : NaN;
81
+ if (!Number.isFinite(parsed) || parsed <= 0)
82
+ return fallback;
83
+ return Math.min(Math.trunc(parsed), max);
84
+ }
85
+ function normalizeRelativePath(value, label) {
86
+ if (value.includes('\0')) {
87
+ throw new RouteError(400, `Unsafe ${label}.`, 'UNSAFE_SKILL_PATH');
88
+ }
89
+ const normalized = value.trim().replace(/\\/g, '/');
90
+ const parts = normalized.split('/').filter(Boolean);
91
+ if (!parts.length
92
+ || normalized.startsWith('/')
93
+ || /^[A-Za-z]:$/.test(parts[0])
94
+ || parts.some((part) => part === '.' || part === '..')) {
95
+ throw new RouteError(400, `Unsafe ${label}.`, 'UNSAFE_SKILL_PATH');
96
+ }
97
+ return parts.join('/');
98
+ }
99
+ function ensureInside(root, target) {
100
+ const resolvedRoot = resolve(root);
101
+ const resolvedTarget = resolve(target);
102
+ if (resolvedTarget !== resolvedRoot && !resolvedTarget.startsWith(`${resolvedRoot}${sep}`)) {
103
+ throw new RouteError(400, 'Resolved path escapes skills directory.', 'UNSAFE_SKILL_PATH');
104
+ }
105
+ }
106
+ function normalizeSkillId(id) {
107
+ return normalizeRelativePath(id, 'skill id');
108
+ }
109
+ function isIgnoredSkillArchivePath(path) {
110
+ return path === '.DS_Store'
111
+ || path.startsWith('__MACOSX/')
112
+ || path.split('/').some((part) => part === '.DS_Store');
113
+ }
114
+ function skillRootFromFiles(files) {
115
+ if (files.has('SKILL.md'))
116
+ return { prefix: '' };
117
+ const skillFiles = [...files.keys()]
118
+ .filter((path) => basename(path) === 'SKILL.md')
119
+ .sort((a, b) => a.split('/').length - b.split('/').length || a.localeCompare(b));
120
+ if (skillFiles.length === 0) {
121
+ throw new RouteError(422, 'Skill bundle is missing a root SKILL.md file.', 'INVALID_SKILL_BUNDLE');
122
+ }
123
+ const root = dirname(skillFiles[0]).split(sep).join('/');
124
+ return {
125
+ prefix: `${root}/`,
126
+ rootName: root.split('/').pop(),
127
+ };
128
+ }
129
+ function prepareSkillFiles(files) {
130
+ const { prefix, rootName } = skillRootFromFiles(files);
131
+ const prepared = new Map();
132
+ for (const [path, content] of files) {
133
+ if (prefix && !path.startsWith(prefix))
134
+ continue;
135
+ const relPath = prefix ? path.slice(prefix.length) : path;
136
+ if (!relPath || relPath === SIDECAR_FILENAME || isIgnoredSkillArchivePath(relPath))
137
+ continue;
138
+ prepared.set(relPath, content);
139
+ }
140
+ if (!prepared.has('SKILL.md')) {
141
+ throw new RouteError(422, 'Skill bundle is missing a root SKILL.md file.', 'INVALID_SKILL_BUNDLE');
142
+ }
143
+ return { files: prepared, rootName };
144
+ }
145
+ function slugifySkillDirectoryName(value, fallback) {
146
+ const slug = (value || fallback)
147
+ .toLowerCase()
148
+ .replace(/[^a-z0-9._-]+/g, '-')
149
+ .replace(/^-+|-+$/g, '')
150
+ .slice(0, 80);
151
+ return slug || fallback;
152
+ }
153
+ async function uniqueLocalSkillDestination(root, baseSlug) {
154
+ let slug = baseSlug;
155
+ let index = 2;
156
+ let destination = resolve(root, 'local', slug);
157
+ while (await pathExists(destination)) {
158
+ slug = `${baseSlug}-${index}`;
159
+ destination = resolve(root, 'local', slug);
160
+ index += 1;
161
+ }
162
+ ensureInside(root, destination);
163
+ return destination;
164
+ }
165
+ async function pathExists(path) {
166
+ try {
167
+ await access(path, fsConstants.F_OK);
168
+ return true;
169
+ }
170
+ catch {
171
+ return false;
172
+ }
173
+ }
174
+ function parseFrontmatter(content) {
175
+ const match = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(content);
176
+ if (!match)
177
+ return {};
178
+ const frontmatter = {};
179
+ for (const line of match[1].split(/\r?\n/)) {
180
+ const item = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(line);
181
+ if (!item)
182
+ continue;
183
+ const key = item[1];
184
+ const value = item[2].trim().replace(/^['"]|['"]$/g, '');
185
+ if (key === 'name' && value)
186
+ frontmatter.name = value;
187
+ if (key === 'description' && value)
188
+ frontmatter.description = value;
189
+ }
190
+ return frontmatter;
191
+ }
192
+ async function readJsonFile(path) {
193
+ try {
194
+ return JSON.parse(await readFile(path, 'utf8'));
195
+ }
196
+ catch {
197
+ return null;
198
+ }
199
+ }
200
+ function skillIdFromFile(root, skillFile) {
201
+ return relative(root, dirname(skillFile)).split(sep).join('/');
202
+ }
203
+ async function readInstalledSkill(skillFile, root = resolveMinionsSkillsDir()) {
204
+ const content = await readFile(skillFile, 'utf8');
205
+ const frontmatter = parseFrontmatter(content);
206
+ const skillDir = dirname(skillFile);
207
+ const id = skillIdFromFile(root, skillFile);
208
+ const sidecar = await readJsonFile(join(skillDir, SIDECAR_FILENAME));
209
+ const fallbackName = basename(skillDir);
210
+ const provider = sidecar?.provider;
211
+ const registrySlug = sidecar?.registrySlug ?? (id.startsWith('clawhub/') ? id.slice('clawhub/'.length).split('/')[0] : undefined);
212
+ return {
213
+ id,
214
+ name: sidecar?.displayName || frontmatter.name || registrySlug || fallbackName,
215
+ description: frontmatter.description || sidecar?.summary || '',
216
+ key: frontmatter.name || registrySlug || fallbackName,
217
+ source: provider === 'clawhub' ? 'ClawHub' : 'Local',
218
+ provider,
219
+ registrySlug,
220
+ version: sidecar?.version,
221
+ installedAt: sidecar?.installedAt,
222
+ };
223
+ }
224
+ async function findSkillFiles(dir, found = []) {
225
+ if (!await pathExists(dir))
226
+ return found;
227
+ const entries = await readdir(dir, { withFileTypes: true });
228
+ // A directory containing SKILL.md *is* a skill; everything beneath it belongs
229
+ // to that skill, so record it and stop descending. Recursing deeper would
230
+ // register nested SKILL.md files (e.g. references/, examples/) as phantom
231
+ // skills — and deleting such a phantom would rm -rf a real skill's subtree.
232
+ if (entries.some((entry) => entry.isFile() && entry.name === 'SKILL.md')) {
233
+ found.push(join(dir, 'SKILL.md'));
234
+ return found;
235
+ }
236
+ for (const entry of entries) {
237
+ if (entry.name.startsWith('.'))
238
+ continue;
239
+ if (entry.isDirectory()) {
240
+ await findSkillFiles(join(dir, entry.name), found);
241
+ }
242
+ }
243
+ return found;
244
+ }
245
+ async function listInstalledSkills() {
246
+ const root = resolveMinionsSkillsDir();
247
+ await mkdir(root, { recursive: true });
248
+ const files = await findSkillFiles(root);
249
+ const skills = await Promise.all(files.map((file) => readInstalledSkill(file, root)));
250
+ return skills.sort((a, b) => (a.source.localeCompare(b.source)
251
+ || a.name.localeCompare(b.name)
252
+ || a.id.localeCompare(b.id)));
253
+ }
254
+ function resolveInstalledSkillFile(id) {
255
+ const root = resolveMinionsSkillsDir();
256
+ const relId = normalizeSkillId(id);
257
+ const skillFile = resolve(root, relId, 'SKILL.md');
258
+ ensureInside(root, skillFile);
259
+ return skillFile;
260
+ }
261
+ async function deleteInstalledSkill(id) {
262
+ const root = resolveMinionsSkillsDir();
263
+ const skillFile = resolveInstalledSkillFile(id);
264
+ if (!await pathExists(skillFile)) {
265
+ throw new RouteError(404, `Skill '${id}' is not installed`, 'SKILL_NOT_FOUND');
266
+ }
267
+ const skill = await readInstalledSkill(skillFile, root);
268
+ await rm(dirname(skillFile), { recursive: true, force: true });
269
+ return skill;
270
+ }
271
+ async function fetchClawHubJson(path, params) {
272
+ const url = new URL(`${CLAWHUB_API_BASE}${path}`);
273
+ for (const [key, value] of Object.entries(params ?? {})) {
274
+ if (value !== undefined)
275
+ url.searchParams.set(key, String(value));
276
+ }
277
+ const response = await fetch(url, { headers: { accept: 'application/json' } });
278
+ if (!response.ok) {
279
+ const text = await response.text().catch(() => '');
280
+ throw new RouteError(response.status >= 500 ? 502 : response.status, text || `ClawHub returned HTTP ${response.status}.`, 'CLAWHUB_REQUEST_FAILED');
281
+ }
282
+ return response.json();
283
+ }
284
+ function statsValue(value) {
285
+ if (!isRecord(value))
286
+ return null;
287
+ return {
288
+ installsAllTime: numberValue(value.installsAllTime),
289
+ downloads: numberValue(value.downloads),
290
+ installsCurrent: numberValue(value.installsCurrent),
291
+ stars: numberValue(value.stars),
292
+ };
293
+ }
294
+ function skillSummaryValue(value) {
295
+ if (!isRecord(value))
296
+ return null;
297
+ const slug = stringValue(value.slug);
298
+ if (!slug)
299
+ return null;
300
+ const tags = isRecord(value.tags) ? value.tags : undefined;
301
+ const latestVersion = isRecord(value.latestVersion) ? value.latestVersion : undefined;
302
+ return {
303
+ slug,
304
+ displayName: stringValue(value.displayName) || stringValue(value.name) || slug,
305
+ summary: stringValue(value.summary) || stringValue(value.description) || '',
306
+ version: stringValue(value.version) ?? null,
307
+ latestVersion: stringValue(latestVersion?.version) ?? stringValue(tags?.latest) ?? null,
308
+ updatedAt: numberValue(value.updatedAt) ?? null,
309
+ stats: statsValue(value.stats),
310
+ };
311
+ }
312
+ function registrySummaries(data) {
313
+ if (!isRecord(data))
314
+ return [];
315
+ const list = Array.isArray(data.results)
316
+ ? data.results
317
+ : Array.isArray(data.items)
318
+ ? data.items
319
+ : [];
320
+ return list.flatMap((item) => {
321
+ const summary = skillSummaryValue(item);
322
+ return summary ? [summary] : [];
323
+ });
324
+ }
325
+ function resolveVersion(detail, requestedVersion) {
326
+ if (requestedVersion && requestedVersion !== 'latest')
327
+ return requestedVersion;
328
+ if (!isRecord(detail)) {
329
+ throw new RouteError(502, 'ClawHub returned an invalid skill payload.', 'CLAWHUB_BAD_RESPONSE');
330
+ }
331
+ const skill = isRecord(detail.skill) ? detail.skill : detail;
332
+ const tags = isRecord(skill.tags) ? skill.tags : undefined;
333
+ const latestVersion = isRecord(detail.latestVersion) ? detail.latestVersion : undefined;
334
+ const version = stringValue(tags?.latest) || stringValue(latestVersion?.version);
335
+ if (!version) {
336
+ throw new RouteError(502, 'ClawHub did not provide a latest version for this skill.', 'CLAWHUB_BAD_RESPONSE');
337
+ }
338
+ return version;
339
+ }
340
+ function skillPayload(detail) {
341
+ if (!isRecord(detail))
342
+ return {};
343
+ return isRecord(detail.skill) ? detail.skill : detail;
344
+ }
345
+ function fileEntriesFromPayload(payload) {
346
+ // The /skills/:slug/versions/:version endpoint nests the file list under
347
+ // `version.files`; fall back to a top-level `files` array for resilience.
348
+ const container = isRecord(payload)
349
+ ? (isRecord(payload.version) && Array.isArray(payload.version.files)
350
+ ? payload.version.files
351
+ : Array.isArray(payload.files)
352
+ ? payload.files
353
+ : [])
354
+ : [];
355
+ return container.flatMap((item) => {
356
+ if (!isRecord(item))
357
+ return [];
358
+ const path = stringValue(item.path);
359
+ if (!path)
360
+ return [];
361
+ return [{
362
+ path,
363
+ size: numberValue(item.size),
364
+ sha256: stringValue(item.sha256),
365
+ content: typeof item.content === 'string' ? item.content : undefined,
366
+ }];
367
+ });
368
+ }
369
+ async function fetchClawHubFile(slug, filePath, version) {
370
+ const url = new URL(`${CLAWHUB_API_BASE}/skills/${encodeURIComponent(slug)}/file`);
371
+ url.searchParams.set('path', filePath);
372
+ url.searchParams.set('version', version);
373
+ const response = await fetch(url, { headers: { accept: '*/*' } });
374
+ if (!response.ok) {
375
+ const text = await response.text().catch(() => '');
376
+ throw new RouteError(response.status >= 500 ? 502 : response.status, text || `ClawHub returned HTTP ${response.status} while fetching ${filePath}.`, 'CLAWHUB_REQUEST_FAILED');
377
+ }
378
+ return Buffer.from(await response.arrayBuffer());
379
+ }
380
+ async function fetchClawHubSkillMarkdown(slug, version) {
381
+ const url = new URL(`${CLAWHUB_API_BASE}/skills/${encodeURIComponent(slug)}/file`);
382
+ url.searchParams.set('path', 'SKILL.md');
383
+ if (version)
384
+ url.searchParams.set('version', version);
385
+ else
386
+ url.searchParams.set('tag', 'latest');
387
+ const response = await fetch(url, { headers: { accept: 'text/markdown, text/plain, */*' } });
388
+ if (!response.ok) {
389
+ const text = await response.text().catch(() => '');
390
+ throw new RouteError(response.status >= 500 ? 502 : response.status, text || `ClawHub returned HTTP ${response.status}.`, 'CLAWHUB_REQUEST_FAILED');
391
+ }
392
+ return response.text();
393
+ }
394
+ function assertFileSize(path, size) {
395
+ if (size > MAX_SKILL_FILE_BYTES) {
396
+ throw new RouteError(413, `Skill file ${path} is too large.`, 'SKILL_FILE_TOO_LARGE');
397
+ }
398
+ }
399
+ function openZip(path) {
400
+ return new Promise((resolveZip, reject) => {
401
+ yauzl.open(path, { lazyEntries: true, validateEntrySizes: true }, (error, zipFile) => {
402
+ if (error) {
403
+ reject(error);
404
+ return;
405
+ }
406
+ if (!zipFile) {
407
+ reject(new RouteError(422, 'Could not open skill zip file.', 'INVALID_SKILL_ZIP'));
408
+ return;
409
+ }
410
+ resolveZip(zipFile);
411
+ });
412
+ });
413
+ }
414
+ function readZipEntry(zipFile, entry) {
415
+ return new Promise((resolveBuffer, reject) => {
416
+ zipFile.openReadStream(entry, (error, stream) => {
417
+ if (error) {
418
+ reject(error);
419
+ return;
420
+ }
421
+ if (!stream) {
422
+ reject(new RouteError(422, `Could not read ${entry.fileName} from skill zip file.`, 'INVALID_SKILL_ZIP'));
423
+ return;
424
+ }
425
+ const chunks = [];
426
+ stream.on('data', (chunk) => {
427
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
428
+ });
429
+ stream.on('error', reject);
430
+ stream.on('end', () => resolveBuffer(Buffer.concat(chunks)));
431
+ });
432
+ });
433
+ }
434
+ async function readZipSkillFiles(zipPath) {
435
+ const zipFile = await openZip(zipPath);
436
+ try {
437
+ return await new Promise((resolveFiles, reject) => {
438
+ const files = new Map();
439
+ let totalBytes = 0;
440
+ let completed = false;
441
+ const fail = (error) => {
442
+ if (completed)
443
+ return;
444
+ completed = true;
445
+ reject(error);
446
+ };
447
+ zipFile.on('entry', (entry) => {
448
+ void (async () => {
449
+ try {
450
+ if (entry.fileName.endsWith('/')) {
451
+ zipFile.readEntry();
452
+ return;
453
+ }
454
+ const relPath = normalizeRelativePath(entry.fileName, 'zip file path');
455
+ if (isIgnoredSkillArchivePath(relPath)) {
456
+ zipFile.readEntry();
457
+ return;
458
+ }
459
+ if (files.has(relPath)) {
460
+ throw new RouteError(400, `Duplicate skill file path ${relPath}.`, 'DUPLICATE_SKILL_FILE');
461
+ }
462
+ if (files.size + 1 > MAX_SKILL_FILES) {
463
+ throw new RouteError(413, 'Skill contains too many files.', 'SKILL_TOO_LARGE');
464
+ }
465
+ // yauzl is opened with validateEntrySizes, so the declared
466
+ // uncompressedSize is enforced against the actual stream length.
467
+ assertFileSize(relPath, entry.uncompressedSize);
468
+ const content = await readZipEntry(zipFile, entry);
469
+ totalBytes += content.byteLength;
470
+ if (totalBytes > MAX_SKILL_TOTAL_BYTES) {
471
+ throw new RouteError(413, 'Skill bundle is too large.', 'SKILL_TOO_LARGE');
472
+ }
473
+ files.set(relPath, content);
474
+ zipFile.readEntry();
475
+ }
476
+ catch (error) {
477
+ fail(error);
478
+ }
479
+ })();
480
+ });
481
+ zipFile.once('error', fail);
482
+ zipFile.once('end', () => {
483
+ if (completed)
484
+ return;
485
+ completed = true;
486
+ resolveFiles(files);
487
+ });
488
+ zipFile.readEntry();
489
+ });
490
+ }
491
+ finally {
492
+ zipFile.close();
493
+ }
494
+ }
495
+ async function uploadedSkillFilesFromRequest(uploadedFiles, relativePaths) {
496
+ if (uploadedFiles.length === 1 && extname(uploadedFiles[0].originalname).toLowerCase() === '.zip') {
497
+ return prepareSkillFiles(await readZipSkillFiles(uploadedFiles[0].path));
498
+ }
499
+ if (uploadedFiles.length > MAX_SKILL_FILES) {
500
+ throw new RouteError(413, 'Skill contains too many files.', 'SKILL_TOO_LARGE');
501
+ }
502
+ const files = new Map();
503
+ let totalBytes = 0;
504
+ // multer already enforces the per-file size limit (limits.fileSize), so only
505
+ // the cumulative bundle size needs checking here.
506
+ for (const [index, file] of uploadedFiles.entries()) {
507
+ const relPath = normalizeRelativePath(relativePaths[index] ?? file.originalname, 'upload file path');
508
+ if (isIgnoredSkillArchivePath(relPath))
509
+ continue;
510
+ if (files.has(relPath)) {
511
+ throw new RouteError(400, `Duplicate skill file path ${relPath}.`, 'DUPLICATE_SKILL_FILE');
512
+ }
513
+ totalBytes += file.size;
514
+ if (totalBytes > MAX_SKILL_TOTAL_BYTES) {
515
+ throw new RouteError(413, 'Skill bundle is too large.', 'SKILL_TOO_LARGE');
516
+ }
517
+ files.set(relPath, await readFile(file.path));
518
+ }
519
+ return prepareSkillFiles(files);
520
+ }
521
+ async function downloadClawHubFiles(slug, version, versionPayload) {
522
+ let entries = fileEntriesFromPayload(versionPayload);
523
+ if (entries.length === 0)
524
+ entries = [{ path: 'SKILL.md' }];
525
+ if (entries.length > MAX_SKILL_FILES) {
526
+ throw new RouteError(413, 'Skill contains too many files.', 'SKILL_TOO_LARGE');
527
+ }
528
+ const files = new Map();
529
+ let totalBytes = 0;
530
+ for (const entry of entries) {
531
+ const relPath = normalizeRelativePath(entry.path, 'skill file path');
532
+ if (entry.size !== undefined)
533
+ assertFileSize(relPath, entry.size);
534
+ const content = entry.content !== undefined
535
+ ? Buffer.from(entry.content, 'utf8')
536
+ : await fetchClawHubFile(slug, relPath, version);
537
+ assertFileSize(relPath, content.byteLength);
538
+ totalBytes += content.byteLength;
539
+ if (totalBytes > MAX_SKILL_TOTAL_BYTES) {
540
+ throw new RouteError(413, 'Skill bundle is too large.', 'SKILL_TOO_LARGE');
541
+ }
542
+ if (entry.sha256) {
543
+ const actual = createHash('sha256').update(content).digest('hex');
544
+ if (actual !== entry.sha256) {
545
+ throw new RouteError(502, `Checksum mismatch for ${relPath}.`, 'CLAWHUB_CHECKSUM_MISMATCH');
546
+ }
547
+ }
548
+ files.set(relPath, content);
549
+ }
550
+ if (!files.has('SKILL.md')) {
551
+ throw new RouteError(422, 'Skill bundle is missing a root SKILL.md file.', 'INVALID_SKILL_BUNDLE');
552
+ }
553
+ return files;
554
+ }
555
+ function displayHomePath(path) {
556
+ const home = homedir();
557
+ const resolved = resolve(path);
558
+ if (resolved === home)
559
+ return '~';
560
+ if (resolved.startsWith(`${home}${sep}`))
561
+ return `~/${relative(home, resolved).split(sep).join('/')}`;
562
+ return resolved;
563
+ }
564
+ function resolveConfigDir(value) {
565
+ const trimmed = value.trim();
566
+ const expanded = trimmed === '~'
567
+ ? homedir()
568
+ : trimmed.startsWith('~/')
569
+ ? join(homedir(), trimmed.slice(2))
570
+ : trimmed;
571
+ return resolve(resolveHermesHome(), expanded);
572
+ }
573
+ // Registers MINIONS_HOME/skills as a Hermes `skills.external_dirs` entry so agent
574
+ // runs load installed skills. Idempotent — called once at server boot; installs
575
+ // drop skills into the already-registered dir and need no further config write.
576
+ export async function ensureHermesExternalSkillsDir() {
577
+ const skillsDir = resolveMinionsSkillsDir();
578
+ const hermesHome = resolveHermesHome();
579
+ const configPath = join(hermesHome, 'config.yaml');
580
+ await mkdir(hermesHome, { recursive: true });
581
+ const existing = await readFile(configPath, 'utf8').catch(() => '');
582
+ const doc = parseDocument(existing);
583
+ const data = (doc.toJS() ?? {});
584
+ const skillsValue = data.skills;
585
+ const rawDirs = isRecord(skillsValue) ? skillsValue.external_dirs : undefined;
586
+ const existingDirs = Array.isArray(rawDirs)
587
+ ? rawDirs.filter((dir) => typeof dir === 'string')
588
+ : typeof rawDirs === 'string'
589
+ ? [rawDirs]
590
+ : [];
591
+ if (existingDirs.some((dir) => resolveConfigDir(dir) === resolve(skillsDir))) {
592
+ return;
593
+ }
594
+ // If `skills:` exists but is not a mapping (a bare `skills:` parses to null, or
595
+ // it could be a scalar/list), `setIn(['skills', 'external_dirs'], …)` throws
596
+ // "Expected YAML collection at skills". Drop the offending node so setIn can
597
+ // recreate the mapping; a real mapping is left intact, preserving sibling keys.
598
+ if (skillsValue !== undefined && !isRecord(skillsValue)) {
599
+ doc.deleteIn(['skills']);
600
+ }
601
+ doc.setIn(['skills', 'external_dirs'], [...existingDirs, displayHomePath(skillsDir)]);
602
+ await writeFile(configPath, doc.toString(), 'utf8');
603
+ }
604
+ async function writeSkillFiles(destination, files, sidecar) {
605
+ const parent = dirname(destination);
606
+ const tempDir = join(parent, `.${basename(destination)}.tmp-${Date.now()}-${randomUUID()}`);
607
+ await rm(tempDir, { recursive: true, force: true });
608
+ await mkdir(tempDir, { recursive: true });
609
+ try {
610
+ for (const [relPath, content] of files) {
611
+ const target = resolve(tempDir, relPath);
612
+ ensureInside(tempDir, target);
613
+ await mkdir(dirname(target), { recursive: true });
614
+ await writeFile(target, content);
615
+ }
616
+ await writeFile(join(tempDir, SIDECAR_FILENAME), `${JSON.stringify(sidecar, null, 2)}\n`, 'utf8');
617
+ await rm(destination, { recursive: true, force: true });
618
+ await rename(tempDir, destination);
619
+ }
620
+ catch (error) {
621
+ await rm(tempDir, { recursive: true, force: true }).catch(() => undefined);
622
+ throw error;
623
+ }
624
+ }
625
+ async function installClawHubSkill(slug, requestedVersion, force) {
626
+ const skillsRoot = resolveMinionsSkillsDir();
627
+ const destination = resolve(skillsRoot, 'clawhub', slug);
628
+ ensureInside(skillsRoot, destination);
629
+ await mkdir(dirname(destination), { recursive: true });
630
+ const existingSkillFile = join(destination, 'SKILL.md');
631
+ if (!force && await pathExists(existingSkillFile)) {
632
+ return {
633
+ skill: await readInstalledSkill(existingSkillFile, skillsRoot),
634
+ installed: false,
635
+ alreadyInstalled: true,
636
+ };
637
+ }
638
+ const detail = await fetchClawHubJson(`/skills/${encodeURIComponent(slug)}`);
639
+ const version = resolveVersion(detail, requestedVersion);
640
+ const versionPayload = await fetchClawHubJson(`/skills/${encodeURIComponent(slug)}/versions/${encodeURIComponent(version)}`);
641
+ const files = await downloadClawHubFiles(slug, version, versionPayload);
642
+ const skill = skillPayload(detail);
643
+ const sidecar = {
644
+ provider: 'clawhub',
645
+ registrySlug: slug,
646
+ version,
647
+ displayName: stringValue(skill.displayName) || slug,
648
+ summary: stringValue(skill.summary) || '',
649
+ sourceUrl: `https://clawhub.ai/skills/${slug}`,
650
+ installedAt: new Date().toISOString(),
651
+ };
652
+ await writeSkillFiles(destination, files, sidecar);
653
+ return {
654
+ skill: await readInstalledSkill(join(destination, 'SKILL.md'), skillsRoot),
655
+ installed: true,
656
+ alreadyInstalled: false,
657
+ };
658
+ }
659
+ function parseSkillImportRequest(value, fileCount) {
660
+ const body = isRecord(value) ? value : {};
661
+ const relativePaths = stringArrayFromField(body.relativePaths);
662
+ if (relativePaths.length > 0 && relativePaths.length !== fileCount) {
663
+ throw new RouteError(400, 'Upload path count must match file count.', 'INVALID_UPLOAD_PATHS');
664
+ }
665
+ return { relativePaths };
666
+ }
667
+ async function importLocalSkill(uploadedFiles, relativePaths) {
668
+ const prepared = await uploadedSkillFilesFromRequest(uploadedFiles, relativePaths);
669
+ const skillContent = prepared.files.get('SKILL.md');
670
+ if (!skillContent) {
671
+ throw new RouteError(422, 'Skill bundle is missing a root SKILL.md file.', 'INVALID_SKILL_BUNDLE');
672
+ }
673
+ const frontmatter = parseFrontmatter(skillContent.toString('utf8'));
674
+ const displayName = frontmatter.name || prepared.rootName || 'Local skill';
675
+ const summary = frontmatter.description || '';
676
+ const skillsRoot = resolveMinionsSkillsDir();
677
+ const baseSlug = slugifySkillDirectoryName(displayName, 'skill');
678
+ const destination = await uniqueLocalSkillDestination(skillsRoot, baseSlug);
679
+ const sidecar = {
680
+ provider: 'local',
681
+ displayName,
682
+ summary,
683
+ installedAt: new Date().toISOString(),
684
+ };
685
+ await mkdir(dirname(destination), { recursive: true });
686
+ await writeSkillFiles(destination, prepared.files, sidecar);
687
+ return {
688
+ skill: await readInstalledSkill(join(destination, 'SKILL.md'), skillsRoot),
689
+ imported: true,
690
+ };
691
+ }
692
+ async function handleSkillImportRequest(req, res) {
693
+ const uploadedFiles = Array.isArray(req.files) ? req.files : [];
694
+ try {
695
+ if (uploadedFiles.length === 0) {
696
+ throw new RouteError(400, 'At least one skill file is required.', 'NO_SKILL_FILES');
697
+ }
698
+ const { relativePaths } = parseSkillImportRequest(req.body, uploadedFiles.length);
699
+ const result = await importLocalSkill(uploadedFiles, relativePaths);
700
+ res.status(201).json({
701
+ skill: result.skill,
702
+ installed: result.imported,
703
+ alreadyInstalled: false,
704
+ });
705
+ }
706
+ catch (error) {
707
+ sendError(res, error, 'Failed to import skill');
708
+ }
709
+ finally {
710
+ await Promise.all(uploadedFiles.map((file) => unlink(file.path).catch(() => undefined)));
711
+ }
712
+ }
4
713
  skillsRouter.get('/', async (_req, res) => {
5
- const skills = await listBundledSkills();
6
- res.json({ skills: skills.map(toSkillMeta) });
714
+ try {
715
+ res.json({ skills: await listInstalledSkills() });
716
+ }
717
+ catch (error) {
718
+ sendError(res, error, 'Failed to list skills');
719
+ }
720
+ });
721
+ skillsRouter.get('/registry/search', async (req, res) => {
722
+ try {
723
+ const query = stringValue(req.query.q);
724
+ const limit = clampLimit(req.query.limit, 24);
725
+ const data = query
726
+ ? await fetchClawHubJson('/search', { q: query, limit, nonSuspiciousOnly: true })
727
+ : await fetchClawHubJson('/skills', { sort: 'downloads', limit, nonSuspiciousOnly: true });
728
+ res.json({ skills: registrySummaries(data) });
729
+ }
730
+ catch (error) {
731
+ sendError(res, error, 'Failed to search ClawHub skills');
732
+ }
733
+ });
734
+ skillsRouter.get('/registry/browse', async (req, res) => {
735
+ try {
736
+ const limit = clampLimit(req.query.limit, 24);
737
+ const data = await fetchClawHubJson('/skills', { sort: 'downloads', limit, nonSuspiciousOnly: true });
738
+ res.json({ skills: registrySummaries(data) });
739
+ }
740
+ catch (error) {
741
+ sendError(res, error, 'Failed to load ClawHub skills');
742
+ }
743
+ });
744
+ skillsRouter.get('/registry/:slug/content', async (req, res) => {
745
+ try {
746
+ const slug = ensureSafeSlug(req.params.slug);
747
+ const content = await fetchClawHubSkillMarkdown(slug, stringValue(req.query.version));
748
+ res.json({ content });
749
+ }
750
+ catch (error) {
751
+ sendError(res, error, 'Failed to load ClawHub skill content');
752
+ }
753
+ });
754
+ skillsRouter.get('/registry/:slug/scan', async (req, res) => {
755
+ try {
756
+ const slug = ensureSafeSlug(req.params.slug);
757
+ const version = stringValue(req.query.version);
758
+ const data = await fetchClawHubJson(`/skills/${encodeURIComponent(slug)}/scan`, version ? { version } : { tag: 'latest' });
759
+ res.json(isRecord(data) ? data : {});
760
+ }
761
+ catch (error) {
762
+ sendError(res, error, 'Failed to load ClawHub skill scan');
763
+ }
764
+ });
765
+ skillsRouter.post('/import', (req, res) => {
766
+ skillImportUploadMiddleware(req, res, (error) => {
767
+ if (error) {
768
+ const status = error instanceof multer.MulterError && error.code === 'LIMIT_FILE_SIZE' ? 413 : 400;
769
+ sendError(res, new RouteError(status, error instanceof Error ? error.message : 'Failed to upload skill files.', 'SKILL_UPLOAD_FAILED'), 'Failed to upload skill files');
770
+ return;
771
+ }
772
+ void handleSkillImportRequest(req, res);
773
+ });
774
+ });
775
+ skillsRouter.post('/install', async (req, res) => {
776
+ try {
777
+ const body = isRecord(req.body) ? req.body : {};
778
+ const provider = stringValue(body.provider) || 'clawhub';
779
+ if (provider !== 'clawhub') {
780
+ throw new RouteError(400, `Unsupported skills provider '${provider}'.`, 'UNSUPPORTED_SKILLS_PROVIDER');
781
+ }
782
+ const slug = ensureSafeSlug(body.slug);
783
+ const requestedVersion = stringValue(body.version);
784
+ const force = body.force === true;
785
+ const result = await installClawHubSkill(slug, requestedVersion, force);
786
+ res.status(result.installed ? 201 : 200).json({
787
+ skill: result.skill,
788
+ installed: result.installed,
789
+ alreadyInstalled: result.alreadyInstalled,
790
+ });
791
+ }
792
+ catch (error) {
793
+ sendError(res, error, 'Failed to install skill');
794
+ }
795
+ });
796
+ skillsRouter.delete('/:id', async (req, res) => {
797
+ try {
798
+ const skill = await deleteInstalledSkill(req.params.id);
799
+ res.json({ ok: true, skill });
800
+ }
801
+ catch (error) {
802
+ sendError(res, error, 'Failed to delete skill');
803
+ }
7
804
  });
8
805
  skillsRouter.get('/:id/content', async (req, res) => {
9
- const result = await getBundledSkillWithContent(req.params.id);
10
- if (!result)
11
- return res.status(404).json({ error: 'Skill not found' });
12
- res.json({ skill: toSkillMeta(result.skill), content: result.content });
806
+ try {
807
+ const skillFile = resolveInstalledSkillFile(req.params.id);
808
+ if (!await pathExists(skillFile)) {
809
+ throw new RouteError(404, `Skill '${req.params.id}' is not installed`, 'SKILL_NOT_FOUND');
810
+ }
811
+ const root = resolveMinionsSkillsDir();
812
+ const [skill, content] = await Promise.all([
813
+ readInstalledSkill(skillFile, root),
814
+ readFile(skillFile, 'utf8'),
815
+ ]);
816
+ res.json({ skill, content });
817
+ }
818
+ catch (error) {
819
+ sendError(res, error, 'Failed to load skill content');
820
+ }
13
821
  });