diff --git a/src/modes/store.ts b/src/modes/store.ts index f7a21117c..fdd39c60b 100644 --- a/src/modes/store.ts +++ b/src/modes/store.ts @@ -14,6 +14,39 @@ let currentModeSlug: string | null = null let customModes: CCBMode[] | null = null const modeListeners = new Set<() => void>() +/** + * Converts a human-readable name to a URL-safe slug. + * @example kebabCase('Claude Persona') → 'claude-persona' + */ +function kebabCase(name: string): string { + return name + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') +} + +/** + * Extracts YAML frontmatter and Markdown body from a string. + * Expects the format used by Claude Code SKILL.md, OpenCode agents, + * and Cursor rules: `---` delimited YAML followed by Markdown content. + * + * @throws {Error} If the string does not contain valid `---` delimiters. + * @returns The parsed frontmatter object and the body text. + */ +function parseMarkdownFrontmatter(raw: string): { + frontmatter: Record + body: string +} { + const parts = raw.split(/^---$/m) + if (parts.length < 3) { + throw new Error('Invalid markdown frontmatter: missing --- delimiters') + } + return { + frontmatter: parseYaml(parts[1]) as Record, + body: parts.slice(2).join('---').trim(), + } +} + function loadCustomModes(): CCBMode[] { if (customModes !== null) return customModes customModes = [] @@ -23,12 +56,22 @@ function loadCustomModes(): CCBMode[] { mkdirSync(modesDir, { recursive: true }) } const files = readdirSync(modesDir).filter( - f => f.endsWith('.yaml') || f.endsWith('.yml'), + f => f.endsWith('.yaml') || f.endsWith('.yml') || f.endsWith('.md'), ) for (const file of files) { try { const raw = readFileSync(join(modesDir, file), 'utf-8') - const data = parseYaml(raw) as Record + let data: Record + if (file.endsWith('.md')) { + const { frontmatter, body } = parseMarkdownFrontmatter(raw) + data = { ...frontmatter, system_prompt: body } + if (!data.slug) { + data.slug = data.name ? kebabCase(String(data.name)) : '' + } + data.icon = data.icon || '🤖' + } else { + data = parseYaml(raw) as Record + } if (!data.slug || !data.name) continue customModes.push({ name: String(data.name), @@ -36,6 +79,7 @@ function loadCustomModes(): CCBMode[] { description: String(data.description || ''), icon: String(data.icon || '🔧'), systemPrompt: String(data.system_prompt || ''), + model: data.model ? String(data.model) : undefined, ui: { accentColor: String( (data.ui as Record)?.accent_color || '#00D4AA', @@ -62,7 +106,7 @@ function loadCustomModes(): CCBMode[] { }, }) } catch { - // skip invalid yaml files + // skip invalid yaml or markdown files } } } catch { diff --git a/src/modes/types.ts b/src/modes/types.ts index ad49a85ae..c8f5d5a1e 100644 --- a/src/modes/types.ts +++ b/src/modes/types.ts @@ -6,6 +6,7 @@ export interface CCBMode { description: string icon: string systemPrompt: string + model?: string ui: { accentColor: string promptPrefix: string