import fs from 'fs'; import path from 'path'; // Per-project artifacts live inside `scripts/` of an installed skill but // belong to the consuming project, not the distributable skill. The build // excludes them from dist, and the harness-sync step preserves them across // the rm+recopy so local state isn't destroyed on every rebuild. // - config.json: legacy live-mode inject target list for existing projects. // New installs write project config at .impeccable/live/config.json instead. export const PER_PROJECT_SCRIPT_ARTIFACTS = new Set(['config.json']); const DETECTOR_BUNDLE_DIR = 'cli/engine'; // Walk the harness-dir skill tree and return any per-project script // artifacts found, ready for restoration after a full sync rm+recopy. // Returns [{ relPath, content: Buffer }], where relPath is relative to // the passed-in rootDir (typically `/skills`). export function stashPerProjectArtifacts(rootDir) { if (!fs.existsSync(rootDir)) return []; const out = []; const walk = (dir) => { for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { const p = path.join(dir, entry.name); if (entry.isDirectory()) { walk(p); continue; } // Only preserve files inside a skill's scripts/ directory. if (path.basename(path.dirname(p)) !== 'scripts') continue; if (PER_PROJECT_SCRIPT_ARTIFACTS.has(entry.name)) { out.push({ relPath: path.relative(rootDir, p), content: fs.readFileSync(p) }); } } }; walk(rootDir); return out; } export function restorePerProjectArtifacts(rootDir, stashed) { for (const { relPath, content } of stashed) { const target = path.join(rootDir, relPath); fs.mkdirSync(path.dirname(target), { recursive: true }); fs.writeFileSync(target, content); } } function readDetectorBundleScripts(rootDir) { const detectorDir = path.join(rootDir, DETECTOR_BUNDLE_DIR); if (!fs.existsSync(detectorDir)) return []; const scripts = []; const walk = (dir) => { for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { const entryPath = path.join(dir, entry.name); if (entry.isDirectory()) { walk(entryPath); continue; } if (!entry.isFile()) continue; const relPath = path.relative(detectorDir, entryPath).split(path.sep).join('/'); scripts.push({ name: `detector/${relPath}`, content: fs.readFileSync(entryPath, 'utf-8'), filePath: entryPath, generated: true, }); } }; walk(detectorDir); return scripts; } function readSkillScripts(scriptsDir) { const scripts = []; const walk = (dir) => { const entries = fs.readdirSync(dir, { withFileTypes: true }) .sort((a, b) => a.name.localeCompare(b.name)); for (const entry of entries) { const entryPath = path.join(dir, entry.name); if (entry.isDirectory()) { walk(entryPath); continue; } if (!entry.isFile()) continue; if (PER_PROJECT_SCRIPT_ARTIFACTS.has(entry.name)) continue; const relPath = path.relative(scriptsDir, entryPath).split(path.sep).join('/'); scripts.push({ name: relPath, content: fs.readFileSync(entryPath, 'utf-8'), filePath: entryPath, }); } }; walk(scriptsDir); return scripts; } /** * Parse frontmatter from markdown content * Returns { frontmatter: object, body: string } */ export function parseFrontmatter(content) { const frontmatterRegex = /^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/; const match = content.match(frontmatterRegex); if (!match) { return { frontmatter: {}, body: content }; } const [, frontmatterText, body] = match; const frontmatter = {}; // Simple YAML parser (handles basic key-value and arrays) const lines = frontmatterText.split(/\r?\n/); let currentKey = null; let currentArray = null; for (const line of lines) { if (!line.trim()) continue; // Calculate indent level const leadingSpaces = line.length - line.trimStart().length; const trimmed = line.trim(); // Array item at level 2 (nested under a key) if (trimmed.startsWith('- ') && leadingSpaces >= 2) { if (currentArray) { if (trimmed.startsWith('- name:')) { // New object in array const obj = {}; obj.name = trimmed.slice(7).trim(); currentArray.push(obj); } else { // Simple string item in array currentArray.push(trimmed.slice(2)); } } continue; } // Property of array object (indented further) if (leadingSpaces >= 4 && currentArray && currentArray.length > 0) { const colonIndex = trimmed.indexOf(':'); if (colonIndex > 0) { const key = trimmed.slice(0, colonIndex).trim(); const value = trimmed.slice(colonIndex + 1).trim(); const lastObj = currentArray[currentArray.length - 1]; lastObj[key] = value === 'true' ? true : value === 'false' ? false : value; } continue; } // Top-level key-value pair if (leadingSpaces === 0) { const colonIndex = trimmed.indexOf(':'); if (colonIndex > 0) { const key = trimmed.slice(0, colonIndex).trim(); const value = trimmed.slice(colonIndex + 1).trim(); const isQuoted = /^(".*"|'.*')$/.test(value); const unquotedValue = isQuoted ? value.slice(1, -1) : value; const shouldCoerceBoolean = key === 'user-invocable' || key === 'user-invokable' || !isQuoted; if (value) { frontmatter[key] = shouldCoerceBoolean ? unquotedValue === 'true' ? true : unquotedValue === 'false' ? false : unquotedValue : unquotedValue; currentKey = key; currentArray = null; } else { // Start of array currentKey = key; currentArray = []; frontmatter[key] = currentArray; } } } } return { frontmatter, body: body.trim() }; } /** * Recursively read all .md files from a directory */ export function readFilesRecursive(dir, fileList = []) { if (!fs.existsSync(dir)) { return fileList; } const files = fs.readdirSync(dir); for (const file of files) { const filePath = path.join(dir, file); const stat = fs.statSync(filePath); if (stat.isDirectory()) { readFilesRecursive(filePath, fileList); } else if (file.endsWith('.md')) { fileList.push(filePath); } } return fileList; } /** * Read and parse the impeccable skill source. * After v3.0 the repo holds exactly one user-invocable skill, flat at skill/. * Returns { skills: [oneEntry] } so downstream array-shaped consumers stay happy. * * The source manifest is `SKILL.src.md`, NOT `SKILL.md`, on purpose: the * `vercel-labs/skills` CLI discovers a skill by finding a literal `SKILL.md` * and copies that directory verbatim. If `skill/SKILL.md` existed, `npx skills` * would install the UNCOMPILED source (unresolved `{{placeholders}}`, no vendored * detector). Naming it `SKILL.src.md` hides it from discovery so the CLI falls * through to a compiled harness dir (`.agents/skills/impeccable`) instead. */ export function readSourceFiles(rootDir) { const skillDir = path.join(rootDir, 'skill'); const skills = []; const skillMdPath = path.join(skillDir, 'SKILL.src.md'); if (!fs.existsSync(skillMdPath)) { return { skills }; } const content = fs.readFileSync(skillMdPath, 'utf-8'); const { frontmatter, body } = parseFrontmatter(content); const references = []; const referenceDir = path.join(skillDir, 'reference'); if (fs.existsSync(referenceDir)) { const refFiles = fs.readdirSync(referenceDir).filter(f => f.endsWith('.md')); for (const refFile of refFiles) { const refPath = path.join(referenceDir, refFile); references.push({ name: path.basename(refFile, '.md'), content: fs.readFileSync(refPath, 'utf-8'), filePath: refPath }); } } // PER_PROJECT_SCRIPT_ARTIFACTS (defined at module top) are excluded from // the distributable skill so the build never bundles one project's state // into another's. const scripts = []; const scriptsDir = path.join(skillDir, 'scripts'); if (fs.existsSync(scriptsDir)) { scripts.push(...readSkillScripts(scriptsDir)); } scripts.push(...readDetectorBundleScripts(rootDir)); const agents = []; const agentsDir = path.join(skillDir, 'agents'); if (fs.existsSync(agentsDir)) { const agentFiles = fs.readdirSync(agentsDir).filter(f => f.endsWith('.md')); for (const agentFile of agentFiles) { const agentPath = path.join(agentsDir, agentFile); const agentContent = fs.readFileSync(agentPath, 'utf-8'); const { frontmatter: agentFrontmatter, body: agentBody } = parseFrontmatter(agentContent); const name = agentFrontmatter.name || path.basename(agentFile, '.md'); const providersRaw = agentFrontmatter.providers; let providers = null; if (Array.isArray(providersRaw)) { providers = providersRaw.map(p => String(p).trim()).filter(Boolean); } else if (typeof providersRaw === 'string' && providersRaw.trim()) { providers = providersRaw.split(',').map(p => p.trim()).filter(Boolean); } agents.push({ name, codexName: agentFrontmatter['codex-name'] || name.replace(/-/g, '_'), claudeName: agentFrontmatter['claude-name'] || name, description: agentFrontmatter.description || '', tools: agentFrontmatter.tools || '', model: agentFrontmatter.model || '', effort: agentFrontmatter.effort || '', maxTurns: agentFrontmatter['max-turns'] ? Number(agentFrontmatter['max-turns']) : '', nicknameCandidates: agentFrontmatter['nickname-candidates'] || [], providers, body: agentBody, filePath: agentPath, }); } } skills.push({ name: frontmatter.name || 'impeccable', description: frontmatter.description || '', license: frontmatter.license || '', compatibility: frontmatter.compatibility || '', metadata: frontmatter.metadata || null, allowedTools: frontmatter['allowed-tools'] || '', userInvocable: frontmatter['user-invocable'] === true || frontmatter['user-invocable'] === 'true', argumentHint: frontmatter['argument-hint'] || '', context: frontmatter.context || null, body, filePath: skillMdPath, references, scripts, agents }); return { skills }; } /** * Ensure directory exists, create if needed */ export function ensureDir(dirPath) { if (!fs.existsSync(dirPath)) { fs.mkdirSync(dirPath, { recursive: true }); } } /** * Clean directory (remove all contents) */ export function cleanDir(dirPath) { if (fs.existsSync(dirPath)) { fs.rmSync(dirPath, { recursive: true, force: true }); } } /** * Write file with automatic directory creation */ export function writeFile(filePath, content) { const dir = path.dirname(filePath); ensureDir(dir); fs.writeFileSync(filePath, content, 'utf-8'); } /** * Extract DO/DON'T patterns from a skill markdown file, grouped by section * (h3 `### ` headings). Recognizes both formats: * - Markdown bullet form: `**DO**: …` / `**DON'T**: …` * - Prose form: `DO …` / `DO NOT …` * * Defaults to the main impeccable SKILL.md but accepts any relative path so * rules in `cli/engine/detect-antipatterns.mjs` can anchor to register-specific * reference files (e.g. `reference/editorial.md`) via an optional `skillFile` * field. Callers that don't pass `relativePath` get the legacy behavior. * * Returns { patterns: [...], antipatterns: [...] } */ // Curated short-list for the homepage Antidote section. Intentionally // hand-written (not auto-extracted) so the copy stays tight and // editorial. The long-form catalog lives on /slop — this is the teaser. const CURATED_CATEGORIES = [ { name: 'Typography', do: [ 'Pair a distinctive display face with a restrained body face; vary across projects.', 'Use a ≥1.25 scale ratio between hierarchy steps. Flat scales read as bland.', 'Cap body line length at 65–75ch. Wider is fatiguing.', ], dont: [ 'Inter, Roboto, Plex, Fraunces, or any other reflex default. Look further.', 'Monospace as lazy shorthand for "technical."', 'Long passages in uppercase. Reserve all-caps for short labels.', ], }, { name: 'Color & Contrast', do: [ 'Use OKLCH. Reduce chroma near lightness extremes.', 'Tint neutrals toward the brand hue. Chroma 0.005–0.01 is enough.', 'Pick a color strategy before picking colors (Restrained, Committed, Full, Drenched).', ], dont: [ 'Pure #000 or #fff. Always tint.', 'Dark mode + purple-to-cyan gradients. The AI tell.', 'Gradient text via background-clip. Use weight or size for emphasis.', ], }, { name: 'Layout & Space', do: [ 'Vary spacing for rhythm. Tight groupings, generous separations.', 'Use the simplest tool: Flexbox for 1D, Grid for 2D, plain flow often enough.', 'Let whitespace carry hierarchy before reaching for color or scale.', ], dont: [ 'Wrap everything in cards. Nested cards are always wrong.', 'Identical card grids of icon + heading + text, repeated endlessly.', 'The hero-metric template: big number, small label, supporting stats, gradient accent.', ], }, { name: 'Visual Details', do: [ 'Commit to an aesthetic direction and execute it with precision.', 'Use ornament only where it earns its place.', ], dont: [ 'Side-stripe borders (border-left/-right > 1px). The dashboard tell.', 'Glassmorphism everywhere. Rare and purposeful or nothing.', 'Rounded rectangles with generic drop shadows. "Could be any AI output."', ], }, { name: 'Motion', do: [ 'Use transform and opacity. Animate the composited properties only.', 'Ease out with exponential curves (quart / quint / expo).', 'Respect prefers-reduced-motion on every transition.', ], dont: [ 'Animate layout (width, height, padding, margin).', 'Bounce or elastic easing. Feels dated and tacky.', 'Decorative motion for its own sake. Motion should signal state.', ], }, { name: 'Interaction', do: [ 'Use optimistic UI: update immediately, sync later.', 'Design empty states that teach the interface, not just say "nothing here."', 'Progressive disclosure: start simple, reveal sophistication on demand.', ], dont: [ 'Make every button primary. Hierarchy matters.', 'Default to a modal. Exhaust inline alternatives first.', 'Repeat information the user can already see.', ], }, ]; export function readPatterns(_rootDir, _relativePath) { // Hand-curated list — see CURATED_CATEGORIES above. The homepage // Antidote teaser uses this; the full catalog lives on /slop. return { patterns: CURATED_CATEGORIES.map((c) => ({ name: c.name, items: c.do })), antipatterns: CURATED_CATEGORIES.map((c) => ({ name: c.name, items: c.dont })), }; } // Previous SKILL.md parser retained below but disabled; kept as a // reference for how prefix-style extraction used to work. function _legacyReadPatterns(rootDir, relativePath = 'skill/SKILL.src.md') { const skillPath = path.join(rootDir, relativePath); if (!fs.existsSync(skillPath)) { return { patterns: [], antipatterns: [] }; } const content = fs.readFileSync(skillPath, 'utf-8'); const lines = content.split('\n'); const patternsMap = {}; // category -> items[] const antipatternsMap = {}; // category -> items[] let currentSection = null; const pushPattern = (item) => { if (!currentSection) return; if (!patternsMap[currentSection]) patternsMap[currentSection] = []; patternsMap[currentSection].push(item); }; const pushAntipattern = (item) => { if (!currentSection) return; if (!antipatternsMap[currentSection]) antipatternsMap[currentSection] = []; antipatternsMap[currentSection].push(item); }; for (const line of lines) { const trimmed = line.trim(); // Track section headings (### Typography, ### Color & Theme, etc.) if (trimmed.startsWith('### ')) { currentSection = trimmed.slice(4).trim(); // Normalize "Color & Theme" to "Color & Contrast" for consistency if (currentSection === 'Color & Theme') { currentSection = 'Color & Contrast'; } continue; } // Markdown bullet form (legacy): **DO**: ... and **DON'T**: ... if (trimmed.startsWith('**DO**:')) { pushPattern(trimmed.slice(7).trim()); continue; } if (trimmed.startsWith("**DON'T**:")) { pushAntipattern(trimmed.slice(10).trim()); continue; } // XML-block prose form (current). Both space and colon variants: // "DO NOT use ..." / "DO NOT: Use ..." // "DO use ..." / "DO: Use ..." // IMPORTANT: check `DO NOT` BEFORE `DO` so the prefix doesn't get // gobbled by the wrong matcher. if (trimmed.startsWith('DO NOT: ')) { pushAntipattern(trimmed.slice('DO NOT: '.length).trim()); continue; } if (trimmed.startsWith('DO NOT ')) { pushAntipattern(trimmed.slice('DO NOT '.length).trim()); continue; } if (trimmed.startsWith('DO: ')) { pushPattern(trimmed.slice('DO: '.length).trim()); continue; } if (trimmed.startsWith('DO ')) { pushPattern(trimmed.slice('DO '.length).trim()); continue; } } // Convert maps to arrays in consistent order const sectionOrder = ['Typography', 'Color & Contrast', 'Layout & Space', 'Visual Details', 'Motion', 'Interaction', 'Responsive', 'UX Writing']; const patterns = []; const antipatterns = []; for (const section of sectionOrder) { if (patternsMap[section] && patternsMap[section].length > 0) { patterns.push({ name: section, items: patternsMap[section] }); } if (antipatternsMap[section] && antipatternsMap[section].length > 0) { antipatterns.push({ name: section, items: antipatternsMap[section] }); } } return { patterns, antipatterns }; } /** * Provider-specific placeholders */ export const PROVIDER_PLACEHOLDERS = { 'claude-code': { model: 'Claude', config_file: 'CLAUDE.md', ask_instruction: 'STOP and call the AskUserQuestion tool to clarify.', command_prefix: '/' }, 'cursor': { model: 'the model', config_file: '.cursorrules', ask_instruction: 'ask the user directly to clarify what you cannot infer.', command_prefix: '/' }, 'gemini': { model: 'Gemini', config_file: 'GEMINI.md', ask_instruction: 'ask the user directly to clarify what you cannot infer.', command_prefix: '/' }, 'codex': { model: 'GPT', config_file: 'AGENTS.md', ask_instruction: "STOP and use Codex's structured user-input/question tool when available; if unavailable, ask directly in chat to clarify what you cannot infer.", command_prefix: '$' }, 'agents': { model: 'the model', config_file: '.github/copilot-instructions.md', ask_instruction: 'ask the user directly to clarify what you cannot infer.', command_prefix: '/' }, 'kiro': { model: 'Claude', config_file: '.kiro/settings.json', ask_instruction: 'ask the user directly to clarify what you cannot infer.', command_prefix: '/' }, opencode: { model: 'Claude', config_file: 'AGENTS.md', ask_instruction: 'STOP and call the `question` tool to clarify.', command_prefix: '/' }, 'pi': { model: 'the model', config_file: 'AGENTS.md', ask_instruction: 'ask the user directly to clarify what you cannot infer.', command_prefix: '/' }, 'qoder': { model: 'the model', config_file: 'AGENTS.md', ask_instruction: 'ask the user directly to clarify what you cannot infer.', command_prefix: '/' }, 'trae': { model: 'the model', config_file: 'RULES.md', ask_instruction: 'ask the user directly to clarify what you cannot infer.', command_prefix: '/' }, 'rovo-dev': { model: 'Rovo Dev', config_file: 'AGENTS.md', ask_instruction: 'ask the user directly to clarify what you cannot infer.', command_prefix: '/' } }; export const PROVIDER_BLOCK_TAGS = new Set([ 'agents', 'claude', 'claude-code', 'codex', 'cursor', 'gemini', 'github', 'kiro', 'opencode', 'pi', 'qoder', 'rovo-dev', 'trae', 'trae-cn', ]); /** * Compile harness-conditional markdown blocks. * * Known provider blocks must be written as standalone tags: * * * Codex-only instructions. * * * Matching blocks keep their body and drop the tags. Non-matching blocks are * removed. Unknown tags are preserved so ordinary markdown/HTML is untouched. */ export function compileProviderBlocks(content, activeTags = []) { const activeTagSet = new Set(activeTags); const providerBlockPattern = /(^|\r?\n)[ \t]*<([a-z][a-z0-9-]*)>[ \t]*\r?\n([\s\S]*?)\r?\n[ \t]*<\/\2>[ \t]*(?=\r?\n|$)/g; let didCompileBlock = false; const compiled = content.replace(providerBlockPattern, (match, prefix, tag, body) => { if (!PROVIDER_BLOCK_TAGS.has(tag)) return match; didCompileBlock = true; return activeTagSet.has(tag) ? `${prefix}${body}` : prefix; }); return didCompileBlock ? compiled.replace(/(?:\r?\n){3,}/g, '\n\n') : compiled; } /** * Strip `` markers from skill markdown. * * External eval tooling can pin each instruction line to a stable ID. * Markers in the source keep that mapping verifiable in lock-step with * the file. Staged SKILL.md files should not expose them, so this strip * runs during the per-provider staging in factory.js. * * Removes the marker plus any leading whitespace on the same line, so * `something. ` becomes `something.` and a standalone * marker line collapses to an empty line that the existing * blank-line normalization in compileProviderBlocks reaps. */ export function stripRuleMarkers(content) { return content.replace(/[ \t]*/g, ''); } /** * Replace all {{placeholder}} tokens with provider-specific values */ function escapeRegex(str) { return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } const EXCLUDED_FROM_SUGGESTIONS = new Set([ 'impeccable', // foundational skill, not a steering command 'teach-impeccable', // deprecated shim 'frontend-design', // deprecated shim ]); // Sub-commands of /impeccable that should appear in {{available_commands}}. // These are the commands that audit/critique/etc. reference when suggesting next steps. const IMPECCABLE_SUB_COMMANDS = [ 'adapt', 'animate', 'audit', 'bolder', 'clarify', 'colorize', 'critique', 'delight', 'distill', 'document', 'harden', 'layout', 'onboard', 'optimize', 'overdrive', 'polish', 'quieter', 'shape', 'typeset', ]; export function replacePlaceholders(content, provider, commandNames = [], allSkillNames = []) { const placeholders = PROVIDER_PLACEHOLDERS[provider] || PROVIDER_PLACEHOLDERS['cursor']; const cmdPrefix = placeholders.command_prefix || '/'; // Build the available_commands list. // After the v3.0 consolidation, commands are sub-commands of /impeccable. // If there's only one user-invocable skill (impeccable), generate sub-command references. // Otherwise fall back to listing skill names (backwards compat for forks). const nonExcluded = commandNames.filter(n => !EXCLUDED_FROM_SUGGESTIONS.has(n)); let commandList; if (nonExcluded.length === 0) { // Single-skill architecture: list sub-commands as /impeccable commandList = IMPECCABLE_SUB_COMMANDS .map(n => `${cmdPrefix}impeccable ${n}`) .join(', '); } else { // Multi-skill architecture (backwards compat) commandList = nonExcluded.map(n => `${cmdPrefix}${n}`).join(', '); } let result = content .replace(/\{\{model\}\}/g, placeholders.model) .replace(/\{\{config_file\}\}/g, placeholders.config_file) .replace(/\{\{ask_instruction\}\}/g, placeholders.ask_instruction) .replace(/\{\{command_prefix\}\}/g, cmdPrefix) .replace(/\{\{available_commands\}\}/g, commandList); // Replace `/skillname` invocations with the correct command prefix for this provider // (e.g., `/normalize` → `$normalize` for Codex) if (cmdPrefix !== '/' && allSkillNames.length > 0) { const sorted = [...allSkillNames].sort((a, b) => b.length - a.length); for (const name of sorted) { result = result.replace( new RegExp(`\\/(?=${escapeRegex(name)}(?:[^a-zA-Z0-9_-]|$))`, 'g'), cmdPrefix ); } } return result; } /** * Decide whether a YAML scalar string value must be quoted to survive parsing. * * Plain (unquoted) YAML scalars cannot contain `: ` or ` #`, cannot start with * a YAML indicator character, cannot look like a boolean/null/number, and * cannot carry leading/trailing whitespace. parseFrontmatter strips surrounding * quotes on input, so we must re-detect the need to quote on output — otherwise * descriptions like "Handles: critique/review..." round-trip into invalid YAML. */ function yamlNeedsQuoting(value) { if (typeof value !== 'string') return false; if (value === '') return true; // Leading or trailing whitespace if (/^\s|\s$/.test(value)) return true; // Starts with a YAML flow/indicator character if (/^[\[\]{},&*!|>'"%@`#]/.test(value)) return true; // Starts with `?`, `:`, or `-` followed by space or end of string if (/^[?:-](\s|$)/.test(value)) return true; // Contains `: ` (ends plain scalar) or ` #` (starts comment), or ends with `:` if (/: |\s#|:$/.test(value)) return true; // Reserved keywords that YAML 1.1 parsers coerce to boolean/null if (/^(true|false|null|yes|no|on|off|~)$/i.test(value)) return true; // Looks like a number if (/^-?\d+(\.\d+)?([eE][+-]?\d+)?$/.test(value)) return true; return false; } function formatYamlScalar(value) { if (typeof value !== 'string') return String(value); if (yamlNeedsQuoting(value)) { return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; } return value; } function appendYamlObject(lines, data, indent = 0) { const space = ' '.repeat(indent); for (const [key, value] of Object.entries(data)) { if (Array.isArray(value)) { lines.push(`${space}${key}:`); for (const item of value) { if (item && typeof item === 'object' && !Array.isArray(item)) { lines.push(`${space} -`); appendYamlObject(lines, item, indent + 4); } else { lines.push(`${space} - ${formatYamlScalar(item)}`); } } } else if (value && typeof value === 'object') { lines.push(`${space}${key}:`); appendYamlObject(lines, value, indent + 2); } else if (typeof value === 'boolean') { lines.push(`${space}${key}: ${value}`); } else { lines.push(`${space}${key}: ${formatYamlScalar(value)}`); } } } /** * Generate YAML frontmatter string */ export function generateYamlFrontmatter(data) { const lines = ['---']; for (const [key, value] of Object.entries(data)) { if (Array.isArray(value)) { lines.push(`${key}:`); for (const item of value) { if (typeof item === 'object') { lines.push(` - name: ${formatYamlScalar(item.name)}`); if (item.description) lines.push(` description: ${formatYamlScalar(item.description)}`); if (item.required !== undefined) lines.push(` required: ${item.required}`); } else { lines.push(` - ${formatYamlScalar(item)}`); } } } else if (typeof value === 'boolean') { lines.push(`${key}: ${value}`); } else { lines.push(`${key}: ${formatYamlScalar(value)}`); } } lines.push('---'); return lines.join('\n'); } /** * Generate a plain YAML document string. */ export function generateYamlDocument(data) { const lines = []; appendYamlObject(lines, data); return lines.join('\n'); }