Skip to content

Commit 1f44e2b

Browse files
committed
feat: support markdown agent format (.md with YAML frontmatter) in mode loader
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
1 parent bee711f commit 1f44e2b

2 files changed

Lines changed: 48 additions & 3 deletions

File tree

src/modes/store.ts

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,39 @@ let currentModeSlug: string | null = null
1414
let customModes: CCBMode[] | null = null
1515
const modeListeners = new Set<() => void>()
1616

17+
/**
18+
* Converts a human-readable name to a URL-safe slug.
19+
* @example kebabCase('Claude Persona') → 'claude-persona'
20+
*/
21+
function kebabCase(name: string): string {
22+
return name
23+
.toLowerCase()
24+
.replace(/[^a-z0-9]+/g, '-')
25+
.replace(/^-+|-+$/g, '')
26+
}
27+
28+
/**
29+
* Extracts YAML frontmatter and Markdown body from a string.
30+
* Expects the format used by Claude Code SKILL.md, OpenCode agents,
31+
* and Cursor rules: `---` delimited YAML followed by Markdown content.
32+
*
33+
* @throws {Error} If the string does not contain valid `---` delimiters.
34+
* @returns The parsed frontmatter object and the body text.
35+
*/
36+
function parseMarkdownFrontmatter(raw: string): {
37+
frontmatter: Record<string, unknown>
38+
body: string
39+
} {
40+
const parts = raw.split(/^---$/m)
41+
if (parts.length < 3) {
42+
throw new Error('Invalid markdown frontmatter: missing --- delimiters')
43+
}
44+
return {
45+
frontmatter: parseYaml(parts[1]) as Record<string, unknown>,
46+
body: parts.slice(2).join('---').trim(),
47+
}
48+
}
49+
1750
function loadCustomModes(): CCBMode[] {
1851
if (customModes !== null) return customModes
1952
customModes = []
@@ -23,19 +56,30 @@ function loadCustomModes(): CCBMode[] {
2356
mkdirSync(modesDir, { recursive: true })
2457
}
2558
const files = readdirSync(modesDir).filter(
26-
f => f.endsWith('.yaml') || f.endsWith('.yml'),
59+
f => f.endsWith('.yaml') || f.endsWith('.yml') || f.endsWith('.md'),
2760
)
2861
for (const file of files) {
2962
try {
3063
const raw = readFileSync(join(modesDir, file), 'utf-8')
31-
const data = parseYaml(raw) as Record<string, unknown>
64+
let data: Record<string, unknown>
65+
if (file.endsWith('.md')) {
66+
const { frontmatter, body } = parseMarkdownFrontmatter(raw)
67+
data = { ...frontmatter, system_prompt: body }
68+
if (!data.slug) {
69+
data.slug = data.name ? kebabCase(String(data.name)) : ''
70+
}
71+
data.icon = data.icon || '🤖'
72+
} else {
73+
data = parseYaml(raw) as Record<string, unknown>
74+
}
3275
if (!data.slug || !data.name) continue
3376
customModes.push({
3477
name: String(data.name),
3578
slug: String(data.slug),
3679
description: String(data.description || ''),
3780
icon: String(data.icon || '🔧'),
3881
systemPrompt: String(data.system_prompt || ''),
82+
model: data.model ? String(data.model) : undefined,
3983
ui: {
4084
accentColor: String(
4185
(data.ui as Record<string, unknown>)?.accent_color || '#00D4AA',
@@ -62,7 +106,7 @@ function loadCustomModes(): CCBMode[] {
62106
},
63107
})
64108
} catch {
65-
// skip invalid yaml files
109+
// skip invalid yaml or markdown files
66110
}
67111
}
68112
} catch {

src/modes/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export interface CCBMode {
66
description: string
77
icon: string
88
systemPrompt: string
9+
model?: string
910
ui: {
1011
accentColor: string
1112
promptPrefix: string

0 commit comments

Comments
 (0)