import { useCallback, useMemo, useState } from "react"; import { analyzeFrontmatterBlock, asStringArray, getSkillFrontmatterUnknownKeys, isFrontmatterPlainRecord, parseFrontmatterFields, stringifyFrontmatter, } from "@paperclipai/shared"; import { AlertTriangle, ChevronDown, ChevronRight, Info, Plus, Trash2, X } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Collapsible, CollapsibleContent } from "@/components/ui/collapsible"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; /** * Skill Studio frontmatter editor (PAP-13145 Option B / PAP-13155). * * The rich markdown editor must never see the leading `---` YAML block or it * corrupts it into headings/thematic breaks on every round-trip. Studio splits * the file into a frontmatter block (owned by this panel) and a body (owned by * MarkdownEditor); this panel offers a schema-aware Fields form when the YAML * can be safely re-serialized, and always falls back to raw YAML when it can't. * * Byte-identity guarantee (QA PAP-13156): this panel only calls `onChange` in * response to a real user edit. Opening a file and saving it untouched never * routes through the serializer, so bytes are preserved exactly. Callers must * mount this panel with a per-file `key` so its local state resets on file * switches. */ type FrontmatterMode = "fields" | "yaml"; export interface FrontmatterPanelChange { frontmatterText: string; hasFrontmatter: boolean; } export interface FrontmatterPanelProps { /** Raw YAML block between the `---` fences (from `splitFrontmatterBlock`). */ frontmatterText: string; hasFrontmatter: boolean; /** File path, e.g. `SKILL.md`. Drives required-field rules + expand default. */ fileName: string; /** Skill slug used to seed `name:` when adding frontmatter to a SKILL.md. */ skillSlug?: string; readOnly?: boolean; onChange: (change: FrontmatterPanelChange) => void; className?: string; } type ScalarValue = string | number | boolean | null; interface ScalarRow { id: string; key: string; /** Original parsed value, preserved verbatim until the row is edited. */ rawValue: unknown; text: string; edited: boolean; } interface UnknownRow extends ScalarRow { editable: boolean; } interface FormModel { hasName: boolean; name: string; hasDescription: boolean; description: string; allowedToolsPresent: boolean; /** null → present but not a string list (type mismatch, edit in YAML). */ allowedTools: string[] | null; metadataPresent: boolean; /** null → metadata is a plain scalar record we can edit as rows. */ metadataComplex: unknown; metaRows: ScalarRow[]; unknown: UnknownRow[]; } function isScalar(value: unknown): value is ScalarValue { return ( value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean" ); } function scalarToText(value: unknown): string { if (value === null || value === undefined) return ""; if (typeof value === "boolean") return value ? "true" : "false"; return String(value); } /** Coerce edited text back to a YAML scalar so `version: 2` stays a number. */ function coerceScalar(text: string): ScalarValue { if (text === "") return ""; if (text === "true") return true; if (text === "false") return false; if (text === "null" || text === "~") return null; if (/^-?\d+(\.\d+)?$/u.test(text)) return Number(text); return text; } function isFlatScalarRecord(value: unknown): value is Record { return isFrontmatterPlainRecord(value) && Object.values(value).every(isScalar); } function buildFormModel(obj: Record): FormModel { const allowedToolsPresent = "allowed-tools" in obj; const metadataValue = obj.metadata; const metadataPresent = "metadata" in obj; const metadataEditable = metadataPresent && isFlatScalarRecord(metadataValue); const metaRows: ScalarRow[] = metadataEditable ? Object.entries(metadataValue as Record).map(([key, value], index) => ({ id: `meta-${index}-${key}`, key, rawValue: value, text: scalarToText(value), edited: false, })) : []; const unknown: UnknownRow[] = getSkillFrontmatterUnknownKeys(obj).map((key, index) => { const value = obj[key]; const editable = isScalar(value); return { id: `unknown-${index}-${key}`, key, rawValue: value, text: scalarToText(value), edited: false, editable, }; }); return { hasName: "name" in obj, name: typeof obj.name === "string" ? obj.name : scalarToText(obj.name), hasDescription: "description" in obj, description: typeof obj.description === "string" ? obj.description : scalarToText(obj.description), allowedToolsPresent, allowedTools: allowedToolsPresent ? asStringArray(obj["allowed-tools"]) : [], metadataPresent, metadataComplex: metadataPresent && !metadataEditable ? metadataValue : null, metaRows, unknown, }; } function scalarRowsToObject(rows: ScalarRow[]): Record { const out: Record = {}; for (const row of rows) { const key = row.key.trim(); if (!key) continue; out[key] = row.edited ? coerceScalar(row.text) : row.rawValue; } return out; } /** * Rebuild the frontmatter object from the form. Known keys are emitted in schema * order (name, description, allowed-tools, metadata) followed by unknown keys — * matches the panel's display order. Only reached in Fields mode, which is only * available for round-trippable blocks, so this never runs on messy YAML. */ function serializeForm(form: FormModel): string { const out: Record = {}; if (form.hasName) out.name = form.name; if (form.hasDescription) out.description = form.description; if (form.allowedToolsPresent) out["allowed-tools"] = form.allowedTools ?? []; if (form.metadataPresent) { out.metadata = form.metadataComplex !== null ? form.metadataComplex : scalarRowsToObject(form.metaRows); } for (const row of form.unknown) { const key = row.key.trim(); if (!key) continue; out[key] = row.editable && row.edited ? coerceScalar(row.text) : row.rawValue; } return stringifyFrontmatter(out); } const SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u; interface ValidationIssue { field: string; message: string; } function collectValidation(form: FormModel, isSkillFile: boolean): ValidationIssue[] { const issues: ValidationIssue[] = []; const name = form.name.trim(); const description = form.description.trim(); if (isSkillFile && !name) issues.push({ field: "name", message: "SKILL.md needs a name." }); if (name && !SLUG_RE.test(name)) { issues.push({ field: "name", message: "Use lowercase letters, numbers and hyphens." }); } if (isSkillFile && !description) { issues.push({ field: "description", message: "SKILL.md needs a description." }); } if (form.allowedToolsPresent && form.allowedTools === null) { issues.push({ field: "allowed-tools", message: "Expected a list — edit in YAML." }); } return issues; } function isSkillMarkdown(fileName: string): boolean { return /(^|\/)skill\.md$/i.test(fileName); } export function FrontmatterPanel({ frontmatterText, hasFrontmatter, fileName, skillSlug, readOnly = false, onChange, className, }: FrontmatterPanelProps) { const isSkillFile = isSkillMarkdown(fileName); // `yamlText` is the canonical raw block — always equal to whatever we've last // emitted (or the original text before any edit). `form` is authoritative only // while in Fields mode. Both initialize from props; the caller keys us per file. const [present, setPresent] = useState(hasFrontmatter); const [yamlText, setYamlText] = useState(frontmatterText); const initialAnalysis = useMemo(() => analyzeFrontmatterBlock(frontmatterText), [frontmatterText]); const [form, setForm] = useState(() => buildFormModel(initialAnalysis.parsed)); const [mode, setMode] = useState( hasFrontmatter && initialAnalysis.canRoundTrip ? "fields" : "yaml", ); const [open, setOpen] = useState(false); const analysis = useMemo(() => analyzeFrontmatterBlock(yamlText), [yamlText]); const canUseFields = present && analysis.canRoundTrip; const effectiveMode: FrontmatterMode = mode === "fields" && !canUseFields ? "yaml" : mode; const validation = useMemo( () => (effectiveMode === "fields" ? collectValidation(form, isSkillFile) : []), [effectiveMode, form, isSkillFile], ); const emit = useCallback( (nextRaw: string, nextPresent: boolean) => { onChange({ frontmatterText: nextRaw, hasFrontmatter: nextPresent }); }, [onChange], ); const commitForm = useCallback( (nextForm: FormModel) => { setForm(nextForm); let nextRaw: string; try { nextRaw = serializeForm(nextForm); } catch { // Unsupported key/value (e.g. a metadata key with a colon) — drop this // edit rather than emit corrupt YAML. return; } setYamlText(nextRaw); emit(nextRaw, true); }, [emit], ); const handleYamlChange = useCallback( (next: string) => { setYamlText(next); emit(next, true); }, [emit], ); const switchMode = useCallback( (next: FrontmatterMode) => { if (next === "fields") { if (!canUseFields) return; setForm(buildFormModel(parseFrontmatterFields(yamlText))); } setMode(next); }, [canUseFields, yamlText], ); const addFrontmatter = useCallback(() => { const seed: FormModel = { hasName: true, name: isSkillFile ? (skillSlug ?? "").trim() : "", hasDescription: isSkillFile, description: "", allowedToolsPresent: false, allowedTools: [], metadataPresent: false, metadataComplex: null, metaRows: [], unknown: [], }; setPresent(true); setMode("fields"); setOpen(true); setForm(seed); const nextRaw = serializeForm(seed); setYamlText(nextRaw); emit(nextRaw, true); }, [emit, isSkillFile, skillSlug]); const summary = useMemo(() => buildSummary(analysis.parsed), [analysis.parsed]); const warningCount = validation.length; const chevron = open ? (