Skip to content

Commit c4b8464

Browse files
Merge pull request #26 from MartyJRE/feat/claude-rules-directory
feat: support .claude/rules/ directory for scoped Claude Code rules
2 parents ef66926 + 95a7b1c commit c4b8464

8 files changed

Lines changed: 469 additions & 180 deletions

src/cli.ts

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
#!/usr/bin/env node
22

3-
import { existsSync, readFileSync, writeFileSync, appendFileSync, rmSync } from 'fs'
3+
import { existsSync, readFileSync, writeFileSync, appendFileSync, rmSync, statSync } from 'fs'
44
import { join, resolve, dirname } from 'path'
55
import { parseArgs } from 'util'
66
import { importAll, importAgent, exportToAgent, exportAll, exportToCopilot, exportToCursor, exportToCline, exportToWindsurf, exportToZed, exportToCodex, exportToAider, exportToClaudeCode, exportToGemini, exportToQodo, importRoo, exportToRoo, exportToJunie, importOpenCode, exportToOpenCode } from './index.js'
@@ -107,6 +107,7 @@ async function main() {
107107
'.rules',
108108
'AGENTS.md',
109109
'CLAUDE.md',
110+
'.claude/rules/*.md',
110111
'GEMINI.md',
111112
'best_practices.md'
112113
]))
@@ -190,7 +191,7 @@ async function main() {
190191
{ name: 'Zed (.rules)', value: 'zed' },
191192
{ name: 'OpenAI Codex (AGENTS.md)', value: 'codex' },
192193
{ name: 'Aider (CONVENTIONS.md)', value: 'aider' },
193-
{ name: 'Claude Code (CLAUDE.md)', value: 'claude' },
194+
{ name: 'Claude Code (CLAUDE.md + .claude/rules/)', value: 'claude' },
194195
{ name: 'Gemini CLI (GEMINI.md)', value: 'gemini' },
195196
{ name: 'Qodo Merge (best_practices.md)', value: 'qodo' },
196197
{ name: 'Roo Code (.roo/rules/)', value: 'roo' },
@@ -246,6 +247,7 @@ async function main() {
246247
'AGENTS.md',
247248
'CONVENTIONS.md',
248249
'CLAUDE.md',
250+
'.claude/rules/',
249251
'GEMINI.md',
250252
'best_practices.md'
251253
)
@@ -290,9 +292,9 @@ async function main() {
290292
exportedPaths.push('CONVENTIONS.md')
291293
break
292294
case 'claude':
293-
exportPath = join(outputDir, 'CLAUDE.md')
294-
if (!isDryRun) exportToClaudeCode(rules, exportPath, options)
295-
exportedPaths.push('CLAUDE.md')
295+
if (!isDryRun) exportToClaudeCode(rules, outputDir, options)
296+
exportPath = join(outputDir, '.claude/rules/')
297+
exportedPaths.push('CLAUDE.md', '.claude/rules/')
296298
break
297299
case 'gemini':
298300
exportPath = join(outputDir, 'GEMINI.md')
@@ -382,7 +384,7 @@ async function main() {
382384
else if (inputPath.includes('.windsurfrules')) format = 'windsurf'
383385
else if (inputPath.endsWith('.rules')) format = 'zed'
384386
else if (inputPath.endsWith('AGENTS.md')) format = 'codex'
385-
else if (inputPath.endsWith('CLAUDE.md')) format = 'claude'
387+
else if (inputPath.endsWith('CLAUDE.md') || inputPath.includes('.claude/rules')) format = 'claude'
386388
else if (inputPath.endsWith('GEMINI.md')) format = 'gemini'
387389
else if (inputPath.endsWith('CONVENTIONS.md')) format = 'aider'
388390
else if (inputPath.endsWith('best_practices.md')) format = 'qodo'
@@ -398,7 +400,7 @@ async function main() {
398400
console.log(`Input: ${color.path(inputPath)}`)
399401

400402
// Import using appropriate importer
401-
const { importCopilot, importCursor, importCline, importWindsurf, importZed, importCodex, importAider, importClaudeCode, importGemini, importQodo } = await import('./importers.js')
403+
const { importCopilot, importCursor, importCline, importWindsurf, importZed, importCodex, importAider, importClaudeCode, importClaudeCodeRules, importGemini, importQodo } = await import('./importers.js')
402404

403405
let result
404406
switch (format) {
@@ -424,7 +426,9 @@ async function main() {
424426
result = importAider(inputPath)
425427
break
426428
case 'claude':
427-
result = importClaudeCode(inputPath)
429+
result = statSync(inputPath).isDirectory()
430+
? importClaudeCodeRules(inputPath)
431+
: importClaudeCode(inputPath)
428432
break
429433
case 'opencode':
430434
result = importOpenCode(inputPath)

src/exporters.ts

Lines changed: 63 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -385,8 +385,68 @@ export function exportToAider(rules: RuleBlock[], outputPath: string, options?:
385385
writeFileSync(outputPath, fullContent, 'utf-8')
386386
}
387387

388-
export function exportToClaudeCode(rules: RuleBlock[], outputPath: string, options?: ExportOptions): void {
389-
exportSingleFileWithHeaders(rules, outputPath, options)
388+
export function exportToClaudeCode(rules: RuleBlock[], outputDir: string, options?: ExportOptions): void {
389+
const filteredRules = rules.filter(rule => !rule.metadata.private || options?.includePrivate)
390+
391+
const alwaysApplyRules = filteredRules.filter(r => r.metadata.alwaysApply !== false)
392+
const scopedRules = filteredRules.filter(r => r.metadata.alwaysApply === false)
393+
394+
// Always-apply rules → CLAUDE.md
395+
if (alwaysApplyRules.length > 0) {
396+
const mainContent = alwaysApplyRules
397+
.map(rule => {
398+
const header = rule.metadata.description ? `# ${rule.metadata.description}\n\n` : ''
399+
return header + rule.content
400+
})
401+
.join('\n\n')
402+
403+
const claudeMdPath = join(outputDir, 'CLAUDE.md')
404+
ensureDirectoryExists(claudeMdPath)
405+
writeFileSync(claudeMdPath, mainContent + '\n', 'utf-8')
406+
}
407+
408+
// Scoped rules → .claude/rules/ as individual files with frontmatter
409+
if (scopedRules.length > 0) {
410+
const rulesDir = join(outputDir, '.claude', 'rules')
411+
mkdirSync(rulesDir, { recursive: true })
412+
413+
for (const rule of scopedRules) {
414+
let filePath: string
415+
416+
if (rule.metadata.id && rule.metadata.id.includes('/')) {
417+
const parts = rule.metadata.id.split('/')
418+
const fileName = parts.pop() + '.md'
419+
const subDir = join(rulesDir, ...parts)
420+
mkdirSync(subDir, { recursive: true })
421+
filePath = join(subDir, fileName)
422+
} else {
423+
const filename = `${rule.metadata.id || 'rule'}.md`
424+
filePath = join(rulesDir, filename)
425+
}
426+
427+
// Claude Code frontmatter only supports description, globs, alwaysApply
428+
const frontMatter: Record<string, unknown> = {}
429+
430+
if (rule.metadata.description !== undefined && rule.metadata.description !== null) {
431+
frontMatter.description = rule.metadata.description
432+
}
433+
434+
// Map scope → globs when globs is not already set
435+
const globs = rule.metadata.globs ?? (
436+
rule.metadata.scope
437+
? (Array.isArray(rule.metadata.scope) ? rule.metadata.scope : [rule.metadata.scope])
438+
: undefined
439+
)
440+
if (globs !== undefined) {
441+
frontMatter.globs = globs
442+
}
443+
444+
frontMatter.alwaysApply = rule.metadata.alwaysApply ?? false
445+
446+
const mdContent = matter.stringify(rule.content, frontMatter, grayMatterOptions)
447+
writeFileSync(filePath, mdContent, 'utf-8')
448+
}
449+
}
390450
}
391451

392452
export function exportToOpenCode(rules: RuleBlock[], outputPath: string, options?: ExportOptions): void {
@@ -554,7 +614,7 @@ export function exportAll(rules: RuleBlock[], repoPath: string, dryRun = false,
554614
exportToZed(rules, join(repoPath, '.rules'), options)
555615
exportToCodex(rules, join(repoPath, 'AGENTS.md'), options)
556616
exportToAider(rules, join(repoPath, 'CONVENTIONS.md'), options)
557-
exportToClaudeCode(rules, join(repoPath, 'CLAUDE.md'), options)
617+
exportToClaudeCode(rules, repoPath, options)
558618
exportToOpenCode(rules, join(repoPath, 'AGENTS.md'), options)
559619
exportToGemini(rules, join(repoPath, 'GEMINI.md'), options)
560620
exportToQodo(rules, join(repoPath, 'best_practices.md'), options)

src/importers.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,16 @@ export async function importAll(repoPath: string): Promise<ImportResults> {
153153
errors.push({ file: claudeMd, error: String(e) })
154154
}
155155
}
156+
157+
// Check for Claude Code rules directory (.claude/rules/)
158+
const claudeRulesDir = join(repoPath, '.claude', 'rules')
159+
if (existsSync(claudeRulesDir)) {
160+
try {
161+
results.push(importClaudeCodeRules(claudeRulesDir))
162+
} catch (e) {
163+
errors.push({ file: claudeRulesDir, error: String(e) })
164+
}
165+
}
156166

157167
// Check for AGENTS.md (OpenCode)
158168
const opencodeMd = join(repoPath, 'AGENTS.md')
@@ -668,6 +678,71 @@ export function importClaudeCode(filePath: string): ImportResult {
668678
}
669679
}
670680

681+
export function importClaudeCodeRules(rulesDir: string): ImportResult {
682+
const rules: RuleBlock[] = []
683+
684+
function findMdFiles(dir: string, relativePath = ''): void {
685+
const entries = readdirSync(dir, { withFileTypes: true })
686+
687+
entries.sort((a: Dirent, b: Dirent) => {
688+
if (a.isDirectory() && !b.isDirectory()) return -1
689+
if (!a.isDirectory() && b.isDirectory()) return 1
690+
return a.name.localeCompare(b.name)
691+
})
692+
693+
for (const entry of entries) {
694+
const fullPath = join(dir, entry.name)
695+
const relPath = relativePath ? join(relativePath, entry.name) : entry.name
696+
697+
if (entry.isDirectory()) {
698+
findMdFiles(fullPath, relPath)
699+
} else if (entry.isFile() && entry.name.endsWith('.md')) {
700+
const content = readFileSync(fullPath, 'utf-8')
701+
const { data, content: body } = matter(content, grayMatterOptions)
702+
703+
let segments = relPath
704+
.replace(/\.md$/, '')
705+
.replace(/\\/g, '/')
706+
.split('/')
707+
.map((s: string) => s.replace(/^\d{2,}-/, '').replace(/\.local$/, ''))
708+
if (segments[0] === 'private') segments = segments.slice(1)
709+
const defaultId = segments.join('/')
710+
711+
const isPrivateFile = isPrivateRule(fullPath)
712+
713+
const metadata: any = {
714+
id: data.id || defaultId,
715+
description: data.description,
716+
alwaysApply: data.alwaysApply ?? false,
717+
}
718+
719+
// Map globs → scope for cross-format compatibility
720+
if (data.globs) {
721+
metadata.globs = data.globs
722+
metadata.scope = data.globs
723+
}
724+
725+
if (data.private === true || (data.private === undefined && isPrivateFile)) {
726+
metadata.private = true
727+
}
728+
729+
rules.push({
730+
metadata,
731+
content: body.trim()
732+
})
733+
}
734+
}
735+
}
736+
737+
findMdFiles(rulesDir)
738+
739+
return {
740+
format: 'claude',
741+
filePath: rulesDir,
742+
rules
743+
}
744+
}
745+
671746
export function importOpenCode(filePath: string): ImportResult {
672747
const content = readFileSync(filePath, 'utf-8')
673748
const isPrivateFile = isPrivateRule(filePath)

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export {
1515
importCodex,
1616
importAider,
1717
importClaudeCode,
18+
importClaudeCodeRules,
1819
importOpenCode,
1920
importGemini,
2021
importQodo,

0 commit comments

Comments
 (0)