fix(core): improve SKILL.md frontmatter parsing robustness

- Support UTF-8 BOM at the start of SKILL.md files.
- Support optional trailing spaces after frontmatter markers.
- Improve simple parser fallback to be case-insensitive and support spaces before colons.
- Prevent simple parser from swallowing subsequent keys into descriptions.
- Added unit tests for identified edge cases.

Fixes https://github.com/google-gemini/gemini-cli/issues/25693
This commit is contained in:
Taylor Mullen 2026-04-20 20:30:43 -07:00
parent a38e2f0048
commit 54b3642706
2 changed files with 80 additions and 5 deletions

View file

@ -271,4 +271,76 @@ description: Test sanitization
expect(skills).toHaveLength(1);
expect(skills[0].name).toBe('gke-prs-troubleshooter');
});
it('should handle UTF-8 BOM in SKILL.md', async () => {
const skillDir = path.join(testRootDir, 'bom-skill');
await fs.mkdir(skillDir, { recursive: true });
const skillFile = path.join(skillDir, 'SKILL.md');
await fs.writeFile(
skillFile,
`\uFEFF---\nname: bom-skill\ndescription: A skill with a BOM\n---\n# Instructions\nBody\n`,
);
const skills = await loadSkillsFromDir(testRootDir);
expect(skills).toHaveLength(1);
expect(skills[0].name).toBe('bom-skill');
});
it('should handle trailing spaces after frontmatter markers', async () => {
const skillDir = path.join(testRootDir, 'space-skill');
await fs.mkdir(skillDir, { recursive: true });
const skillFile = path.join(skillDir, 'SKILL.md');
await fs.writeFile(
skillFile,
`--- \nname: space-skill\ndescription: A skill with trailing spaces\n--- \n# Instructions\nBody\n`,
);
const skills = await loadSkillsFromDir(testRootDir);
expect(skills).toHaveLength(1);
expect(skills[0].name).toBe('space-skill');
});
it('should handle space before colon and case-insensitivity in simple parser', async () => {
const skillDir = path.join(testRootDir, 'simple-parser-robustness');
await fs.mkdir(skillDir, { recursive: true });
const skillFile = path.join(skillDir, 'SKILL.md');
// Forces YAML failure with unquoted colon, triggers simple parser
await fs.writeFile(
skillFile,
`---
Name : robust-name
DESCRIPTION : robust:description
---
`,
);
const skills = await loadSkillsFromDir(testRootDir);
expect(skills).toHaveLength(1);
expect(skills[0].name).toBe('robust-name');
expect(skills[0].description).toBe('robust:description');
});
it('should not swallow other keys into description in simple parser', async () => {
const skillDir = path.join(testRootDir, 'simple-parser-swallow');
await fs.mkdir(skillDir, { recursive: true });
const skillFile = path.join(skillDir, 'SKILL.md');
// Forces YAML failure, triggers simple parser
await fs.writeFile(
skillFile,
`---
description: A long description: with a colon
name: my-skill
---
`,
);
const skills = await loadSkillsFromDir(testRootDir);
expect(skills).toHaveLength(1);
expect(skills[0].name).toBe('my-skill');
expect(skills[0].description).toBe('A long description: with a colon');
});
});

View file

@ -32,7 +32,7 @@ export interface SkillDefinition {
}
export const FRONTMATTER_REGEX =
/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n([\s\S]*))?/;
/^\uFEFF?---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n([\s\S]*))?/;
/**
* Parses frontmatter content using YAML with a fallback to simple key-value parsing.
@ -49,6 +49,8 @@ export function parseFrontmatter(
if (typeof name === 'string' && typeof description === 'string') {
return { name, description };
}
// If they are not strings (e.g. number or boolean), fall back to simple parser
// which will treat them as strings.
}
} catch (yamlError) {
debugLogger.debug(
@ -75,22 +77,23 @@ function parseSimpleFrontmatter(
const line = lines[i];
// Match "name:" at the start of the line (optional whitespace)
const nameMatch = line.match(/^\s*name:\s*(.*)$/);
const nameMatch = line.match(/^\s*name\s*:\s*(.*)$/i);
if (nameMatch) {
name = nameMatch[1].trim();
continue;
}
// Match "description:" at the start of the line (optional whitespace)
const descMatch = line.match(/^\s*description:\s*(.*)$/);
const descMatch = line.match(/^\s*description\s*:\s*(.*)$/i);
if (descMatch) {
const descLines = [descMatch[1].trim()];
// Check for multi-line description (indented continuation lines)
while (i + 1 < lines.length) {
const nextLine = lines[i + 1];
// If next line is indented, it's a continuation of the description
if (nextLine.match(/^[ \t]+\S/)) {
// If next line is indented, it's a continuation of the description,
// UNLESS it looks like another key (e.g. "name:")
if (nextLine.match(/^[ \t]+\S/) && !nextLine.match(/^\s*\w+\s*:/)) {
descLines.push(nextLine.trim());
i++;
} else {