// lib/skills/skill-frontmatter.mjs // Frontmatter reader for skill, command and agent files: the fields the skill // workshop needs, with the line each one is on and the YAML traps it hits. // // Why not lib/util/frontmatter.mjs: that parser deliberately rejects // multi-line strings, and Voyage's agents write `description: |`. This reader // takes plain, quoted and block-scalar (`|`, `>`, with `-`/`+`) values, flow // lists, and indented lists or maps (kept as raw text). It is not a YAML // parser: it reads the subset a SKILL.md uses and reports what would break a // real one. Claude Code loads a skill whose YAML does not parse "with no // fields set", so the description is gone and the skill cannot match; a trap // here is an error, not a style note. // // A value is read as YAML reads it: trailing space on the line is nothing, and // a comment (` #`, or `#` at the start) ends a plain value, a flow list or a // block indicator (`model: opus # pinned` is `opus`). Two comments are still // traps: one that is the whole value (the value is empty), and one that cuts a // plain `description`, the text Claude Code routes on (`Use when issue #42 // …` would route on "Use when issue"); quote the description if "#" belongs to // it. Release review of 6.3.0, rounds 3-5. const KEY_RE = /^([A-Za-z0-9_-]+):(.*)$/; const BLOCK_RE = /^([|>])([+-]?)\s*$/; /** * Split a file into its frontmatter lines and body. * @returns {{ ok: boolean, fmLines: string[], body: string, bodyStartLine: number }} */ export function splitFile(text) { const src = String(text).replace(/^/, ''); const lines = src.split(/\r?\n/); if (lines[0] !== '---') return { ok: false, fmLines: [], body: src, bodyStartLine: 1 }; const end = lines.indexOf('---', 1); if (end < 0) return { ok: false, fmLines: [], body: src, bodyStartLine: 1 }; return { ok: true, fmLines: lines.slice(1, end), body: lines.slice(end + 1).join('\n'), bodyStartLine: end + 2 }; } /** The YAML traps in one plain (unquoted, single-line) scalar value, meant whole. */ export function plainScalarHazards(value) { const out = []; if (/:\s/.test(value) || /:$/.test(value)) out.push('unquoted ": " (YAML reads a mapping and the line fails)'); if (/^#/.test(value)) out.push('"#" at the start is a YAML comment: the value is empty'); if (/\s#/.test(value)) out.push('" #" starts a YAML comment: the rest of the value is cut'); if (/^[`@%*&!|>,?{]/.test(value)) out.push(`a value may not start with "${value[0]}" unquoted`); if (/^\[/.test(value) && !/\]$/.test(value)) out.push('an unclosed flow list'); if (/^-\s/.test(value)) out.push('"- " at the start reads as a list item'); return out; } // Where a YAML comment starts in a value: `#` at the start or after a space or // tab, outside a quoted item (a quote opens only at the start of a token, so // the apostrophe in `don't` is text). -1 when there is none. function commentAt(v) { let q = null; for (let i = 0; i < v.length; i++) { const c = v[i]; if (q) { if (q === '"' && c === '\\') i++; else if (c === q) q = null; continue; } if ((c === '"' || c === "'") && (i === 0 || /[\s,[]/.test(v[i - 1]))) { q = c; continue; } if (c === '#' && (i === 0 || v[i - 1] === ' ' || v[i - 1] === '\t')) return i; } return -1; } // A quoted value ends at its closing quote; after it only a YAML comment may // follow (`name: "x" # pinned`). Inside double quotes a backslash escapes the // next character; inside single quotes '' is one quote. function unquote(v) { const q = v[0]; if (q !== '"' && q !== "'") return null; let end = -1; for (let i = 1; i < v.length; i++) { if (q === '"' && v[i] === '\\') { i++; continue; } if (v[i] !== q) continue; if (q === "'" && v[i + 1] === "'") { i++; continue; } end = i; break; } if (end < 0) return { error: q === '"' ? 'unclosed double quote' : 'unclosed single quote' }; const rest = v.slice(end + 1); if (rest !== '' && !/^\s+#/.test(rest)) return { error: 'text after the closing quote that is not a comment' }; const body = v.slice(0, end + 1); if (q === "'") return { value: body.slice(1, -1).replace(/''/g, "'") }; try { return { value: JSON.parse(body) }; } catch { return { error: 'bad escape in a double-quoted value' }; } } function blockValue(lines, style, chomp) { const nonBlank = lines.filter((l) => l.trim() !== ''); const indent = nonBlank.length ? Math.min(...nonBlank.map((l) => l.match(/^ */)[0].length)) : 0; const stripped = lines.map((l) => l.slice(indent)); while (stripped.length && stripped[stripped.length - 1].trim() === '') stripped.pop(); let text; if (style === '|') text = stripped.join('\n'); else { // Folded: lines join with a space; a blank line is a newline. text = ''; for (const l of stripped) { if (l === '') text += '\n'; else text += (text === '' || text.endsWith('\n') ? '' : ' ') + l; } } if (chomp === '-') return text; return `${text}\n`; } /** * Read the frontmatter of a skill/command/agent file. * @returns {{ ok: boolean, fields: Object, * hazards: {line: number, message: string}[], error?: string }} */ export function readFrontmatterFields(text) { const split = splitFile(text); if (!split.ok) return { ok: false, fields: {}, hazards: [], error: 'no frontmatter: the file must start with a "---" line and close it' }; const { fmLines } = split; const fields = {}; const hazards = []; let i = 0; while (i < fmLines.length) { const line = fmLines[i]; const lineNo = i + 2; // line 1 is the opening --- if (/\t/.test(line)) hazards.push({ line: lineNo, message: 'a tab in frontmatter: YAML forbids tabs for indentation' }); if (line.trim() === '' || line.trimStart().startsWith('#')) { i++; continue; } if (/^\s/.test(line)) { hazards.push({ line: lineNo, message: 'an indented line that belongs to no key' }); i++; continue; } const m = KEY_RE.exec(line); if (!m) { hazards.push({ line: lineNo, message: `not a "key: value" line: ${line.slice(0, 60)}` }); i++; continue; } const key = m[1]; const rest = m[2].trim(); if (m[2] !== '' && !/^[ \t]/.test(m[2])) hazards.push({ line: lineNo, message: `"${key}:" needs a space after the colon` }); if (key in fields) hazards.push({ line: lineNo, message: `duplicate key "${key}"` }); // Collect the indented continuation lines (block scalar, list or map). let j = i + 1; const cont = []; while (j < fmLines.length && (fmLines[j].trim() === '' || /^\s/.test(fmLines[j]))) { cont.push(fmLines[j]); j++; } while (cont.length && cont[cont.length - 1].trim() === '') { cont.pop(); } // A quoted value keeps its own comment rule (unquote); any other value // ends where a comment starts. const quoted = rest[0] === '"' || rest[0] === "'"; const cut = quoted ? -1 : commentAt(rest); const value = cut < 0 ? rest : rest.slice(0, cut).trimEnd(); const block = BLOCK_RE.exec(value); if (block) { fields[key] = { value: blockValue(cont, block[1], block[2]), line: lineNo, style: 'block' }; } else if (value === '') { const items = cont.filter((l) => l.trim() !== ''); if (items.length && items.every((l) => /^\s+-\s/.test(l))) { fields[key] = { value: items.map((l) => l.replace(/^\s+-\s+/, '').trim()), line: lineNo, style: 'list' }; } else { fields[key] = { value: items.join('\n'), line: lineNo, style: items.length ? 'map' : 'empty' }; } if (cut === 0 && !items.length) hazards.push({ line: lineNo, message: `${key}: "#" at the start is a YAML comment: the value is empty` }); } else { if (cont.length) hazards.push({ line: lineNo, message: `"${key}" has a value and indented lines under it` }); const q = unquote(rest); if (q && q.error) { hazards.push({ line: lineNo, message: q.error }); fields[key] = { value: rest, line: lineNo, style: 'broken' }; } else if (q) { fields[key] = { value: q.value, line: lineNo, style: 'quoted' }; } else if (/^\[.*\]$/.test(value)) { const inner = value.slice(1, -1).trim(); fields[key] = { value: inner === '' ? [] : inner.split(',').map((s) => { const t = s.trim(); const u = unquote(t); return u && !u.error ? u.value : t; }), line: lineNo, style: 'flow', }; } else { for (const h of plainScalarHazards(value)) hazards.push({ line: lineNo, message: `${key}: ${h}` }); if (cut > 0 && key === 'description') { hazards.push({ line: lineNo, message: `${key}: " #" starts a YAML comment: the description is cut to "${value}"; quote it if "#" belongs to it` }); } fields[key] = { value, line: lineNo, style: 'plain' }; } } i = j; } return { ok: hazards.length === 0, fields, hazards }; } /** The description of a file as one string ('' when absent). */ export function descriptionOf(text) { const r = readFrontmatterFields(text); const d = r.fields.description; if (!d) return ''; return Array.isArray(d.value) ? d.value.join(' ') : String(d.value); }