/**
* skill_parser.ts
* Purpose: Parse SKILL.md into Boundary (YAML frontmatter) and Kernel (Markdown body).
*
* Knife rule:
* - Boundary = YAML between the first '---' line and the next '---' line.
* - Kernel = everything after the closing '---' (verbatim).
*
* No external deps. YAML subset:
* - scalar "key: value"
* - arrays:
* key: [a, b]
* key:
* - a
* - b
*/
export type Boundary = Record<string, unknown>;
export interface SkillDocumentParts {
boundary: Boundary;
kernel: string;
}
function isFence(line: string): boolean {
return line.trim() === '---';
}
function stripCommentPreservingQuotes(line: string): string {
let inSingle = false;
let inDouble = false;
for (let i = 0; i < line.length; i++) {
const ch = line[i];
const prev = i > 0 ? line[i - 1] : '';
if (ch === "'" && !inDouble) inSingle = !inSingle;
if (ch === '"' && !inSingle && prev !== '\\') inDouble = !inDouble;
if (ch === '#' && !inSingle && !inDouble) return line.slice(0, i);
}
return line;
}
function parseInlineArray(raw: string): string[] {
// raw expected like: [a, "b", 'c']
const s = raw.trim();
if (!s.startsWith('[') || !s.endsWith(']')) return [];
const inner = s.slice(1, -1).trim();
if (inner.length === 0) return [];
// split on commas not inside quotes (tiny parser)
const out: string[] = [];
let buf = '';
let inSingle = false;
let inDouble = false;
for (let i = 0; i < inner.length; i++) {
const ch = inner[i];
const prev = i > 0 ? inner[i - 1] : '';
if (ch === "'" && !inDouble) inSingle = !inSingle;
if (ch === '"' && !inSingle && prev !== '\\') inDouble = !inDouble;
if (ch === ',' && !inSingle && !inDouble) {
out.push(buf.trim());
buf = '';
continue;
}
buf += ch;
}
out.push(buf.trim());
return out
.map((x) => x.replace(/^['"]|['"]$/g, '').trim())
.filter((x) => x.length > 0);
}
function parseScalar(raw: string): unknown {
const v = raw.trim();
if (v === 'true') return true;
if (v === 'false') return false;
if (v === 'null') return null;
// number (basic)
if (/^-?\d+(\.\d+)?$/.test(v)) return Number(v);
// quoted string
const m = v.match(/^(['"])(.*)\1$/);
if (m) return m[2];
return v;
}
function parseYamlSubset(yamlText: string): Boundary {
const lines = yamlText.split(/\r?\n/);
const obj: Boundary = {};
let i = 0;
while (i < lines.length) {
const raw = stripCommentPreservingQuotes(lines[i]).trimEnd();
i += 1;
if (raw.trim().length === 0) continue;
// key: value OR key:
const kv = raw.match(/^([A-Za-z0-9_\-\.]+)\s*:\s*(.*)$/);
if (!kv) continue;
const key = kv[1];
const rest = kv[2] ?? '';
// inline array
if (rest.trim().startsWith('[')) {
obj[key] = parseInlineArray(rest.trim());
continue;
}
// block array
if (rest.trim().length === 0) {
// gather indented "- item" lines
const arr: string[] = [];
while (i < lines.length) {
const candidate = stripCommentPreservingQuotes(lines[i]);
const trimmed = candidate.trim();
const m = trimmed.match(/^-\s+(.*)$/);
if (!m) break;
const item = m[1].trim().replace(/^['"]|['"]$/g, '');
if (item.length > 0) arr.push(item);
i += 1;
}
obj[key] = arr;
continue;
}
obj[key] = parseScalar(rest);
}
return obj;
}
export function parseSkillMarkdown(markdown: string): SkillDocumentParts {
const lines = markdown.split(/\r?\n/);
if (lines.length === 0 || !isFence(lines[0])) {
throw new Error('SKILL.md missing opening YAML fence ---');
}
// find closing fence
let fence2 = -1;
for (let i = 1; i < lines.length; i++) {
if (isFence(lines[i])) {
fence2 = i;
break;
}
}
if (fence2 === -1) throw new Error('SKILL.md missing closing YAML fence ---');
const yamlText = lines.slice(1, fence2).join('\n');
const kernel = lines.slice(fence2 + 1).join('\n');
const boundary = parseYamlSubset(yamlText);
return { boundary, kernel };
}