UNPKG

@tanstack/ai-skills

Version:

Portable Agent Skills (SKILL.md) as a first-class chat() middleware for TanStack AI.

248 lines (224 loc) • 7.71 kB
/** * Lenient `SKILL.md` frontmatter parsing (spec §7). * * Other clients emit malformed frontmatter — most commonly unquoted colons in * descriptions — so the default is lenient. We hand-roll a tiny parser rather * than pull in a YAML dependency: SKILL.md frontmatter is a flat key/value * block (plus block scalars for `description` and simple lists for * `allowedTools`), and taking `value = rest-of-line-after-first-colon` handles * the unquoted-colon case in a single pass — no quote-and-retry needed. * * | Condition | Behavior | * |----------------------|---------------------------------| * | name/dir mismatch | warn, load | * | name over 64 chars | warn, load | * | invalid name chars | warn, load | * | missing `description`| throw (caller skips) | * | no frontmatter | throw (caller skips) | * * `strict: true` promotes every warning to a thrown error. */ import type { SkillMetadata } from './types' export interface ParseWarning { code: 'name-dir-mismatch' | 'name-too-long' | 'name-invalid-chars' message: string } export interface ParsedSkill { metadata: SkillMetadata /** frontmatter stripped. */ body: string warnings: Array<ParseWarning> } export class SkillParseError extends Error { override name = 'SkillParseError' } const NAME_RE = /^[a-z0-9-]+$/ /** Split leading `---` frontmatter from the body. Returns null if absent. */ function splitFrontmatter( raw: string, ): { frontmatter: string; body: string } | null { // Tolerate a BOM and leading blank lines before the opening fence. const text = raw.replace(/^/, '') const match = /^\s*---\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n([\s\S]*))?$/.exec( text, ) if (!match) return null return { frontmatter: match[1] ?? '', body: match[2] ?? '' } } /** Parse the flat frontmatter block into raw string/list/map values. */ function parseBlock(frontmatter: string): Record<string, unknown> { const lines = frontmatter.split(/\r?\n/) const out: Record<string, unknown> = {} let i = 0 const indentOf = (s: string) => s.length - s.trimStart().length while (i < lines.length) { const line = lines[i] if (line === undefined) break if (line.trim() === '' || line.trimStart().startsWith('#')) { i++ continue } // Top-level keys are unindented. if (indentOf(line) > 0) { i++ continue } const colon = line.indexOf(':') if (colon === -1) { i++ continue } const key = line.slice(0, colon).trim() const value = line.slice(colon + 1).trim() // Block scalar: `>` (folded) or `|` (literal), with optional chomp/indent. if (value === '>' || value === '|' || /^[>|][+-]?\d*$/.test(value)) { const folded = value.startsWith('>') const collected: Array<string> = [] i++ while (i < lines.length) { const next = lines[i] if (next === undefined) break if (next.trim() !== '' && indentOf(next) === 0) break collected.push(next) i++ } // Strip the common leading indentation of the block. const nonEmpty = collected.filter((l) => l.trim() !== '') const minIndent = nonEmpty.length ? Math.min(...nonEmpty.map(indentOf)) : 0 const stripped = collected.map((l) => l.slice(minIndent)) out[key] = folded ? stripped.join(' ').replace(/\s+/g, ' ').trim() : stripped.join('\n').trim() continue } // Empty value → indented children: a block list (`- x`) or a nested map // (`k: v`), one level deep. if (value === '') { const items: Array<string> = [] const map: Record<string, string> = {} let j = i + 1 while (j < lines.length) { const next = lines[j] if (next === undefined) break if (next.trim() === '') { j++ continue } if (indentOf(next) === 0) break const t = next.trim() if (t.startsWith('- ')) { items.push(unquote(t.slice(2).trim())) } else { const c = t.indexOf(':') if (c === -1) break map[t.slice(0, c).trim()] = unquote(t.slice(c + 1).trim()) } j++ } if (items.length) out[key] = items else if (Object.keys(map).length) out[key] = map else out[key] = '' i = Math.max(j, i + 1) continue } // Inline list `[a, b]`. if (value.startsWith('[') && value.endsWith(']')) { out[key] = value .slice(1, -1) .split(',') .map((s) => unquote(s.trim())) .filter((s) => s !== '') i++ continue } out[key] = unquote(value) i++ } return out } function unquote(s: string): string { if ( (s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'")) ) { return s.slice(1, -1) } return s } function asStringMap(value: unknown): Record<string, string> | undefined { if (typeof value !== 'object' || value === null) return undefined const out: Record<string, string> = {} for (const [k, v] of Object.entries(value)) { if (typeof v === 'string') out[k] = v } return Object.keys(out).length ? out : undefined } /** * Parse a `SKILL.md`. Throws {@link SkillParseError} for cases the spec says to * skip (no frontmatter, missing description). Non-fatal issues are returned as * `warnings`; `strict` turns them into throws. */ export function parseSkill( raw: string, opts: { dirName?: string; strict?: boolean } = {}, ): ParsedSkill { const split = splitFrontmatter(raw) if (!split) { throw new SkillParseError('SKILL.md has no frontmatter block') } const block = parseBlock(split.frontmatter) const name = typeof block.name === 'string' ? block.name : undefined const description = typeof block.description === 'string' ? block.description : undefined if (!description) { throw new SkillParseError('SKILL.md is missing a `description`') } const warnings: Array<ParseWarning> = [] const effectiveName = name ?? opts.dirName ?? '' if (name && opts.dirName && name !== opts.dirName) { warnings.push({ code: 'name-dir-mismatch', message: `skill name "${name}" does not match directory "${opts.dirName}"`, }) } if (effectiveName.length > 64) { warnings.push({ code: 'name-too-long', message: `skill name "${effectiveName}" exceeds 64 characters`, }) } if (effectiveName && !NAME_RE.test(effectiveName)) { warnings.push({ code: 'name-invalid-chars', message: `skill name "${effectiveName}" contains characters outside [a-z0-9-]`, }) } if (opts.strict && warnings.length) { throw new SkillParseError(warnings.map((w) => w.message).join('; ')) } const metadata: SkillMetadata = { name: effectiveName, description, ...(typeof block.license === 'string' && { license: block.license }), ...(typeof block.compatibility === 'string' && { compatibility: block.compatibility, }), ...(Array.isArray(block.allowedTools) && { allowedTools: block.allowedTools.filter( (t): t is string => typeof t === 'string', ), }), ...((): { metadata?: Record<string, string> } => { const m = asStringMap(block.metadata) return m ? { metadata: m } : {} })(), } return { metadata, body: split.body.trim(), warnings } } /** Strip the frontmatter block, returning just the body. */ export function stripFrontmatter(raw: string): string { const split = splitFrontmatter(raw) return split ? split.body.trim() : raw.trim() }