diff --git a/packages/core/src/skills/skillLoader.test.ts b/packages/core/src/skills/skillLoader.test.ts index 3fe88c3443..9fa20bd70c 100644 --- a/packages/core/src/skills/skillLoader.test.ts +++ b/packages/core/src/skills/skillLoader.test.ts @@ -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'); + }); }); diff --git a/packages/core/src/skills/skillLoader.ts b/packages/core/src/skills/skillLoader.ts index d41b464496..99c7a08ceb 100644 --- a/packages/core/src/skills/skillLoader.ts +++ b/packages/core/src/skills/skillLoader.ts @@ -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 {