From aa99fa905abdfc9399a3813b2e2e31dbdc2dea4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Pra=C5=BE=C3=A1k?= Date: Tue, 7 Apr 2026 21:23:20 +0200 Subject: [PATCH 1/2] feat: support .claude/rules/ directory for scoped Claude Code rules The Claude Code exporter now produces a hybrid output: always-apply rules go to CLAUDE.md (as before), while scoped/conditional rules are written as individual .md files in .claude/rules/ with YAML frontmatter (description, globs, alwaysApply). This matches Claude Code's native rules directory format. Also adds importClaudeCodeRules() to read .claude/rules/ with frontmatter parsing, and importAll now detects both CLAUDE.md and .claude/rules/. --- src/cli.ts | 20 +- src/exporters.ts | 66 +++++- src/importers.ts | 75 +++++++ src/index.ts | 1 + test/claude-code.test.ts | 233 +++++++++++++++++++--- test/conditional-export.test.ts | 49 +++-- test/export-conditional-smoke.test.ts | 202 ++++++++----------- test/gitignore-prompt-conditional.test.ts | 3 +- 8 files changed, 469 insertions(+), 180 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 4391a74..793ddcc 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -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' @@ -107,6 +107,7 @@ async function main() { '.rules', 'AGENTS.md', 'CLAUDE.md', + '.claude/rules/*.md', 'GEMINI.md', 'best_practices.md' ])) @@ -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' }, @@ -246,6 +247,7 @@ async function main() { 'AGENTS.md', 'CONVENTIONS.md', 'CLAUDE.md', + '.claude/rules/', 'GEMINI.md', 'best_practices.md' ) @@ -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') @@ -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' @@ -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) { @@ -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) diff --git a/src/exporters.ts b/src/exporters.ts index d5d9163..82d159c 100644 --- a/src/exporters.ts +++ b/src/exporters.ts @@ -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, 'utf-8') + } + + // 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 = {} + + 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 { @@ -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) diff --git a/src/importers.ts b/src/importers.ts index a87359e..230aa35 100644 --- a/src/importers.ts +++ b/src/importers.ts @@ -153,6 +153,16 @@ export async function importAll(repoPath: string): Promise { 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') @@ -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) diff --git a/src/index.ts b/src/index.ts index c368992..b4321c5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,6 +15,7 @@ export { importCodex, importAider, importClaudeCode, + importClaudeCodeRules, importOpenCode, importGemini, importQodo, diff --git a/test/claude-code.test.ts b/test/claude-code.test.ts index b4b8b3a..cc2d7c0 100644 --- a/test/claude-code.test.ts +++ b/test/claude-code.test.ts @@ -1,15 +1,15 @@ import { describe, it, expect } from 'vitest' -import { importClaudeCode, exportToClaudeCode } from '../src/index.js' -import { mkdtempSync, writeFileSync, readFileSync, rmSync } from 'fs' +import { importClaudeCode, importClaudeCodeRules, exportToClaudeCode } from '../src' +import { mkdtempSync, writeFileSync, readFileSync, mkdirSync, rmSync, existsSync } from 'fs' import { join } from 'path' import { tmpdir } from 'os' -import type { RuleBlock } from '../src/types.js' +import type { RuleBlock } from '../src' describe('Claude Code format', () => { it('should import CLAUDE.md file', () => { const tempDir = mkdtempSync(join(tmpdir(), 'claude-test-')) const claudePath = join(tempDir, 'CLAUDE.md') - + const content = `# Project Context This project is a React application with TypeScript. @@ -49,9 +49,8 @@ The app uses Redux for state management and React Router for navigation.` } }) - it('should export to CLAUDE.md format', () => { + it('should export always-apply rules to CLAUDE.md', () => { const tempDir = mkdtempSync(join(tmpdir(), 'claude-export-')) - const claudePath = join(tempDir, 'CLAUDE.md') const rules: RuleBlock[] = [ { @@ -72,7 +71,8 @@ Use Node.js 20+ and pnpm for package management. { metadata: { id: 'code-style', - description: 'Code Style Guidelines' + description: 'Code Style Guidelines', + alwaysApply: true }, content: `## Formatting @@ -83,47 +83,213 @@ Use Node.js 20+ and pnpm for package management. ] try { - exportToClaudeCode(rules, claudePath) + exportToClaudeCode(rules, tempDir) + + const exported = readFileSync(join(tempDir, 'CLAUDE.md'), 'utf8') - const exported = readFileSync(claudePath, 'utf8') - // Should include headers from descriptions expect(exported).toContain('# Project Setup Instructions') expect(exported).toContain('# Code Style Guidelines') - + // Should include content expect(exported).toContain('Use Node.js 20+ and pnpm') expect(exported).toContain('2 space indentation') - - // Should separate rules with double newlines - expect(exported.split('\n\n').length).toBeGreaterThan(2) } finally { rmSync(tempDir, { recursive: true, force: true }) } }) - it('should handle rules without descriptions', () => { - const tempDir = mkdtempSync(join(tmpdir(), 'claude-nodesc-')) - const claudePath = join(tempDir, 'CLAUDE.md') + it('should export scoped rules to .claude/rules/', () => { + const tempDir = mkdtempSync(join(tmpdir(), 'claude-scoped-')) const rules: RuleBlock[] = [ { metadata: { - id: 'basic-rule', + id: 'general', alwaysApply: true }, - content: 'Always use TypeScript' + content: 'General instructions' + }, + { + metadata: { + id: 'typescript-rules', + alwaysApply: false, + description: 'TypeScript conventions', + globs: ['src/**/*.ts'] + }, + content: 'Use strict TypeScript.' + }, + { + metadata: { + id: 'test-rules', + alwaysApply: false, + description: 'Testing conventions', + scope: '**/*.test.ts' + }, + content: 'Write comprehensive tests.' + } + ] + + try { + exportToClaudeCode(rules, tempDir) + + // CLAUDE.md should only have always-apply rules + const claudeMd = readFileSync(join(tempDir, 'CLAUDE.md'), 'utf8') + expect(claudeMd).toContain('General instructions') + expect(claudeMd).not.toContain('Use strict TypeScript') + + // Scoped rules in .claude/rules/ + const tsRule = readFileSync(join(tempDir, '.claude', 'rules', 'typescript-rules.md'), 'utf8') + expect(tsRule).toContain('description: TypeScript conventions') + expect(tsRule).toContain('globs:') + expect(tsRule).toContain('src/**/*.ts') + expect(tsRule).toContain('alwaysApply: false') + expect(tsRule).toContain('Use strict TypeScript.') + + // scope should be mapped to globs + const testRule = readFileSync(join(tempDir, '.claude', 'rules', 'test-rules.md'), 'utf8') + expect(testRule).toContain('globs:') + expect(testRule).toContain('**/*.test.ts') + } finally { + rmSync(tempDir, { recursive: true, force: true }) + } + }) + + it('should export nested rule IDs to subdirectories', () => { + const tempDir = mkdtempSync(join(tmpdir(), 'claude-nested-')) + + const rules: RuleBlock[] = [ + { + metadata: { + id: 'api/auth', + alwaysApply: false, + description: 'Auth API rules' + }, + content: 'Auth rules content' } ] try { - exportToClaudeCode(rules, claudePath) + exportToClaudeCode(rules, tempDir) - const exported = readFileSync(claudePath, 'utf8') - - // Should not have a header if no description - expect(exported).not.toContain('#') - expect(exported.trim()).toBe('Always use TypeScript') + const filePath = join(tempDir, '.claude', 'rules', 'api', 'auth.md') + expect(existsSync(filePath)).toBe(true) + const content = readFileSync(filePath, 'utf8') + expect(content).toContain('Auth rules content') + } finally { + rmSync(tempDir, { recursive: true, force: true }) + } + }) + + it('should not produce CLAUDE.md when only scoped rules exist', () => { + const tempDir = mkdtempSync(join(tmpdir(), 'claude-nomain-')) + + const rules: RuleBlock[] = [ + { + metadata: { + id: 'scoped-only', + alwaysApply: false, + description: 'Only scoped' + }, + content: 'Scoped content' + } + ] + + try { + exportToClaudeCode(rules, tempDir) + + expect(existsSync(join(tempDir, 'CLAUDE.md'))).toBe(false) + expect(existsSync(join(tempDir, '.claude', 'rules', 'scoped-only.md'))).toBe(true) + } finally { + rmSync(tempDir, { recursive: true, force: true }) + } + }) + + it('should import .claude/rules/ directory', () => { + const tempDir = mkdtempSync(join(tmpdir(), 'claude-import-rules-')) + const rulesDir = join(tempDir, '.claude', 'rules') + mkdirSync(rulesDir, { recursive: true }) + + writeFileSync(join(rulesDir, 'coding-style.md'), `--- +description: Coding style rules +globs: + - src/**/*.ts +alwaysApply: false +--- +Use consistent naming.`, 'utf8') + + writeFileSync(join(rulesDir, 'general.md'), `--- +description: General rules +alwaysApply: true +--- +Follow best practices.`, 'utf8') + + try { + const result = importClaudeCodeRules(rulesDir) + + expect(result.format).toBe('claude') + expect(result.rules).toHaveLength(2) + + const codingRule = result.rules.find(r => r.metadata.id === 'coding-style')! + expect(codingRule.metadata.description).toBe('Coding style rules') + expect(codingRule.metadata.alwaysApply).toBe(false) + expect(codingRule.metadata.globs).toEqual(['src/**/*.ts']) + expect(codingRule.metadata.scope).toEqual(['src/**/*.ts']) + expect(codingRule.content).toBe('Use consistent naming.') + + const generalRule = result.rules.find(r => r.metadata.id === 'general')! + expect(generalRule.metadata.alwaysApply).toBe(true) + } finally { + rmSync(tempDir, { recursive: true, force: true }) + } + }) + + it('should default alwaysApply to false when importing rules', () => { + const tempDir = mkdtempSync(join(tmpdir(), 'claude-default-')) + const rulesDir = join(tempDir, '.claude', 'rules') + mkdirSync(rulesDir, { recursive: true }) + + writeFileSync(join(rulesDir, 'no-always.md'), `--- +description: No alwaysApply field +--- +Some content.`, 'utf8') + + try { + const result = importClaudeCodeRules(rulesDir) + expect(result.rules[0].metadata.alwaysApply).toBe(false) + } finally { + rmSync(tempDir, { recursive: true, force: true }) + } + }) + + it('should round-trip scoped rules through export and import', () => { + const tempDir = mkdtempSync(join(tmpdir(), 'claude-roundtrip-')) + + const rules: RuleBlock[] = [ + { + metadata: { + id: 'style-guide', + alwaysApply: false, + description: 'Style guide', + globs: ['src/**/*.ts', 'lib/**/*.ts'] + }, + content: 'Follow the style guide.' + } + ] + + try { + exportToClaudeCode(rules, tempDir) + + const rulesDir = join(tempDir, '.claude', 'rules') + const imported = importClaudeCodeRules(rulesDir) + + expect(imported.rules).toHaveLength(1) + const rule = imported.rules[0] + expect(rule.metadata.id).toBe('style-guide') + expect(rule.metadata.description).toBe('Style guide') + expect(rule.metadata.alwaysApply).toBe(false) + expect(rule.metadata.globs).toEqual(['src/**/*.ts', 'lib/**/*.ts']) + expect(rule.content).toBe('Follow the style guide.') } finally { rmSync(tempDir, { recursive: true, force: true }) } @@ -132,7 +298,7 @@ Use Node.js 20+ and pnpm for package management. it('should preserve content formatting during import/export', () => { const tempDir = mkdtempSync(join(tmpdir(), 'claude-preserve-')) const claudePath = join(tempDir, 'CLAUDE.md') - + const originalContent = `# Development Guidelines ## Code Structure @@ -157,13 +323,14 @@ src/ try { // Import const imported = importClaudeCode(claudePath) - + // Export to a different location - const exportPath = join(tempDir, 'CLAUDE-exported.md') - exportToClaudeCode(imported.rules, exportPath) - - const exported = readFileSync(exportPath, 'utf8') - + const exportDir = join(tempDir, 'export') + mkdirSync(exportDir, { recursive: true }) + exportToClaudeCode(imported.rules, exportDir) + + const exported = readFileSync(join(exportDir, 'CLAUDE.md'), 'utf8') + // The content should be preserved (with added header) expect(exported).toContain('# Claude Code context and instructions') expect(exported).toContain('Always validate inputs') @@ -172,4 +339,4 @@ src/ rmSync(tempDir, { recursive: true, force: true }) } }) -}) \ No newline at end of file +}) diff --git a/test/conditional-export.test.ts b/test/conditional-export.test.ts index 8d3795c..525fda9 100644 --- a/test/conditional-export.test.ts +++ b/test/conditional-export.test.ts @@ -1,9 +1,9 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest' -import { mkdtempSync, rmSync, readFileSync } from 'fs' +import { mkdtempSync, rmSync, readFileSync, existsSync } from 'fs' import { join } from 'path' import { tmpdir } from 'os' -import { exportToCopilot, exportToClaudeCode, exportToWindsurf } from '../src/exporters.js' -import type { RuleBlock } from '../src/types.js' +import { exportToCopilot, exportToClaudeCode, exportToWindsurf } from '../src' +import type { RuleBlock } from '../src' describe('Conditional rules export', () => { let tempDir: string @@ -67,7 +67,7 @@ describe('Conditional rules export', () => { expect(content).not.toContain('Use strict mode.') }) - it('should export conditional rules with description keywords', () => { + it('should export scoped rules to .claude/rules/ directory', () => { const rules: RuleBlock[] = [ { metadata: { @@ -86,18 +86,22 @@ describe('Conditional rules export', () => { } ] - const outputPath = join(tempDir, 'CLAUDE.md') - exportToClaudeCode(rules, outputPath) + exportToClaudeCode(rules, tempDir) - const content = readFileSync(outputPath, 'utf-8') - - expect(content).toContain('Always apply this rule.') - expect(content).toContain('## Context-Specific Rules') - expect(content).toContain('When working with database queries and SQL operations, also apply:') - expect(content).toContain('→ [database-patterns](.agent/database-patterns.md)') + // Always-apply rule should be in CLAUDE.md + const claudeMd = readFileSync(join(tempDir, 'CLAUDE.md'), 'utf-8') + expect(claudeMd).toContain('Always apply this rule.') + + // Scoped rule should be in .claude/rules/ as individual file + const rulePath = join(tempDir, '.claude', 'rules', 'database-patterns.md') + expect(existsSync(rulePath)).toBe(true) + const ruleContent = readFileSync(rulePath, 'utf-8') + expect(ruleContent).toContain('description: database queries and SQL operations') + expect(ruleContent).toContain('alwaysApply: false') + expect(ruleContent).toContain('Use parameterized queries.') }) - it('should create workflow sections for folder-based rules', () => { + it('should export nested folder-based rules to .claude/rules/', () => { const rules: RuleBlock[] = [ { metadata: { @@ -124,14 +128,17 @@ describe('Conditional rules export', () => { } ] - const outputPath = join(tempDir, 'AGENTS.md') - exportToClaudeCode(rules, outputPath) + exportToClaudeCode(rules, tempDir) - const content = readFileSync(outputPath, 'utf-8') - - expect(content).toContain('## Workflows') - expect(content).toContain('→ [workflows/pr-review](.agent/workflows/pr-review.md) - Pull request review workflow') - expect(content).toContain('→ [workflows/deployment](.agent/workflows/deployment.md) - Deployment workflow') + // Nested rules should be in subdirectories + const prPath = join(tempDir, '.claude', 'rules', 'workflows', 'pr-review.md') + const deployPath = join(tempDir, '.claude', 'rules', 'workflows', 'deployment.md') + expect(existsSync(prPath)).toBe(true) + expect(existsSync(deployPath)).toBe(true) + + const prContent = readFileSync(prPath, 'utf-8') + expect(prContent).toContain('PR review steps') + expect(prContent).toContain('description: Pull request review workflow') }) it('should not add conditional section when all rules have alwaysApply', () => { @@ -161,4 +168,4 @@ describe('Conditional rules export', () => { expect(content).toContain('Rule 2 content') expect(content).not.toContain('## Context-Specific Rules') }) -}) \ No newline at end of file +}) diff --git a/test/export-conditional-smoke.test.ts b/test/export-conditional-smoke.test.ts index 87966ee..0ec2ecf 100644 --- a/test/export-conditional-smoke.test.ts +++ b/test/export-conditional-smoke.test.ts @@ -16,7 +16,7 @@ describe('Conditional export smoke tests for single-file formats', () => { rmSync(tempDir, { recursive: true, force: true }) }) - it('should export to CLAUDE.md with conditional rules section', () => { + it('should export to Claude Code with always-apply in CLAUDE.md and scoped in .claude/rules/', () => { // Create a realistic .agent directory structure const agentDir = join(tempDir, '.agent') mkdirSync(agentDir, { recursive: true }) @@ -119,43 +119,42 @@ description: Deployment process // Import all rules const result = importAgent(agentDir) - - // Export to CLAUDE.md - const claudePath = join(tempDir, 'CLAUDE.md') - exportToClaudeCode(result.rules, claudePath) - - const content = readFileSync(claudePath, 'utf-8') - - // Verify always-apply rules are in the main content - expect(content).toContain('# Code Style Guidelines') - expect(content).toContain('Use 2 spaces for indentation') - expect(content).toContain('# Git Workflow') - expect(content).toContain('Write clear commit messages') - - // Verify conditional rules section exists - expect(content).toContain('## Context-Specific Rules') - - // Verify scope-based rules - expect(content).toContain('When working with files matching `**/*.ts`, also apply:') - expect(content).toContain('→ [typescript](.agent/typescript.md) - TypeScript specific rules') - - expect(content).toContain('When working with files matching `**/*.tsx`, also apply:') - expect(content).toContain('→ [react-components](.agent/react-components.md) - React component guidelines') - - // Verify description-based rules - expect(content).toContain('When working with database operations, SQL queries, and data modeling, also apply:') - expect(content).toContain('→ [database](.agent/database.md)') - - // Verify workflows section - expect(content).toContain('## Workflows') - expect(content).toContain('→ [workflows/pr-review](.agent/workflows/pr-review.md) - Pull request review checklist') - expect(content).toContain('→ [workflows/deployment](.agent/workflows/deployment.md) - Deployment process') - - // Verify conditional content is NOT in the main section - expect(content.indexOf('# TypeScript Rules')).toBe(-1) - expect(content.indexOf('# React Component Guidelines')).toBe(-1) - expect(content.indexOf('# Database Best Practices')).toBe(-1) - expect(content.indexOf('# PR Review Checklist')).toBe(-1) + + // Export to Claude Code format + exportToClaudeCode(result.rules, tempDir) + + // Verify CLAUDE.md has always-apply rules + const claudeContent = readFileSync(join(tempDir, 'CLAUDE.md'), 'utf-8') + expect(claudeContent).toContain('# Code Style Guidelines') + expect(claudeContent).toContain('Use 2 spaces for indentation') + expect(claudeContent).toContain('# Git Workflow') + expect(claudeContent).toContain('Write clear commit messages') + + // Verify CLAUDE.md does NOT contain conditional rules content + expect(claudeContent).not.toContain('# TypeScript Rules') + expect(claudeContent).not.toContain('# React Component Guidelines') + expect(claudeContent).not.toContain('# Database Best Practices') + expect(claudeContent).not.toContain('# PR Review Checklist') + + // Verify scoped rules are in .claude/rules/ + const rulesDir = join(tempDir, '.claude', 'rules') + expect(existsSync(join(rulesDir, 'typescript.md'))).toBe(true) + expect(existsSync(join(rulesDir, 'react-components.md'))).toBe(true) + expect(existsSync(join(rulesDir, 'database.md'))).toBe(true) + expect(existsSync(join(rulesDir, 'workflows', 'pr-review.md'))).toBe(true) + expect(existsSync(join(rulesDir, 'workflows', 'deployment.md'))).toBe(true) + + // Verify frontmatter in scoped rule files + const tsContent = readFileSync(join(rulesDir, 'typescript.md'), 'utf-8') + expect(tsContent).toContain('description: TypeScript specific rules') + expect(tsContent).toContain('alwaysApply: false') + expect(tsContent).toContain('**/*.ts') + expect(tsContent).toContain('Use strict mode') + + const reactContent = readFileSync(join(rulesDir, 'react-components.md'), 'utf-8') + expect(reactContent).toContain('description: React component guidelines') + expect(reactContent).toContain('**/*.tsx') + expect(reactContent).toContain('**/components/**') }) it('should export to different single-file formats with correct separators', () => { @@ -184,10 +183,10 @@ description: Testing guidelines Write comprehensive tests.`) const result = importAgent(agentDir) - + // Test different export formats const formats = [ - { + { path: join(tempDir, '.github', 'copilot-instructions.md'), exporter: exportToCopilot, separator: '---' @@ -208,31 +207,31 @@ Write comprehensive tests.`) separator: null } ] - + formats.forEach(({ path, exporter, separator }) => { exporter(result.rules, path) const content = readFileSync(path, 'utf-8') - + // All should have main content expect(content).toContain('# Main Rules') expect(content).toContain('Always follow these guidelines.') - + // All should have conditional section expect(content).toContain('## Context-Specific Rules') expect(content).toContain('When working with files matching `**/*.test.{js,ts}`, also apply:') expect(content).toContain('→ [testing](.agent/testing.md) - Testing guidelines') - + // Check separator if applicable if (separator) { expect(content).toContain(`\n\n${separator}\n\n## Context-Specific Rules`) } - + // Conditional content should not be in main section expect(content.indexOf('# Testing Guidelines')).toBe(-1) }) }) - it('should handle complex real-world scenario with multiple rule types', () => { + it('should handle complex real-world scenario with multiple rule types via exportAll', () => { const agentDir = join(tempDir, '.agent') const dirs = { frontend: join(agentDir, 'frontend'), @@ -240,7 +239,7 @@ Write comprehensive tests.`) workflows: join(agentDir, 'workflows'), testing: join(agentDir, 'testing') } - + // Create all directories Object.values(dirs).forEach(dir => mkdirSync(dir, { recursive: true })) @@ -333,58 +332,33 @@ description: Release management How to cut a release.`) - // Import and export + // Import and export all const result = importAgent(agentDir) exportAll(result.rules, tempDir, false) - - // Check CLAUDE.md + + // Check CLAUDE.md has always-apply content only const claudePath = join(tempDir, 'CLAUDE.md') expect(existsSync(claudePath)).toBe(true) const claudeContent = readFileSync(claudePath, 'utf-8') - - // Verify structure - const sections = claudeContent.split('\n## ') - - // Should have main content, Context-Specific Rules, and Workflows - expect(sections.length).toBeGreaterThanOrEqual(3) - - // Check main content expect(claudeContent).toContain('# Project Standards') - - // Check Context-Specific Rules section exists and has correct content - const contextSection = sections.find(s => s.startsWith('Context-Specific Rules')) - expect(contextSection).toBeDefined() - - // Frontend rules - expect(contextSection).toContain('When working with files matching `src/components/**/*.tsx`, also apply:') - expect(contextSection).toContain('→ [frontend/react-patterns](.agent/frontend/react-patterns.md) - React best practices') - expect(contextSection).toContain('When working with files matching `**/*.{css,scss}`, also apply:') - expect(contextSection).toContain('→ [frontend/styling](.agent/frontend/styling.md) - CSS and styling guidelines') - - // Backend rules - expect(contextSection).toContain('When working with files matching `src/api/**/*.ts`, also apply:') - expect(contextSection).toContain('→ [backend/api-design](.agent/backend/api-design.md) - REST API design principles') - - // Testing rules - expect(contextSection).toContain('When working with files matching `**/*.test.ts`, also apply:') - expect(contextSection).toContain('→ [testing/unit-tests](.agent/testing/unit-tests.md) - Unit testing standards') - - // Check Workflows section - const workflowsSection = sections.find(s => s.startsWith('Workflows')) - expect(workflowsSection).toBeDefined() - expect(workflowsSection).toContain('→ [workflows/code-review](.agent/workflows/code-review.md) - Code review process') - expect(workflowsSection).toContain('→ [workflows/release](.agent/workflows/release.md) - Release management') - - // Check Backend section (for backend/security which has no scope) - const backendSection = sections.find(s => s.startsWith('Backend')) - expect(backendSection).toBeDefined() - expect(backendSection).toContain('→ [backend/security](.agent/backend/security.md) - security, authentication, and authorization') - - // Verify no conditional content in main section - const mainContent = sections[0] // Content before first ## - expect(mainContent).not.toContain('# React Patterns') - expect(mainContent).not.toContain('# API Design') - expect(mainContent).not.toContain('# Code Review Process') + expect(claudeContent).not.toContain('# React Patterns') + expect(claudeContent).not.toContain('# API Design') + + // Check scoped rules in .claude/rules/ + const rulesDir = join(tempDir, '.claude', 'rules') + expect(existsSync(join(rulesDir, 'frontend', 'react-patterns.md'))).toBe(true) + expect(existsSync(join(rulesDir, 'frontend', 'styling.md'))).toBe(true) + expect(existsSync(join(rulesDir, 'backend', 'api-design.md'))).toBe(true) + expect(existsSync(join(rulesDir, 'backend', 'security.md'))).toBe(true) + expect(existsSync(join(rulesDir, 'testing', 'unit-tests.md'))).toBe(true) + expect(existsSync(join(rulesDir, 'workflows', 'code-review.md'))).toBe(true) + expect(existsSync(join(rulesDir, 'workflows', 'release.md'))).toBe(true) + + // Verify Copilot still uses single-file conditional section + const copilotPath = join(tempDir, '.github', 'copilot-instructions.md') + const copilotContent = readFileSync(copilotPath, 'utf-8') + expect(copilotContent).toContain('## Context-Specific Rules') + expect(copilotContent).toContain('When working with files matching') }) it('should handle edge cases gracefully', () => { @@ -405,7 +379,7 @@ Go language specifics.`) // Rule in folder but with scope (should appear in scope section, not folder section) const utilsDir = join(agentDir, 'utils') mkdirSync(utilsDir) - + writeFileSync(join(utilsDir, 'helpers.md'), `--- id: utils/helpers alwaysApply: false @@ -429,24 +403,24 @@ description: Logging best practices How to implement logging.`) const result = importAgent(agentDir) - const claudePath = join(tempDir, 'CLAUDE.md') - exportToClaudeCode(result.rules, claudePath) - - const content = readFileSync(claudePath, 'utf-8') - - // Should have Context-Specific Rules - expect(content).toContain('## Context-Specific Rules') - - // no-description should be in scope section only - expect(content).toContain('When working with files matching `**/*.go`, also apply:') - expect(content).toContain('→ [no-description](.agent/no-description.md)') - - // utils/helpers should be in scope section (not Utils section) - expect(content).toContain('When working with files matching `src/utils/**`, also apply:') - expect(content).toContain('→ [utils/helpers](.agent/utils/helpers.md) - Utility function guidelines') - - // utils/logging should be in Utils section - expect(content).toContain('## Utils') - expect(content).toContain('→ [utils/logging](.agent/utils/logging.md) - Logging best practices') + exportToClaudeCode(result.rules, tempDir) + + // No always-apply rules, so no CLAUDE.md + expect(existsSync(join(tempDir, 'CLAUDE.md'))).toBe(false) + + // All scoped rules should be in .claude/rules/ + const rulesDir = join(tempDir, '.claude', 'rules') + + const noDescContent = readFileSync(join(rulesDir, 'no-description.md'), 'utf-8') + expect(noDescContent).toContain('**/*.go') + expect(noDescContent).toContain('alwaysApply: false') + + const helpersContent = readFileSync(join(rulesDir, 'utils', 'helpers.md'), 'utf-8') + expect(helpersContent).toContain('src/utils/**') + expect(helpersContent).toContain('description: Utility function guidelines') + + const loggingContent = readFileSync(join(rulesDir, 'utils', 'logging.md'), 'utf-8') + expect(loggingContent).toContain('description: Logging best practices') + expect(loggingContent).toContain('alwaysApply: false') }) -}) \ No newline at end of file +}) diff --git a/test/gitignore-prompt-conditional.test.ts b/test/gitignore-prompt-conditional.test.ts index b4c817d..ee22a42 100644 --- a/test/gitignore-prompt-conditional.test.ts +++ b/test/gitignore-prompt-conditional.test.ts @@ -176,10 +176,11 @@ node_modules/ AGENTS.md CONVENTIONS.md CLAUDE.md +.claude/rules/** GEMINI.md best_practices.md `) - + // Get the initial gitignore content const initialContent = readFileSync(gitignorePath, 'utf-8') From 95a7b1c75bb0cf8b5e9c0b91ad35696249a4497a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Pra=C5=BE=C3=A1k?= Date: Tue, 7 Apr 2026 21:35:24 +0200 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20appease=20coderabbit=20=E2=80=94=20a?= =?UTF-8?q?dd=20trailing=20newline=20to=20CLAUDE.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/exporters.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/exporters.ts b/src/exporters.ts index 82d159c..8dfdc7d 100644 --- a/src/exporters.ts +++ b/src/exporters.ts @@ -402,7 +402,7 @@ export function exportToClaudeCode(rules: RuleBlock[], outputDir: string, option const claudeMdPath = join(outputDir, 'CLAUDE.md') ensureDirectoryExists(claudeMdPath) - writeFileSync(claudeMdPath, mainContent, 'utf-8') + writeFileSync(claudeMdPath, mainContent + '\n', 'utf-8') } // Scoped rules → .claude/rules/ as individual files with frontmatter