fix: parse skill frontmatter as YAML (#25610)

This commit is contained in:
Michael Suchacz
2026-05-22 15:09:30 +02:00
committed by GitHub
parent 15ada66e14
commit bdf2698fcd
5 changed files with 223 additions and 87 deletions
@@ -98,7 +98,7 @@ describe("parsePersonalSkillMarkdown", () => {
it("parses SKILL.md frontmatter and body", () => {
expect(
parsePersonalSkillMarkdown(
'---\nname: test-skill\ndescription: "Does a thing"\n---\n\nUse this skill.',
'---\nname: "test-skill"\ndescription: "Does a thing"\n---\n\nUse this skill.',
),
).toEqual({
name: "test-skill",
@@ -107,14 +107,62 @@ describe("parsePersonalSkillMarkdown", () => {
});
});
it("uses backend-compatible parsing for YAML-comment-sensitive values", () => {
it("parses folded YAML description values", () => {
expect(
parsePersonalSkillMarkdown(
[
"---",
"name: brainstorming",
"description: >",
" Use before any creative work: features, components, functionality changes,",
" or behavior modifications. Turns ideas into approved designs through",
" collaborative dialog. Hard gate: no implementation action until the",
" design is presented and approved.",
"---",
"Use this skill.",
].join("\n"),
),
).toEqual({
name: "brainstorming",
description: [
"Use before any creative work: features, components, functionality changes,",
"or behavior modifications. Turns ideas into approved designs through",
"collaborative dialog. Hard gate: no implementation action until the",
"design is presented and approved.",
].join(" "),
body: "Use this skill.",
});
});
it("uses YAML comment semantics in frontmatter", () => {
expect(
parsePersonalSkillMarkdown(
"---\nname: test-skill\ndescription: Build # test\n---\nBody",
),
).toEqual({
name: "test-skill",
description: "Build # test",
description: "Build",
body: "Body",
});
});
it("rejects non-string frontmatter fields", () => {
expect(() =>
parsePersonalSkillMarkdown("---\nname: null\n---\nBody"),
).toThrow("Skill name must be a string.");
expect(() =>
parsePersonalSkillMarkdown(
"---\nname: test-skill\ndescription: null\n---\nBody",
),
).toThrow("Skill description must be a string.");
});
it("allows whitespace around frontmatter delimiters", () => {
expect(
parsePersonalSkillMarkdown(" --- \nname: test-skill\n --- \nBody"),
).toEqual({
name: "test-skill",
description: "",
body: "Body",
});
});
@@ -207,6 +255,29 @@ describe("buildPersonalSkillMarkdown", () => {
).toBe("---\nname: test-skill\n---\nUse this skill.\n");
});
it("quotes skill names that YAML would otherwise coerce", () => {
const content = buildPersonalSkillMarkdown({
name: "true",
description: "",
body: "Use this skill.",
});
expect(content).toContain('name: "true"');
expect(parsePersonalSkillMarkdown(content)).toMatchObject({
name: "true",
});
const numericNameContent = buildPersonalSkillMarkdown({
name: "123",
description: "",
body: "Use this skill.",
});
expect(numericNameContent).toContain('name: "123"');
expect(parsePersonalSkillMarkdown(numericNameContent)).toMatchObject({
name: "123",
});
});
it("escapes quoted description values", () => {
const content = buildPersonalSkillMarkdown({
name: "test-skill",
@@ -1,3 +1,4 @@
import frontMatter from "front-matter";
import type * as TypesGen from "#/api/typesGenerated";
export const PERSONAL_SKILL_MAX_SIZE_BYTES = 64 * 1024;
@@ -87,34 +88,26 @@ export const filterPersonalSkills = (
class PersonalSkillMarkdownError extends Error {}
const unquoteFrontmatterScalar = (value: string): string => {
if (value.length < 2) {
return value;
const frontmatterStringField = (
attributes: Record<string, unknown>,
key: "name" | "description",
): string => {
const value = attributes[key];
if (value === undefined) {
return "";
}
const first = value[0];
const last = value[value.length - 1];
if (first !== last) {
return value;
if (typeof value !== "string") {
throw new PersonalSkillMarkdownError(`Skill ${key} must be a string.`);
}
const inner = value.slice(1, -1);
if (first === '"') {
return inner.replaceAll('\\"', '"').replaceAll("\\\\", "\\");
}
if (first === "'") {
return inner;
}
return value;
return value.replace(/[\r\n]+$/, "");
};
// This parser is only for projecting SKILL.md content into form fields.
// The API reparses and validates saved content on submit, so this mirrors the
// backend scalar subset instead of accepting full YAML semantics.
// The API re-validates on submit; this only projects content into form fields.
export const parsePersonalSkillMarkdown = (
content: string,
): PersonalSkillFormValues => {
const lines = content.replace(/^\uFEFF/, "").split("\n");
const normalizedContent = content.replace(/^\uFEFF/, "");
const lines = normalizedContent.split("\n");
if (lines[0]?.trim() !== "---") {
throw new PersonalSkillMarkdownError(
"Missing opening frontmatter delimiter.",
@@ -130,28 +123,24 @@ export const parsePersonalSkillMarkdown = (
);
}
let name = "";
let description = "";
for (const line of lines.slice(1, closingIndex)) {
const separatorIndex = line.indexOf(":");
if (separatorIndex < 0) {
continue;
const parseableContent = [
"---",
...lines.slice(1, closingIndex),
"---",
...lines.slice(closingIndex + 1),
].join("\n");
const parsed = (() => {
try {
return frontMatter<Record<string, unknown>>(parseableContent);
} catch (error) {
const message = error instanceof Error ? error.message : "unknown error";
throw new PersonalSkillMarkdownError(`Invalid frontmatter: ${message}`);
}
const key = line.slice(0, separatorIndex).trim().toLowerCase();
const value = unquoteFrontmatterScalar(
line.slice(separatorIndex + 1).trim(),
);
if (key === "name") {
name = value;
} else if (key === "description") {
description = value;
}
}
})();
const body = lines
.slice(closingIndex + 1)
.join("\n")
.trim();
const name = frontmatterStringField(parsed.attributes, "name");
const description = frontmatterStringField(parsed.attributes, "description");
const body = parsed.body.trim();
if (!name) {
throw new PersonalSkillMarkdownError("Skill name is required.");
@@ -185,6 +174,14 @@ const frontmatterLineValue = (value: string): string =>
const frontmatterStringValue = (value: string): string =>
`"${frontmatterLineValue(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
const frontmatterNameValue = (value: string): string => {
const lineValue = frontmatterLineValue(value);
if (/^(?:true|false|null)$/.test(lineValue) || /^[0-9]/.test(lineValue)) {
return frontmatterStringValue(lineValue);
}
return lineValue;
};
export const isValidPersonalSkillDescription = (description: string): boolean =>
getPersonalSkillContentSizeBytes(description) <=
PERSONAL_SKILL_MAX_DESCRIPTION_BYTES;
@@ -192,7 +189,7 @@ export const isValidPersonalSkillDescription = (description: string): boolean =>
export const buildPersonalSkillMarkdown = (
values: PersonalSkillFormValues,
): string => {
const name = frontmatterLineValue(values.name);
const name = frontmatterNameValue(values.name);
const description = frontmatterLineValue(values.description);
const body = values.body.trim();
const frontmatter = ["---", `name: ${name}`];