mirror of
https://github.com/google-gemini/gemini-cli
synced 2026-04-21 13:37:17 +00:00
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:
parent
a38e2f0048
commit
54b3642706
2 changed files with 80 additions and 5 deletions
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue