import { NextRequest, NextResponse } from 'next/server' import { createHash } from 'node:crypto' import { access, mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises' import { constants } from 'node:fs' import { join } from 'node:path' import { homedir } from 'node:os' import { requireRole } from '@/lib/auth' import { resolveWithin } from '@/lib/paths' import { checkSkillSecurity } from '@/lib/skill-registry' interface SkillSummary { id: string name: string source: string path: string description?: string registry_slug?: string | null security_status?: string | null } type SkillRoot = { source: string; path: string } function resolveSkillRoot( envName: string, fallback: string, ): string { const override = process.env[envName] return override && override.trim().length > 0 ? override.trim() : fallback } async function pathReadable(path: string): Promise { try { await access(path, constants.R_OK) return true } catch { return false } } async function extractDescription(skillPath: string): Promise { const skillDocPath = join(skillPath, 'SKILL.md') if (!(await pathReadable(skillDocPath))) return undefined try { const content = await readFile(skillDocPath, 'utf8') const lines = content.split('\n').map((line) => line.trim()).filter(Boolean) const firstParagraph = lines.find((line) => !line.startsWith('#')) if (!firstParagraph) return undefined return firstParagraph.length > 220 ? `${firstParagraph.slice(0, 217)}...` : firstParagraph } catch { return undefined } } async function collectSkillsFromDir(baseDir: string, source: string): Promise { if (!(await pathReadable(baseDir))) return [] try { const entries = await readdir(baseDir, { withFileTypes: true }) const out: SkillSummary[] = [] for (const entry of entries) { if (!entry.isDirectory()) continue const skillPath = join(baseDir, entry.name) const skillDocPath = join(skillPath, 'SKILL.md') if (!(await pathReadable(skillDocPath))) continue out.push({ id: `${source}:${entry.name}`, name: entry.name, source, path: skillPath, description: await extractDescription(skillPath), }) } return out.sort((a, b) => a.name.localeCompare(b.name)) } catch { return [] } } function getSkillRoots(): SkillRoot[] { const home = homedir() const cwd = process.cwd() const roots: SkillRoot[] = [ { source: 'user-agents', path: resolveSkillRoot('MC_SKILLS_USER_AGENTS_DIR', join(home, '.agents', 'skills')) }, { source: 'user-codex', path: resolveSkillRoot('MC_SKILLS_USER_CODEX_DIR', join(home, '.codex', 'skills')) }, { source: 'project-agents', path: resolveSkillRoot('MC_SKILLS_PROJECT_AGENTS_DIR', join(cwd, '.agents', 'skills')) }, { source: 'project-codex', path: resolveSkillRoot('MC_SKILLS_PROJECT_CODEX_DIR', join(cwd, '.codex', 'skills')) }, ] // Add OpenClaw gateway skill roots when configured const openclawState = process.env.OPENCLAW_STATE_DIR || process.env.OPENCLAW_HOME || join(home, '.openclaw') const openclawSkills = resolveSkillRoot('MC_SKILLS_OPENCLAW_DIR', join(openclawState, 'skills')) roots.push({ source: 'openclaw', path: openclawSkills }) // Add OpenClaw workspace-local skills (takes precedence when names conflict) const workspaceDir = process.env.OPENCLAW_WORKSPACE_DIR || process.env.MISSION_CONTROL_WORKSPACE_DIR || join(openclawState, 'workspace') const workspaceSkills = resolveSkillRoot('MC_SKILLS_WORKSPACE_DIR', join(workspaceDir, 'skills')) roots.push({ source: 'workspace', path: workspaceSkills }) return roots } function normalizeSkillName(raw: string): string | null { const value = raw.trim() if (!value) return null if (!/^[a-zA-Z0-9._-]+$/.test(value)) return null return value } function getRootBySource(roots: SkillRoot[], sourceRaw: string | null): SkillRoot | null { const source = String(sourceRaw || '').trim() if (!source) return null return roots.find((r) => r.source === source) || null } async function upsertSkill(root: SkillRoot, name: string, content: string) { const skillPath = resolveWithin(root.path, name) const skillDocPath = resolveWithin(skillPath, 'SKILL.md') await mkdir(skillPath, { recursive: true }) await writeFile(skillDocPath, content, 'utf8') // Update DB hash so next sync cycle detects our write try { const { getDatabase } = await import('@/lib/db') const db = getDatabase() const hash = createHash('sha256').update(content, 'utf8').digest('hex') const now = new Date().toISOString() const descLines = content.split('\n').map(l => l.trim()).filter(Boolean) const desc = descLines.find(l => !l.startsWith('#')) db.prepare(` INSERT INTO skills (name, source, path, description, content_hash, installed_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(source, name) DO UPDATE SET path = excluded.path, description = excluded.description, content_hash = excluded.content_hash, updated_at = excluded.updated_at `).run( name, root.source, skillPath, desc ? (desc.length > 220 ? `${desc.slice(0, 217)}...` : desc) : null, hash, now, now ) } catch { /* DB not ready yet — sync will catch it */ } return { skillPath, skillDocPath } } async function deleteSkill(root: SkillRoot, name: string) { const skillPath = resolveWithin(root.path, name) await rm(skillPath, { recursive: true, force: true }) // Remove from DB try { const { getDatabase } = await import('@/lib/db') const db = getDatabase() db.prepare('DELETE FROM skills WHERE source = ? AND name = ?').run(root.source, name) } catch { /* best-effort */ } return { skillPath } } /** * Try to serve skill list from DB (fast path). * Falls back to filesystem scan if DB has no data yet. */ function getSkillsFromDB(): SkillSummary[] | null { try { const { getDatabase } = require('@/lib/db') const db = getDatabase() const rows = db.prepare('SELECT name, source, path, description, registry_slug, security_status FROM skills ORDER BY name').all() as Array<{ name: string; source: string; path: string; description: string | null; registry_slug: string | null; security_status: string | null }> if (rows.length === 0) return null // DB empty — fall back to fs scan return rows.map(r => ({ id: `${r.source}:${r.name}`, name: r.name, source: r.source, path: r.path, description: r.description || undefined, registry_slug: r.registry_slug, security_status: r.security_status, })) } catch { return null } } export async function GET(request: NextRequest) { const auth = requireRole(request, 'viewer') if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status }) const roots = getSkillRoots() const { searchParams } = new URL(request.url) const mode = searchParams.get('mode') if (mode === 'content') { const source = String(searchParams.get('source') || '') const name = normalizeSkillName(String(searchParams.get('name') || '')) if (!source || !name) { return NextResponse.json({ error: 'source and valid name are required' }, { status: 400 }) } const root = roots.find((r) => r.source === source) if (!root) return NextResponse.json({ error: 'Invalid source' }, { status: 400 }) const skillPath = join(root.path, name) const skillDocPath = join(skillPath, 'SKILL.md') if (!(await pathReadable(skillDocPath))) { return NextResponse.json({ error: 'SKILL.md not found' }, { status: 404 }) } const content = await readFile(skillDocPath, 'utf8') // Run security check inline const security = checkSkillSecurity(content) return NextResponse.json({ source, name, skillPath, skillDocPath, content, security, }) } if (mode === 'check') { // Security-check a specific skill's content const source = String(searchParams.get('source') || '') const name = normalizeSkillName(String(searchParams.get('name') || '')) if (!source || !name) { return NextResponse.json({ error: 'source and valid name are required' }, { status: 400 }) } const root = roots.find((r) => r.source === source) if (!root) return NextResponse.json({ error: 'Invalid source' }, { status: 400 }) const skillPath = join(root.path, name) const skillDocPath = join(skillPath, 'SKILL.md') if (!(await pathReadable(skillDocPath))) { return NextResponse.json({ error: 'SKILL.md not found' }, { status: 404 }) } const content = await readFile(skillDocPath, 'utf8') const security = checkSkillSecurity(content) // Update DB with security status try { const { getDatabase } = await import('@/lib/db') const db = getDatabase() db.prepare('UPDATE skills SET security_status = ?, updated_at = ? WHERE source = ? AND name = ?') .run(security.status, new Date().toISOString(), source, name) } catch { /* best-effort */ } return NextResponse.json({ source, name, security }) } // Try DB-backed fast path first const dbSkills = getSkillsFromDB() if (dbSkills) { // Group by source for the groups response const groupMap = new Map() for (const root of roots) { groupMap.set(root.source, { source: root.source, path: root.path, skills: [] }) } for (const skill of dbSkills) { const group = groupMap.get(skill.source) if (group) group.skills.push(skill) } const deduped = new Map() for (const skill of dbSkills) { if (!deduped.has(skill.name)) deduped.set(skill.name, skill) } return NextResponse.json({ skills: Array.from(deduped.values()).sort((a, b) => a.name.localeCompare(b.name)), groups: Array.from(groupMap.values()), total: deduped.size, }) } // Fallback: filesystem scan (first load before sync runs) const bySource = await Promise.all( roots.map(async (root) => ({ source: root.source, path: root.path, skills: await collectSkillsFromDir(root.path, root.source), })) ) const all = bySource.flatMap((group) => group.skills) const deduped = new Map() for (const skill of all) { if (!deduped.has(skill.name)) deduped.set(skill.name, skill) } return NextResponse.json({ skills: Array.from(deduped.values()).sort((a, b) => a.name.localeCompare(b.name)), groups: bySource, total: deduped.size, }) } export async function POST(request: NextRequest) { const auth = requireRole(request, 'operator') if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status }) const roots = getSkillRoots() const body = await request.json().catch(() => ({})) const root = getRootBySource(roots, body?.source) const name = normalizeSkillName(String(body?.name || '')) const contentRaw = typeof body?.content === 'string' ? body.content : '' const content = contentRaw.trim() || `# ${name || 'skill'}\n\nDescribe this skill.\n` if (!root || !name) { return NextResponse.json({ error: 'Valid source and name are required' }, { status: 400 }) } await mkdir(root.path, { recursive: true }) const { skillPath, skillDocPath } = await upsertSkill(root, name, content) return NextResponse.json({ ok: true, source: root.source, name, skillPath, skillDocPath }) } export async function PUT(request: NextRequest) { const auth = requireRole(request, 'operator') if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status }) const roots = getSkillRoots() const body = await request.json().catch(() => ({})) const root = getRootBySource(roots, body?.source) const name = normalizeSkillName(String(body?.name || '')) const content = typeof body?.content === 'string' ? body.content : null if (!root || !name || content == null) { return NextResponse.json({ error: 'Valid source, name, and content are required' }, { status: 400 }) } await mkdir(root.path, { recursive: true }) const { skillPath, skillDocPath } = await upsertSkill(root, name, content) return NextResponse.json({ ok: true, source: root.source, name, skillPath, skillDocPath }) } export async function DELETE(request: NextRequest) { const auth = requireRole(request, 'operator') if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status }) const { searchParams } = new URL(request.url) const roots = getSkillRoots() const root = getRootBySource(roots, searchParams.get('source')) const name = normalizeSkillName(String(searchParams.get('name') || '')) if (!root || !name) { return NextResponse.json({ error: 'Valid source and name are required' }, { status: 400 }) } const { skillPath } = await deleteSkill(root, name) return NextResponse.json({ ok: true, source: root.source, name, skillPath }) } export const dynamic = 'force-dynamic'