diff --git a/packages/core/src/skills/skillLoader.test.ts b/packages/core/src/skills/skillLoader.test.ts index 9f46f9ae4c..7c74ff2f37 100644 --- a/packages/core/src/skills/skillLoader.test.ts +++ b/packages/core/src/skills/skillLoader.test.ts @@ -100,4 +100,98 @@ describe('skillLoader', () => { expect(skills).toEqual([]); expect(coreEvents.emitFeedback).not.toHaveBeenCalled(); }); + + it('should parse skill with colon in description (issue #16323)', async () => { + const skillDir = path.join(testRootDir, 'colon-skill'); + await fs.mkdir(skillDir, { recursive: true }); + const skillFile = path.join(skillDir, 'SKILL.md'); + await fs.writeFile( + skillFile, + `--- +name: foo +description: Simple story generation assistant for fiction writing. Use for creating characters, scenes, storylines, and prose. Trigger words: character, scene, storyline, story, prose, fiction, writing. +--- +# Instructions +Do something. +`, + ); + + const skills = await loadSkillsFromDir(testRootDir); + + expect(skills).toHaveLength(1); + expect(skills[0].name).toBe('foo'); + expect(skills[0].description).toContain('Trigger words:'); + }); + + it('should parse skill with multiple colons in description', async () => { + const skillDir = path.join(testRootDir, 'multi-colon-skill'); + await fs.mkdir(skillDir, { recursive: true }); + const skillFile = path.join(skillDir, 'SKILL.md'); + await fs.writeFile( + skillFile, + `--- +name: multi-colon +description: Use this for tasks like: coding, reviewing, testing. Keywords: async, await, promise. +--- +# Instructions +Do something. +`, + ); + + const skills = await loadSkillsFromDir(testRootDir); + + expect(skills).toHaveLength(1); + expect(skills[0].name).toBe('multi-colon'); + expect(skills[0].description).toContain('tasks like:'); + expect(skills[0].description).toContain('Keywords:'); + }); + + it('should parse skill with quoted YAML description (backward compatibility)', async () => { + const skillDir = path.join(testRootDir, 'quoted-skill'); + await fs.mkdir(skillDir, { recursive: true }); + const skillFile = path.join(skillDir, 'SKILL.md'); + await fs.writeFile( + skillFile, + `--- +name: quoted-skill +description: "A skill with colons: like this one: and another." +--- +# Instructions +Do something. +`, + ); + + const skills = await loadSkillsFromDir(testRootDir); + + expect(skills).toHaveLength(1); + expect(skills[0].name).toBe('quoted-skill'); + expect(skills[0].description).toBe( + 'A skill with colons: like this one: and another.', + ); + }); + + it('should parse skill with multi-line YAML description', async () => { + const skillDir = path.join(testRootDir, 'multiline-skill'); + await fs.mkdir(skillDir, { recursive: true }); + const skillFile = path.join(skillDir, 'SKILL.md'); + await fs.writeFile( + skillFile, + `--- +name: multiline-skill +description: + Expertise in reviewing code for style, security, and performance. Use when the + user asks for "feedback," a "review," or to "check" their changes. +--- +# Instructions +Do something. +`, + ); + + const skills = await loadSkillsFromDir(testRootDir); + + expect(skills).toHaveLength(1); + expect(skills[0].name).toBe('multiline-skill'); + expect(skills[0].description).toContain('Expertise in reviewing code'); + expect(skills[0].description).toContain('check'); + }); }); diff --git a/packages/core/src/skills/skillLoader.ts b/packages/core/src/skills/skillLoader.ts index 995d8160f0..f25d55f08b 100644 --- a/packages/core/src/skills/skillLoader.ts +++ b/packages/core/src/skills/skillLoader.ts @@ -32,6 +32,74 @@ export interface SkillDefinition { export const FRONTMATTER_REGEX = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n([\s\S]*))?/; +/** + * Parses frontmatter content using YAML with a fallback to simple key-value parsing. + * This handles cases where description contains colons that would break YAML parsing. + */ +function parseFrontmatter( + content: string, +): { name: string; description: string } | null { + try { + const parsed = yaml.load(content); + if (parsed && typeof parsed === 'object') { + const { name, description } = parsed as Record; + if (typeof name === 'string' && typeof description === 'string') { + return { name, description }; + } + } + } catch (yamlError) { + debugLogger.debug( + 'YAML frontmatter parsing failed, falling back to simple parser:', + yamlError, + ); + } + + return parseSimpleFrontmatter(content); +} + +/** + * Simple frontmatter parser that extracts name and description fields. + * Handles cases where values contain colons that would break YAML parsing. + */ +function parseSimpleFrontmatter( + content: string, +): { name: string; description: string } | null { + const lines = content.split(/\r?\n/); + let name: string | undefined; + let description: string | undefined; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + if (line.startsWith('name:')) { + name = line.substring(5).trim(); + continue; + } + + if (line.startsWith('description:')) { + const descLines = [line.substring(12).trim()]; + + while (i + 1 < lines.length) { + const nextLine = lines[i + 1]; + if (nextLine.match(/^[ \t]+\S/)) { + descLines.push(nextLine.trim()); + i++; + } else { + break; + } + } + + description = descLines.filter(Boolean).join(' '); + continue; + } + } + + if (name !== undefined && description !== undefined) { + return { name, description }; + } + return null; +} + /** * Discovers and loads all skills in the provided directory. */ @@ -92,19 +160,14 @@ export async function loadSkillFromFile( return null; } - const frontmatter = yaml.load(match[1]); - if (!frontmatter || typeof frontmatter !== 'object') { - return null; - } - - const { name, description } = frontmatter as Record; - if (typeof name !== 'string' || typeof description !== 'string') { + const frontmatter = parseFrontmatter(match[1]); + if (!frontmatter) { return null; } return { - name, - description, + name: frontmatter.name, + description: frontmatter.description, location: filePath, body: match[2].trim(), };