Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 12 additions & 8 deletions src/cli.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
#!/usr/bin/env node

import { existsSync, readFileSync, writeFileSync, appendFileSync, rmSync } from 'fs'
import { existsSync, readFileSync, writeFileSync, appendFileSync, rmSync, statSync } from 'fs'
import { join, resolve, dirname } from 'path'
import { parseArgs } from 'util'
import { importAll, importAgent, exportToAgent, exportAll, exportToCopilot, exportToCursor, exportToCline, exportToWindsurf, exportToZed, exportToCodex, exportToAider, exportToClaudeCode, exportToGemini, exportToQodo, importRoo, exportToRoo, exportToJunie, importOpenCode, exportToOpenCode } from './index.js'
Expand Down Expand Up @@ -107,6 +107,7 @@ async function main() {
'.rules',
'AGENTS.md',
'CLAUDE.md',
'.claude/rules/*.md',
'GEMINI.md',
'best_practices.md'
]))
Expand Down Expand Up @@ -190,7 +191,7 @@ async function main() {
{ name: 'Zed (.rules)', value: 'zed' },
{ name: 'OpenAI Codex (AGENTS.md)', value: 'codex' },
{ name: 'Aider (CONVENTIONS.md)', value: 'aider' },
{ name: 'Claude Code (CLAUDE.md)', value: 'claude' },
{ name: 'Claude Code (CLAUDE.md + .claude/rules/)', value: 'claude' },
{ name: 'Gemini CLI (GEMINI.md)', value: 'gemini' },
{ name: 'Qodo Merge (best_practices.md)', value: 'qodo' },
{ name: 'Roo Code (.roo/rules/)', value: 'roo' },
Expand Down Expand Up @@ -246,6 +247,7 @@ async function main() {
'AGENTS.md',
'CONVENTIONS.md',
'CLAUDE.md',
'.claude/rules/',
'GEMINI.md',
'best_practices.md'
)
Expand Down Expand Up @@ -290,9 +292,9 @@ async function main() {
exportedPaths.push('CONVENTIONS.md')
break
case 'claude':
exportPath = join(outputDir, 'CLAUDE.md')
if (!isDryRun) exportToClaudeCode(rules, exportPath, options)
exportedPaths.push('CLAUDE.md')
if (!isDryRun) exportToClaudeCode(rules, outputDir, options)
exportPath = join(outputDir, '.claude/rules/')
exportedPaths.push('CLAUDE.md', '.claude/rules/')
break
case 'gemini':
exportPath = join(outputDir, 'GEMINI.md')
Expand Down Expand Up @@ -382,7 +384,7 @@ async function main() {
else if (inputPath.includes('.windsurfrules')) format = 'windsurf'
else if (inputPath.endsWith('.rules')) format = 'zed'
else if (inputPath.endsWith('AGENTS.md')) format = 'codex'
else if (inputPath.endsWith('CLAUDE.md')) format = 'claude'
else if (inputPath.endsWith('CLAUDE.md') || inputPath.includes('.claude/rules')) format = 'claude'
else if (inputPath.endsWith('GEMINI.md')) format = 'gemini'
else if (inputPath.endsWith('CONVENTIONS.md')) format = 'aider'
else if (inputPath.endsWith('best_practices.md')) format = 'qodo'
Expand All @@ -398,7 +400,7 @@ async function main() {
console.log(`Input: ${color.path(inputPath)}`)

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

let result
switch (format) {
Expand All @@ -424,7 +426,9 @@ async function main() {
result = importAider(inputPath)
break
case 'claude':
result = importClaudeCode(inputPath)
result = statSync(inputPath).isDirectory()
? importClaudeCodeRules(inputPath)
: importClaudeCode(inputPath)
break
case 'opencode':
result = importOpenCode(inputPath)
Expand Down
66 changes: 63 additions & 3 deletions src/exporters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -385,8 +385,68 @@ export function exportToAider(rules: RuleBlock[], outputPath: string, options?:
writeFileSync(outputPath, fullContent, 'utf-8')
}

export function exportToClaudeCode(rules: RuleBlock[], outputPath: string, options?: ExportOptions): void {
exportSingleFileWithHeaders(rules, outputPath, options)
export function exportToClaudeCode(rules: RuleBlock[], outputDir: string, options?: ExportOptions): void {
const filteredRules = rules.filter(rule => !rule.metadata.private || options?.includePrivate)

const alwaysApplyRules = filteredRules.filter(r => r.metadata.alwaysApply !== false)
const scopedRules = filteredRules.filter(r => r.metadata.alwaysApply === false)

// Always-apply rules → CLAUDE.md
if (alwaysApplyRules.length > 0) {
const mainContent = alwaysApplyRules
.map(rule => {
const header = rule.metadata.description ? `# ${rule.metadata.description}\n\n` : ''
return header + rule.content
})
.join('\n\n')

const claudeMdPath = join(outputDir, 'CLAUDE.md')
ensureDirectoryExists(claudeMdPath)
writeFileSync(claudeMdPath, mainContent + '\n', 'utf-8')
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Scoped rules → .claude/rules/ as individual files with frontmatter
if (scopedRules.length > 0) {
const rulesDir = join(outputDir, '.claude', 'rules')
mkdirSync(rulesDir, { recursive: true })

for (const rule of scopedRules) {
let filePath: string

if (rule.metadata.id && rule.metadata.id.includes('/')) {
const parts = rule.metadata.id.split('/')
const fileName = parts.pop() + '.md'
const subDir = join(rulesDir, ...parts)
mkdirSync(subDir, { recursive: true })
filePath = join(subDir, fileName)
} else {
const filename = `${rule.metadata.id || 'rule'}.md`
filePath = join(rulesDir, filename)
}

// Claude Code frontmatter only supports description, globs, alwaysApply
const frontMatter: Record<string, unknown> = {}

if (rule.metadata.description !== undefined && rule.metadata.description !== null) {
frontMatter.description = rule.metadata.description
}

// Map scope → globs when globs is not already set
const globs = rule.metadata.globs ?? (
rule.metadata.scope
? (Array.isArray(rule.metadata.scope) ? rule.metadata.scope : [rule.metadata.scope])
: undefined
)
if (globs !== undefined) {
frontMatter.globs = globs
}

frontMatter.alwaysApply = rule.metadata.alwaysApply ?? false

const mdContent = matter.stringify(rule.content, frontMatter, grayMatterOptions)
writeFileSync(filePath, mdContent, 'utf-8')
}
}
}

export function exportToOpenCode(rules: RuleBlock[], outputPath: string, options?: ExportOptions): void {
Expand Down Expand Up @@ -554,7 +614,7 @@ export function exportAll(rules: RuleBlock[], repoPath: string, dryRun = false,
exportToZed(rules, join(repoPath, '.rules'), options)
exportToCodex(rules, join(repoPath, 'AGENTS.md'), options)
exportToAider(rules, join(repoPath, 'CONVENTIONS.md'), options)
exportToClaudeCode(rules, join(repoPath, 'CLAUDE.md'), options)
exportToClaudeCode(rules, repoPath, options)
exportToOpenCode(rules, join(repoPath, 'AGENTS.md'), options)
exportToGemini(rules, join(repoPath, 'GEMINI.md'), options)
exportToQodo(rules, join(repoPath, 'best_practices.md'), options)
Expand Down
75 changes: 75 additions & 0 deletions src/importers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,16 @@ export async function importAll(repoPath: string): Promise<ImportResults> {
errors.push({ file: claudeMd, error: String(e) })
}
}

// Check for Claude Code rules directory (.claude/rules/)
const claudeRulesDir = join(repoPath, '.claude', 'rules')
if (existsSync(claudeRulesDir)) {
try {
results.push(importClaudeCodeRules(claudeRulesDir))
} catch (e) {
errors.push({ file: claudeRulesDir, error: String(e) })
}
}

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

export function importClaudeCodeRules(rulesDir: string): ImportResult {
const rules: RuleBlock[] = []

function findMdFiles(dir: string, relativePath = ''): void {
const entries = readdirSync(dir, { withFileTypes: true })

entries.sort((a: Dirent, b: Dirent) => {
if (a.isDirectory() && !b.isDirectory()) return -1
if (!a.isDirectory() && b.isDirectory()) return 1
return a.name.localeCompare(b.name)
})

for (const entry of entries) {
const fullPath = join(dir, entry.name)
const relPath = relativePath ? join(relativePath, entry.name) : entry.name

if (entry.isDirectory()) {
findMdFiles(fullPath, relPath)
} else if (entry.isFile() && entry.name.endsWith('.md')) {
const content = readFileSync(fullPath, 'utf-8')
const { data, content: body } = matter(content, grayMatterOptions)

let segments = relPath
.replace(/\.md$/, '')
.replace(/\\/g, '/')
.split('/')
.map((s: string) => s.replace(/^\d{2,}-/, '').replace(/\.local$/, ''))
if (segments[0] === 'private') segments = segments.slice(1)
const defaultId = segments.join('/')

const isPrivateFile = isPrivateRule(fullPath)

const metadata: any = {
id: data.id || defaultId,
description: data.description,
alwaysApply: data.alwaysApply ?? false,
}

// Map globs → scope for cross-format compatibility
if (data.globs) {
metadata.globs = data.globs
metadata.scope = data.globs
}

if (data.private === true || (data.private === undefined && isPrivateFile)) {
metadata.private = true
}

rules.push({
metadata,
content: body.trim()
})
}
}
}

findMdFiles(rulesDir)

return {
format: 'claude',
filePath: rulesDir,
rules
}
}

export function importOpenCode(filePath: string): ImportResult {
const content = readFileSync(filePath, 'utf-8')
const isPrivateFile = isPrivateRule(filePath)
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export {
importCodex,
importAider,
importClaudeCode,
importClaudeCodeRules,
importOpenCode,
importGemini,
importQodo,
Expand Down
Loading
Loading