import express, { type Application, type Request, type Response, type RequestHandler } from 'express'; import { readFileSync, writeFileSync, unlinkSync, existsSync, mkdirSync, readdirSync, lstatSync, renameSync, rmSync } from 'fs'; import { join, relative } from 'path'; import { randomBytes } from 'crypto'; import type { SkillCatalog, SkillEntry } from '../engine/skills.js'; import { VALID_SKILL_NAME } from '../engine/skills.js'; import { scanSkillContent, scanSkillDirectory, maxSeverity } from '../engine/skills-scanner.js'; import { logger } from '../logger.js'; import { handleInstallFromUrl } from './skills-git-install.js'; const MAX_CONTENT_SIZE = 64 * 1024; // 64 KB export interface MountSkillsApiOptions { skillCatalog: SkillCatalog; requireAuth?: RequestHandler; requireAdmin?: RequestHandler; authActive?: boolean; auditLog?: (jobId: string | null, action: string, actor: string, detail: object) => Promise; } type AuthedUser = { id?: string; role?: string }; function getUser(req: Request): AuthedUser | undefined { return (req as any).user as AuthedUser | undefined; } function getUserId(req: Request): string { const user = getUser(req); return user?.id ?? 'local'; } function isAdmin(req: Request): boolean { const user = getUser(req); return user?.role === 'admin'; } /** * Recursively list files in a directory, skipping symlinks. * Returns paths relative to `baseDir`. */ function listDirFiles(baseDir: string, maxDepth: number = 5): string[] { const results: string[] = []; function walk(dir: string, depth: number): void { if (depth > maxDepth) return; let entries: string[]; try { entries = readdirSync(dir); } catch { return; } for (const entry of entries) { const fullPath = join(dir, entry); let stat; try { stat = lstatSync(fullPath); } catch { continue; } if (stat.isSymbolicLink()) continue; if (stat.isDirectory()) { walk(fullPath, depth + 1); } else if (stat.isFile()) { results.push(relative(baseDir, fullPath)); } } } walk(baseDir, 0); return results; } export function mountSkillsApi(app: Application, opts: MountSkillsApiOptions): void { const { skillCatalog } = opts; // JSON body parser for skills endpoints app.use('/api/skills', express.json()); // Auth gating if (opts.authActive && opts.requireAuth) { app.use('/api/skills', opts.requireAuth); } // ── GET /api/skills ── list skills ────────────────────────────── app.get('/api/skills', (req: Request, res: Response) => { try { const userId = getUserId(req); const scope = (req.query.scope as string) ?? 'all'; if (!['all', 'system', 'user'].includes(scope)) { res.status(400).json({ error: 'scope must be one of: all, system, user' }); return; } const entries = skillCatalog.getForUser(userId); const filtered = scope === 'all' ? entries : entries.filter(e => e.source === scope); const skills = filtered.map(e => ({ name: e.name, description: e.description, triggers: e.triggers, source: e.source, hasDir: e.dirPath !== null, })); res.json({ skills }); } catch (e) { res.status(500).json({ error: `Failed to list skills: ${e}` }); } }); // ── POST /api/skills/install-from-url ── Git URL install ──────── // Must be before /:name routes to avoid Express matching 'install-from-url' as :name app.post('/api/skills/install-from-url', handleInstallFromUrl({ skillCatalog: opts.skillCatalog, auditLog: opts.auditLog, })); // ── GET /api/skills/:name ── skill detail ────────────────────── app.get('/api/skills/:name', (req: Request, res: Response) => { const { name } = req.params; if (!VALID_SKILL_NAME.test(name)) { res.status(400).json({ error: 'Invalid skill name' }); return; } try { const userId = getUserId(req); const scopeHint = req.query.scope as string | undefined; // Find the entry matching name (and optional scope filter) const entries = skillCatalog.getForUser(userId); let entry: SkillEntry | undefined; if (scopeHint && ['system', 'user'].includes(scopeHint)) { entry = entries.find(e => e.name === name && e.source === scopeHint); } if (!entry) { entry = entries.find(e => e.name === name); } if (!entry) { res.status(404).json({ error: 'Skill not found' }); return; } // Read content via catalog const contentResult = skillCatalog.getSkillContent(name, userId); const content = contentResult?.content ?? ''; // Read raw file for frontmatter let raw = ''; try { raw = readFileSync(entry.filePath, 'utf-8'); } catch { /* skip */ } // File listing for directory skills let files: string[] | undefined; if (entry.dirPath) { files = listDirFiles(entry.dirPath); } // Security scan let findings; if (entry.dirPath) { findings = scanSkillDirectory(entry.dirPath); } else { findings = scanSkillContent(raw); } res.json({ name: entry.name, description: entry.description, triggers: entry.triggers, source: entry.source, hasDir: entry.dirPath !== null, content, files, findings, maxSeverity: maxSeverity(findings), }); } catch (e) { res.status(500).json({ error: `Failed to read skill: ${e}` }); } }); // ── POST /api/skills ── create single-file skill ────────────── app.post('/api/skills', async (req: Request, res: Response) => { try { const { name, content, scope } = req.body ?? {}; // Validate name if (!name || typeof name !== 'string' || !VALID_SKILL_NAME.test(name)) { res.status(400).json({ error: 'Invalid skill name (lowercase alphanumeric, hyphens, underscores)' }); return; } // Validate scope if (!scope || !['system', 'user'].includes(scope)) { res.status(400).json({ error: 'scope must be one of: system, user' }); return; } // Validate content if (!content || typeof content !== 'string') { res.status(400).json({ error: 'content is required' }); return; } if (Buffer.byteLength(content, 'utf-8') > MAX_CONTENT_SIZE) { res.status(400).json({ error: `Content exceeds maximum size of ${MAX_CONTENT_SIZE / 1024}KB` }); return; } // System scope requires admin if (scope === 'system' && !isAdmin(req)) { res.status(403).json({ error: 'Only admins can create system skills' }); return; } const userId = getUserId(req); // Determine destination directory const destDir = scope === 'system' ? skillCatalog.getSystemDir() : skillCatalog.getUserSkillDir(userId); // Check for existing skill (directory or flat file) const destDirPath = join(destDir, name); const destFlatPath = join(destDir, `${name}.md`); if (existsSync(destDirPath) || existsSync(destFlatPath)) { res.status(409).json({ error: 'Skill already exists' }); return; } // Scan content before writing const findings = scanSkillContent(content); const severity = maxSeverity(findings); // Always create directory format: {name}/SKILL.md const tmpDir = join(destDir, `.tmp-${randomBytes(8).toString('hex')}`); mkdirSync(tmpDir, { recursive: true }); writeFileSync(join(tmpDir, 'SKILL.md'), content, 'utf-8'); renameSync(tmpDir, destDirPath); // Invalidate cache if (scope === 'system') { skillCatalog.refreshSystem(); } else { skillCatalog.invalidate(userId); } // Audit log const actor = userId; if (opts.auditLog) { await opts.auditLog(null, 'skill.create', actor, { name, scope, severity }); } logger.info(`[skills-api] created skill=${name} scope=${scope} actor=${actor} severity=${severity}`); res.status(201).json({ name, scope, severity, findings }); } catch (e) { res.status(500).json({ error: `Failed to create skill: ${e}` }); } }); // ── PUT /api/skills/:name ── edit skill content ─────────────── app.put('/api/skills/:name', async (req: Request, res: Response) => { const { name } = req.params; if (!VALID_SKILL_NAME.test(name)) { res.status(400).json({ error: 'Invalid skill name' }); return; } const scope = req.query.scope as string | undefined; if (!scope) { res.status(400).json({ error: 'scope query parameter is required' }); return; } if (!['system', 'user'].includes(scope)) { res.status(400).json({ error: 'scope must be one of: system, user' }); return; } // System scope requires admin if (scope === 'system' && !isAdmin(req)) { res.status(403).json({ error: 'Only admins can edit system skills' }); return; } try { const { content } = req.body ?? {}; if (!content || typeof content !== 'string') { res.status(400).json({ error: 'content is required' }); return; } if (Buffer.byteLength(content, 'utf-8') > MAX_CONTENT_SIZE) { res.status(400).json({ error: `Content exceeds maximum size of ${MAX_CONTENT_SIZE / 1024}KB` }); return; } const userId = getUserId(req); const baseDir = scope === 'system' ? skillCatalog.getSystemDir() : skillCatalog.getUserSkillDir(userId); // Find the skill file: either flat file or directory with SKILL.md let targetPath: string | null = null; const flatPath = join(baseDir, `${name}.md`); const dirSkillPath = join(baseDir, name, 'SKILL.md'); if (existsSync(dirSkillPath)) { targetPath = dirSkillPath; } else if (existsSync(flatPath)) { targetPath = flatPath; } if (!targetPath) { res.status(404).json({ error: 'Skill not found' }); return; } // Scan new content const findings = scanSkillContent(content); const severity = maxSeverity(findings); // Atomic write: tmpfile in same directory as target → rename const targetDir = targetPath === dirSkillPath ? join(baseDir, name) : baseDir; const tmpPath = join(targetDir, `.tmp-${randomBytes(8).toString('hex')}.md`); writeFileSync(tmpPath, content, 'utf-8'); renameSync(tmpPath, targetPath); // Invalidate cache if (scope === 'system') { skillCatalog.refreshSystem(); } else { skillCatalog.invalidate(userId); } // Audit log const actor = userId; if (opts.auditLog) { await opts.auditLog(null, 'skill.update', actor, { name, scope, severity }); } logger.info(`[skills-api] updated skill=${name} scope=${scope} actor=${actor} severity=${severity}`); res.json({ ok: true, severity, findings }); } catch (e) { res.status(500).json({ error: `Failed to update skill: ${e}` }); } }); // ── DELETE /api/skills/:name ── delete skill ────────────────── app.delete('/api/skills/:name', async (req: Request, res: Response) => { const { name } = req.params; if (!VALID_SKILL_NAME.test(name)) { res.status(400).json({ error: 'Invalid skill name' }); return; } const scope = req.query.scope as string | undefined; if (!scope) { res.status(400).json({ error: 'scope query parameter is required' }); return; } if (!['system', 'user'].includes(scope)) { res.status(400).json({ error: 'scope must be one of: system, user' }); return; } // System scope requires admin if (scope === 'system' && !isAdmin(req)) { res.status(403).json({ error: 'Only admins can delete system skills' }); return; } try { const userId = getUserId(req); const baseDir = scope === 'system' ? skillCatalog.getSystemDir() : skillCatalog.getUserSkillDir(userId); // Find the skill: directory or flat file const dirPath = join(baseDir, name); const flatPath = join(baseDir, `${name}.md`); let deleted = false; if (existsSync(dirPath) && lstatSync(dirPath).isDirectory()) { rmSync(dirPath, { recursive: true, force: true }); deleted = true; } else if (existsSync(flatPath) && lstatSync(flatPath).isFile()) { unlinkSync(flatPath); deleted = true; } if (!deleted) { res.status(404).json({ error: 'Skill not found' }); return; } // Invalidate cache if (scope === 'system') { skillCatalog.refreshSystem(); } else { skillCatalog.invalidate(userId); } // Audit log const actor = userId; if (opts.auditLog) { await opts.auditLog(null, 'skill.delete', actor, { name, scope }); } logger.info(`[skills-api] deleted skill=${name} scope=${scope} actor=${actor}`); res.json({ ok: true }); } catch (e) { res.status(500).json({ error: `Failed to delete skill: ${e}` }); } }); }