mission-control/src/app/api/skills/route.ts

356 lines
13 KiB
TypeScript

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<boolean> {
try {
await access(path, constants.R_OK)
return true
} catch {
return false
}
}
async function extractDescription(skillPath: string): Promise<string | undefined> {
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<SkillSummary[]> {
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<string, { source: string; path: string; skills: SkillSummary[] }>()
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<string, SkillSummary>()
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<string, SkillSummary>()
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'