From 83e891d7b27b293536df58326a77ee67fc115927 Mon Sep 17 00:00:00 2001 From: James F <47167674+GhostDragon124@users.noreply.github.com> Date: Wed, 10 Jun 2026 19:49:11 +0800 Subject: [PATCH] feat: support markdown agent format (.md with YAML frontmatter) in mode loader (#1267) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the mode loader to accept .md files alongside .yaml/.yml in ~/.claude/modes/. Markdown files use YAML frontmatter for metadata and the body as systemPrompt — the same format supported by OpenCode, Claude Code agents, and Cursor rules. .md data is normalized to the same shape as .yaml data, reusing the existing CCBMode mapping with zero code duplication. - Add kebabCase() helper for slug derivation from name - Add parseMarkdownFrontmatter() helper (uses existing yaml package) - .md: body → system_prompt, auto-slug if missing, icon default 🤖 - Add optional model field to CCBMode for cross-tool alignment - Existing .yaml/.yml path: unchanged --- src/modes/store.ts | 50 +++++++++++++++++++++++++++++++++++++++++++--- src/modes/types.ts | 1 + 2 files changed, 48 insertions(+), 3 deletions(-) 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