// /scripts/claude.js // Merge/split all CLAUDE.md files relative to the project root. // Root section behavior: On merge, if a pre-merge /CLAUDE.md existed, its content is embedded // as a special section (relPath === "CLAUDE.md"). On split, that section (if present) becomes the // new content of /CLAUDE.md; otherwise the root file is cleared. // // Usage examples from /web-console: // bun ../scripts/claude.js merge // bun ../scripts/claude.js split // bun ../scripts/claude.js merge --root .. // bun ../scripts/claude.js split --root .. // @ts-check const fs = require("fs").promises; const path = require("path"); function parseArgs() { const args = process.argv.slice(2); const mode = (args[0] || "").toLowerCase(); let root = null; for (let i = 1; i < args.length; i++) { const a = args[i]; if (a === "--root" && args[i + 1]) { root = args[i + 1]; i++; } else if (a.startsWith("--root=")) { root = a.slice("--root=".length); } else if (!a.startsWith("-") && !root) { // allow optional positional root after command: e.g. "merge .." root = a; } } return { mode, root }; } const { mode, root } = parseArgs(); const DEFAULT_ROOT = path.resolve(__dirname, ".."); const REPO_ROOT = root ? path.resolve(process.cwd(), root) : DEFAULT_ROOT; const TARGET_COMBINED = path.join(REPO_ROOT, "COMPOSITE_CLAUDE.md"); /** @type {Set} */ const IGNORE_DIRS = new Set([ "node_modules", ".git", "dist", "build", "out", ".next", ".vercel", ".svelte-kit", ".docusaurus", ".venv", "coverage", ".turbo", ".cache", ".cargo", ".debug", "tmp", ]); const sectionStart = (relPath) => ``; const sectionEnd = (relPath) => ``; const formatSection = (relPath, content) => `## Context: ${relPath} ${sectionStart(relPath)} ${content.trimEnd()} ${sectionEnd(relPath)} --- `; const mergedFileHeader = "> Important! When `SECTION:some/path/CLAUDE.md START` HTML comment tag is encountered in this file, it means that the content within this tag applies to `some/path/` directory.\n"; /** * @param {string} dir * @returns {Promise} */ async function listClaudeFiles(dir) { const out = []; const entries = await fs.readdir(dir, { withFileTypes: true }); for (const entry of entries) { if (entry.isDirectory()) { if (IGNORE_DIRS.has(entry.name)) continue; out.push(...(await listClaudeFiles(path.join(dir, entry.name)))); continue; } if (entry.isFile() && entry.name === "CLAUDE.md") { const abs = path.join(dir, entry.name); // Exclude the root combined target if (path.resolve(abs) === path.resolve(TARGET_COMBINED)) continue; out.push(abs); } } return out; } /** @param {string} p */ function toRel(p) { return path.posix.normalize( path.relative(REPO_ROOT, p).split(path.sep).join("/"), ); } /** @param {string} absTarget */ function ensureInsideRepo(absTarget) { const rel = path.relative(REPO_ROOT, absTarget); if (rel.startsWith("..")) { throw new Error(`Refusing to write outside repo: ${absTarget}`); } } async function mergeAll() { // Capture pre-merge content of root CLAUDE.md (if it exists) const rootClaudePath = path.join(REPO_ROOT, "CLAUDE.md"); /** @type {string|null} */ let preMergeRoot = null; try { preMergeRoot = await fs.readFile(rootClaudePath, "utf8"); } catch (e) { preMergeRoot = null; // no root file before merge } const files = await listClaudeFiles(REPO_ROOT); files.sort((a, b) => toRel(a).localeCompare(toRel(b))); const sections = [mergedFileHeader]; // If a pre-merge root existed, include it as an (optional) root section if (preMergeRoot !== null) { const rel = "CLAUDE.md"; // special: points to the root file itself const block = formatSection(rel, preMergeRoot); sections.push(block); } // Include all scattered CLAUDE.md files for (const abs of files) { const rel = toRel(abs); const content = await fs.readFile(abs, "utf8"); const block = formatSection(rel, content); sections.push(block); } await fs.writeFile(TARGET_COMBINED, sections.join("\n"), "utf8"); console.log( `Merged ${files.length} CLAUDE.md file(s) into ${toRel(TARGET_COMBINED)} using root ${REPO_ROOT}.`, ); // Delete merged files (keep only the root combined file) for (const abs of files) { try { await fs.unlink(abs); console.log(`Deleted ${toRel(abs)}`); } catch (err) { console.warn(`Failed to delete ${toRel(abs)}:`, err); } } } async function splitAll() { // Read combined let combined; try { combined = await fs.readFile(TARGET_COMBINED, "utf8"); } catch (err) { if (/** @type {any} */ (err).code === "ENOENT") { console.error( `No ${toRel(TARGET_COMBINED)} found. Run "merge" first or set --root correctly.`, ); process.exit(1); } throw err; } // Extract all sections (including optional root section with relPath === "CLAUDE.md") const re = /\s*([\s\S]*?)\s*/g; /** @type {RegExpExecArray | null} */ let match; let count = 0; // Delete TARGET_COMBINED before writing individual files try { await fs.unlink(TARGET_COMBINED); console.log(`Deleted ${toRel(TARGET_COMBINED)}`); } catch (err) { if (/** @type {any} */ (err).code !== "ENOENT") throw err; } while ((match = re.exec(combined)) !== null) { const relPath = match[1].trim(); const body = match[2]; const absPath = path.join(REPO_ROOT, relPath); ensureInsideRepo(absPath); if (path.posix.basename(relPath) !== "CLAUDE.md") { console.warn(`Skipping section with non-CLAUDE.md filename: ${relPath}`); continue; } await fs.mkdir(path.dirname(absPath), { recursive: true }); await fs.writeFile(absPath, body.trimStart(), "utf8"); console.log(`Wrote ${relPath}`); count++; } if (count === 0) { console.warn( "No sections found to split. Are the markers intact and --root correct?", ); } else { console.log( `Restored ${count} CLAUDE.md file(s) from ${toRel(TARGET_COMBINED)} using root ${REPO_ROOT}.`, ); } } (async function main() { if (mode === "merge") { await mergeAll(); } else if (mode === "split") { await splitAll(); } else { console.log(`Usage: bun claude.js merge [--root ] # Combine all CLAUDE.md into /CLAUDE.md (optional root section) and delete originals bun claude.js split [--root ] # Recreate individual CLAUDE.md files; root set from root section or cleared `); process.exit(1); } })().catch((e) => { console.error(e); process.exit(1); });