Repository Public MIT
1import { parse as parseYaml } from "yaml";2import type { Finding } from "./constants";34/** Agent Skills open specification (agentskills.io/specification), verified 2026-11-09. */5export const SKILL_NAME_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;6const SPEC_FIELDS = new Set(["name", "description", "license", "compatibility", "metadata", "allowed-tools"]);78export interface SkillFrontmatter {9 name?: string;10 description?: string;11 license?: string;12 compatibility?: string;13 metadata?: Record<string, unknown>;14 "allowed-tools"?: string;15 [k: string]: unknown;16}1718export function splitFrontmatter(text: string): { frontmatter: string | null; body: string } {19 const src = text.replace(/^/, "");20 const m = /^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/.exec(src);21 if (!m) return { frontmatter: null, body: src };22 return { frontmatter: m[1]!, body: src.slice(m[0].length) };23}2425export function parseSkillMd(text: string, expectedName?: string): { frontmatter: SkillFrontmatter | null; body: string; findings: Finding[] } {26 const findings: Finding[] = [];27 const err = (code: string, message: string) => findings.push({ code, severity: "high", message, path: "SKILL.md", blocking: true });28 const warn = (code: string, message: string) => findings.push({ code, severity: "low", message, path: "SKILL.md", blocking: false });2930 const { frontmatter: raw, body } = splitFrontmatter(text);31 if (raw === null) {32 err("skill-frontmatter-missing", "SKILL.md must start with YAML frontmatter delimited by --- lines");33 return { frontmatter: null, body, findings };34 }35 let fm: SkillFrontmatter;36 try {37 // yaml@2 never executes tags; custom tags resolve to plain data.38 const parsed = parseYaml(raw, { schema: "core", maxAliasCount: 10 });39 if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("frontmatter is not a mapping");40 fm = parsed as SkillFrontmatter;41 } catch (e) {42 err("skill-frontmatter-invalid", `SKILL.md frontmatter is not valid YAML: ${(e as Error).message.split("\n")[0]}`);43 return { frontmatter: null, body, findings };44 }4546 if (typeof fm.name !== "string" || fm.name.length < 1 || fm.name.length > 64 || !SKILL_NAME_RE.test(fm.name)) {47 err("skill-name-invalid", "frontmatter `name` is required: 1-64 characters, lowercase letters, digits and single hyphens, no leading or trailing hyphen");48 } else if (expectedName && fm.name !== expectedName) {49 err("skill-name-mismatch", `frontmatter name "${fm.name}" must equal the artifact slug "${expectedName}" (the spec requires the name to match the skill directory)`);50 }51 if (typeof fm.description !== "string" || fm.description.trim().length < 1 || fm.description.length > 1024) {52 err("skill-description-invalid", "frontmatter `description` is required: 1-1024 characters describing what the skill does and when to use it");53 }54 if (fm.compatibility !== undefined && (typeof fm.compatibility !== "string" || fm.compatibility.length < 1 || fm.compatibility.length > 500)) {55 err("skill-compatibility-invalid", "frontmatter `compatibility` must be a string of 1-500 characters");56 }57 if (fm.license !== undefined && typeof fm.license !== "string") err("skill-license-invalid", "frontmatter `license` must be a string");58 if (fm.metadata !== undefined && (typeof fm.metadata !== "object" || fm.metadata === null || Array.isArray(fm.metadata))) {59 err("skill-metadata-invalid", "frontmatter `metadata` must be a mapping");60 }61 if (fm["allowed-tools"] !== undefined && typeof fm["allowed-tools"] !== "string") {62 err("skill-allowed-tools-invalid", "frontmatter `allowed-tools` must be a space-separated string");63 }64 const extra = Object.keys(fm).filter((k) => !SPEC_FIELDS.has(k));65 if (extra.length) warn("skill-nonstandard-fields", `frontmatter fields outside the open specification (client-specific, may be rejected elsewhere): ${extra.join(", ")}`);66 if (body.trim().length === 0) warn("skill-body-empty", "SKILL.md has no instructions after the frontmatter");67 if (text.split("\n").length > 500) warn("skill-body-long", "SKILL.md exceeds the recommended 500 lines; move detail into references/");68 return { frontmatter: fm, body, findings };69}70