SPB Git

Repository Public MIT

skillmd.ts 4.1 KB 70 lines typescript

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
Files main
Menu

Theme