feat: support markdown agent format (.md with YAML frontmatter) in mode loader (#1267)

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
This commit is contained in:
James F
2026-06-10 19:49:11 +08:00
committed by GitHub
parent bee711f431
commit 83e891d7b2
2 changed files with 48 additions and 3 deletions

View File

@@ -14,6 +14,39 @@ let currentModeSlug: string | null = null
let customModes: CCBMode[] | null = null let customModes: CCBMode[] | null = null
const modeListeners = new Set<() => void>() 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<string, unknown>
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<string, unknown>,
body: parts.slice(2).join('---').trim(),
}
}
function loadCustomModes(): CCBMode[] { function loadCustomModes(): CCBMode[] {
if (customModes !== null) return customModes if (customModes !== null) return customModes
customModes = [] customModes = []
@@ -23,12 +56,22 @@ function loadCustomModes(): CCBMode[] {
mkdirSync(modesDir, { recursive: true }) mkdirSync(modesDir, { recursive: true })
} }
const files = readdirSync(modesDir).filter( 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) { for (const file of files) {
try { try {
const raw = readFileSync(join(modesDir, file), 'utf-8') const raw = readFileSync(join(modesDir, file), 'utf-8')
const data = parseYaml(raw) as Record<string, unknown> let data: Record<string, unknown>
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<string, unknown>
}
if (!data.slug || !data.name) continue if (!data.slug || !data.name) continue
customModes.push({ customModes.push({
name: String(data.name), name: String(data.name),
@@ -36,6 +79,7 @@ function loadCustomModes(): CCBMode[] {
description: String(data.description || ''), description: String(data.description || ''),
icon: String(data.icon || '🔧'), icon: String(data.icon || '🔧'),
systemPrompt: String(data.system_prompt || ''), systemPrompt: String(data.system_prompt || ''),
model: data.model ? String(data.model) : undefined,
ui: { ui: {
accentColor: String( accentColor: String(
(data.ui as Record<string, unknown>)?.accent_color || '#00D4AA', (data.ui as Record<string, unknown>)?.accent_color || '#00D4AA',
@@ -62,7 +106,7 @@ function loadCustomModes(): CCBMode[] {
}, },
}) })
} catch { } catch {
// skip invalid yaml files // skip invalid yaml or markdown files
} }
} }
} catch { } catch {

View File

@@ -6,6 +6,7 @@ export interface CCBMode {
description: string description: string
icon: string icon: string
systemPrompt: string systemPrompt: string
model?: string
ui: { ui: {
accentColor: string accentColor: string
promptPrefix: string promptPrefix: string