Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 38 additions & 3 deletions src/lib/slash-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,10 +139,22 @@ function parseSkillFile(skillDirPath: string, skillName: string): SlashCommand |
return null;
}
const content = fs.readFileSync(skillPath, 'utf-8');
const { data: frontmatter } = safeParseFrontmatter(content);
let name: string = skillName;
let description: string = '';
try {
const { data: frontmatter } = safeParseFrontmatter(content);
name = frontmatter.name || skillName;
description = frontmatter.description || '';
} catch {
// Fallback: SKILL.md may contain YAML-unfriendly characters (e.g., unquoted
// colons or brackets in argument-hint). Extract only name/description via regex.
const fmResult = extractFrontmatterFields(content);
name = fmResult.name || skillName;
description = fmResult.description || '';
}
return {
name: truncateString(frontmatter.name || skillName, MAX_SKILL_NAME_LENGTH),
description: truncateString(frontmatter.description || '', MAX_SKILL_DESCRIPTION_LENGTH),
name: truncateString(name, MAX_SKILL_NAME_LENGTH),
description: truncateString(description, MAX_SKILL_DESCRIPTION_LENGTH),
category: 'skill',
source: 'skill',
filePath: path.relative(process.cwd(), skillPath),
Expand All @@ -153,6 +165,29 @@ function parseSkillFile(skillDirPath: string, skillName: string): SlashCommand |
}
}

/**
* Regex-based fallback to extract name and description from frontmatter.
*
* Used when safeParseFrontmatter() fails due to YAML parse errors (e.g., unquoted
* colons in argument-hint fields). Only extracts the two fields needed for the UI.
*
* @param content - Raw SKILL.md file content
* @returns Object with name and description (empty string if not found)
*/
export function extractFrontmatterFields(content: string): { name: string; description: string } {
const fmMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
if (!fmMatch) {
return { name: '', description: '' };
}
const fmBlock = fmMatch[1];
const nameMatch = fmBlock.match(/^name:\s*(.+)$/m);
const descMatch = fmBlock.match(/^description:\s*(.+)$/m);
return {
name: nameMatch ? nameMatch[1].trim() : '',
description: descMatch ? descMatch[1].trim() : '',
};
}

/**
* Load all slash commands from .claude/commands/*.md
*
Expand Down
90 changes: 90 additions & 0 deletions tests/unit/slash-commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -532,6 +532,96 @@ describe('loadSkills', () => {
});
});

describe('extractFrontmatterFields', () => {
beforeEach(() => {
vi.resetModules();
});

it('should extract name and description from valid frontmatter', async () => {
const { extractFrontmatterFields } = await import('@/lib/slash-commands');
const result = extractFrontmatterFields('---\nname: my-skill\ndescription: A skill\n---\nBody');
expect(result.name).toBe('my-skill');
expect(result.description).toBe('A skill');
});

it('should extract fields from frontmatter with YAML-unfriendly characters', async () => {
const { extractFrontmatterFields } = await import('@/lib/slash-commands');
const content =
'---\nname: release\ndescription: Create a new release\nargument-hint: [version-type] (major|minor|patch) or [version] (e.g., 1.2.3)\n---\nBody';
const result = extractFrontmatterFields(content);
expect(result.name).toBe('release');
expect(result.description).toBe('Create a new release');
});

it('should return empty strings when no frontmatter is present', async () => {
const { extractFrontmatterFields } = await import('@/lib/slash-commands');
const result = extractFrontmatterFields('No frontmatter here');
expect(result.name).toBe('');
expect(result.description).toBe('');
});

it('should return empty strings when fields are missing from frontmatter', async () => {
const { extractFrontmatterFields } = await import('@/lib/slash-commands');
const result = extractFrontmatterFields('---\nallowed-tools: Bash\n---\nBody');
expect(result.name).toBe('');
expect(result.description).toBe('');
});
});

describe('loadSkills with YAML-unfriendly frontmatter', () => {
beforeEach(() => {
vi.resetModules();
});

afterEach(() => {
vi.clearAllMocks();
});

it('should load skills even when frontmatter contains YAML-unfriendly characters', async () => {
const testDir = path.resolve(__dirname, '../fixtures/test-yaml-fallback');
const skillDir = path.join(testDir, '.claude', 'skills', 'release');
try {
fs.mkdirSync(skillDir, { recursive: true });
fs.writeFileSync(
path.join(skillDir, 'SKILL.md'),
'---\nname: release\ndescription: Create a new release\nargument-hint: [version-type] (major|minor|patch) or [version] (e.g., 1.2.3)\n---\nBody'
);

const { loadSkills } = await import('@/lib/slash-commands');
const skills = await loadSkills(testDir);

expect(skills).toHaveLength(1);
expect(skills[0].name).toBe('release');
expect(skills[0].description).toBe('Create a new release');
expect(skills[0].category).toBe('skill');
expect(skills[0].source).toBe('skill');
} finally {
fs.rmSync(testDir, { recursive: true, force: true });
}
});

it('should fallback to directory name when regex extraction finds no name', async () => {
const testDir = path.resolve(__dirname, '../fixtures/test-yaml-fallback-noname');
const skillDir = path.join(testDir, '.claude', 'skills', 'my-tool');
try {
fs.mkdirSync(skillDir, { recursive: true });
// Frontmatter with no name field but with YAML-unfriendly content
fs.writeFileSync(
path.join(skillDir, 'SKILL.md'),
'---\nargument-hint: [a] (e.g., 1.2.3)\n---\nBody'
);

const { loadSkills } = await import('@/lib/slash-commands');
const skills = await loadSkills(testDir);

expect(skills).toHaveLength(1);
expect(skills[0].name).toBe('my-tool');
} finally {
fs.rmSync(testDir, { recursive: true, force: true });
}
});
});

describe('safeParseFrontmatter', () => {
beforeEach(() => {
vi.resetModules();
Expand Down