import { execSync } from "child_process"; import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "fs"; import { dirname, join, relative, sep } from "path"; // --- CLI Argument Parsing --- function parseArgs(argv) { let projectDir = process.cwd(); let deleteOriginals = false; let dryRun = false; let force = false; for (let i = 2; i < argv.length; i++) { if (argv[i] === "--project-dir" && argv[i + 1]) { projectDir = argv[++i]; } else if (argv[i] === "--delete-originals") { deleteOriginals = true; } else if (argv[i] === "--dry-run") { dryRun = true; } else if (argv[i] === "--force") { force = true; } } return { projectDir, deleteOriginals, dryRun, force }; } // --- Discovery --- const HARDCODED_EXCLUDES = new Set([ "node_modules", "dist", "build", ".git", "vendor", ".rpiv", ".next", ".nuxt", ".output", "coverage", "__pycache__", ".venv", ]); function discoverClaudeMdFiles(projectDir) { const gitDir = join(projectDir, ".git"); if (existsSync(gitDir)) { return discoverViaGit(projectDir); } return discoverViaWalk(projectDir); } function discoverViaGit(projectDir) { try { const output = execSync("git ls-files --cached --others --exclude-standard", { cwd: projectDir, encoding: "utf-8", maxBuffer: 10 * 1024 * 1024, }); return output .split("\n") .filter((f) => f.endsWith("/CLAUDE.md") || f === "CLAUDE.md") .filter((f) => !f.startsWith(".rpiv/")); } catch { // git command failed — fall back to walk return discoverViaWalk(projectDir); } } function discoverViaWalk(projectDir) { const results = []; function walk(dir) { let entries; try { entries = readdirSync(dir); } catch { return; // permission error, skip } for (const entry of entries) { if (HARDCODED_EXCLUDES.has(entry)) continue; const fullPath = join(dir, entry); let stat; try { stat = statSync(fullPath); } catch { continue; } if (stat.isDirectory()) { walk(fullPath); } else if (entry === "CLAUDE.md") { const rel = relative(projectDir, fullPath).split(sep).join("/"); if (!rel.startsWith(".rpiv/")) { results.push(rel); } } } } walk(projectDir); return results; } // --- Path Mapping --- function computeTargetPath(claudeMdRelative) { const dir = dirname(claudeMdRelative); if (dir === ".") { return ".rpiv/guidance/architecture.md"; } return join(".rpiv", "guidance", dir, "architecture.md").split(sep).join("/"); } function transformContent(content, targetPath) { let refsTransformed = 0; const warnings = []; // Pattern 1: Backtick-wrapped path references like `src/core/CLAUDE.md` let transformed = content.replace(/`((?:[\w][\w./-]*\/)?CLAUDE\.md)`/g, (_match, claudePath) => { const replacement = claudePathToGuidancePath(claudePath); refsTransformed++; return `\`${replacement}\``; }); // Pattern 2: Bare path references (with directory prefix) not inside backticks // Match things like "src/core/CLAUDE.md" but not already-backtick-wrapped transformed = transformed.replace(/(? { const replacement = claudePathToGuidancePath(claudePath); refsTransformed++; return replacement; }); // Pattern 3: Standalone "CLAUDE.md" that references the root file // Only match when it looks like a file reference (not part of a longer word) // Avoid matching inside paths already transformed above transformed = transformed.replace(/(? { refsTransformed++; return ".rpiv/guidance/architecture.md"; }); // Scan for remaining prose references that might need manual attention const lines = transformed.split("\n"); for (let i = 0; i < lines.length; i++) { // Look for prose patterns like "see X CLAUDE.md" or "X layer CLAUDE.md" if ( /\b\w+\s+CLAUDE\.md\b/i.test(content.split("\n")[i] ?? "") && !/(src|lib|app|packages|apps)\//.test(content.split("\n")[i] ?? "") ) { // Check if this line still has an untransformed prose reference if (/CLAUDE\.md/i.test(lines[i])) { warnings.push({ file: targetPath, line: i + 1, message: `Prose reference to CLAUDE.md may need manual update: "${lines[i].trim()}"`, }); } } } return { content: transformed, refsTransformed, warnings }; } function claudePathToGuidancePath(claudePath) { const dir = dirname(claudePath); if (dir === ".") { return ".rpiv/guidance/architecture.md"; } return `.rpiv/guidance/${dir}/architecture.md`; } // --- Main --- function main() { const { projectDir, deleteOriginals, dryRun, force } = parseArgs(process.argv); process.stderr.write(`[rpiv:migrate] scanning ${projectDir} for CLAUDE.md files\n`); const claudeFiles = discoverClaudeMdFiles(projectDir); if (claudeFiles.length === 0) { const report = { migrated: [], conflicts: [], warnings: [], originalsDeleted: false, dryRun, }; process.stdout.write(JSON.stringify(report, null, 2)); return; } process.stderr.write(`[rpiv:migrate] found ${claudeFiles.length} CLAUDE.md file(s)\n`); const migrated = []; const conflicts = []; const allWarnings = []; const writtenFiles = []; for (const source of claudeFiles) { const target = computeTargetPath(source); const targetAbs = join(projectDir, target); // Check for conflicts if (existsSync(targetAbs) && !force) { conflicts.push(target); continue; } // Read source content const sourceAbs = join(projectDir, source); let content; try { content = readFileSync(sourceAbs, "utf-8"); } catch (err) { allWarnings.push({ file: source, line: 0, message: `Failed to read: ${err instanceof Error ? err.message : String(err)}`, }); continue; } if (content.trim().length === 0) { allWarnings.push({ file: source, line: 0, message: "Empty file, skipped", }); continue; } // Transform content const { content: transformed, refsTransformed, warnings } = transformContent(content, target); const lines = transformed.split("\n").length; migrated.push({ source, target, lines, refsTransformed }); allWarnings.push(...warnings); if (!dryRun) { writtenFiles.push({ targetAbs, content: transformed }); } } // Write all files (all-or-nothing approach for safety) if (!dryRun) { for (const { targetAbs, content } of writtenFiles) { mkdirSync(dirname(targetAbs), { recursive: true }); writeFileSync(targetAbs, content, "utf-8"); } process.stderr.write(`[rpiv:migrate] wrote ${writtenFiles.length} file(s)\n`); } // Delete originals only after all writes succeed let originalsDeleted = false; if (!dryRun && deleteOriginals && writtenFiles.length > 0) { for (const entry of migrated) { const sourceAbs = join(projectDir, entry.source); try { unlinkSync(sourceAbs); } catch (err) { allWarnings.push({ file: entry.source, line: 0, message: `Failed to delete original: ${err instanceof Error ? err.message : String(err)}`, }); } } originalsDeleted = true; process.stderr.write(`[rpiv:migrate] deleted ${migrated.length} original CLAUDE.md file(s)\n`); } const report = { migrated, conflicts, warnings: allWarnings, originalsDeleted, dryRun, }; process.stdout.write(JSON.stringify(report, null, 2)); } main();