421 lines
13 KiB
TypeScript
421 lines
13 KiB
TypeScript
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<void>;
|
|
}
|
|
|
|
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}` });
|
|
}
|
|
});
|
|
}
|