|
#!/usr/bin/env bun |
|
/** |
|
* compile-claude — Semantic CLAUDE.md compiler |
|
* |
|
* Compresses CLAUDE.src.md into CLAUDE.md using LLM semantic compression. |
|
* Source+Compiled pattern: you edit .src.md, this tool produces .md |
|
* |
|
* Requirements: |
|
* - bun (https://bun.sh) |
|
* - claude CLI (https://docs.anthropic.com/en/docs/claude-code) |
|
* |
|
* Usage: |
|
* compile-claude [options] |
|
* |
|
* Options: |
|
* --print Preview compressed output + stats (don't write) |
|
* --model <name> Claude model to use [default: sonnet] |
|
* --source <file> Source file path [default: ./CLAUDE.src.md] |
|
* --output <file> Output file path [default: ./CLAUDE.md] |
|
* --timeout <ms> Inference timeout in ms [default: 120000] |
|
* -h, --help Show help |
|
*/ |
|
|
|
import { existsSync, readFileSync, writeFileSync, copyFileSync } from "fs"; |
|
import { resolve, dirname } from "path"; |
|
import { fileURLToPath } from "url"; |
|
import * as readline from "readline"; |
|
|
|
// Types |
|
interface CompileOptions { |
|
source: string; |
|
output: string; |
|
model: string; |
|
timeout: number; |
|
printOnly: boolean; |
|
} |
|
|
|
// Constants |
|
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)); |
|
const PROMPT_FILE = resolve(SCRIPT_DIR, "prompt.txt"); |
|
|
|
// Colors (ANSI) |
|
const c = { |
|
red: "\x1b[0;31m", |
|
green: "\x1b[0;32m", |
|
yellow: "\x1b[0;33m", |
|
blue: "\x1b[0;34m", |
|
dim: "\x1b[0;90m", |
|
bold: "\x1b[1m", |
|
reset: "\x1b[0m", |
|
}; |
|
|
|
function log(msg: string): void { |
|
console.log(msg); |
|
} |
|
|
|
function logError(msg: string): void { |
|
console.error(`${c.red}${msg}${c.reset}`); |
|
} |
|
|
|
async function promptYesNo(question: string): Promise<boolean> { |
|
const rl = readline.createInterface({ |
|
input: process.stdin, |
|
output: process.stdout, |
|
}); |
|
|
|
return new Promise((resolve) => { |
|
rl.question(question, (answer) => { |
|
rl.close(); |
|
const normalized = (answer || "Y").toLowerCase(); |
|
resolve(normalized.startsWith("y")); |
|
}); |
|
}); |
|
} |
|
|
|
// Parse CLI arguments |
|
function parseArgs(): CompileOptions { |
|
const args = process.argv.slice(2); |
|
const options: CompileOptions = { |
|
source: "./CLAUDE.src.md", |
|
output: "./CLAUDE.md", |
|
model: "sonnet", |
|
timeout: 120000, |
|
printOnly: false, |
|
}; |
|
|
|
for (let i = 0; i < args.length; i++) { |
|
const arg = args[i]; |
|
|
|
switch (arg) { |
|
case "--print": |
|
options.printOnly = true; |
|
break; |
|
case "--model": |
|
options.model = args[++i] || options.model; |
|
break; |
|
case "--source": |
|
options.source = args[++i] || options.source; |
|
break; |
|
case "--output": |
|
options.output = args[++i] || options.output; |
|
break; |
|
case "--timeout": |
|
options.timeout = parseInt(args[++i] || "120000", 10); |
|
break; |
|
case "-h": |
|
case "--help": |
|
showHelp(); |
|
process.exit(0); |
|
default: |
|
logError(`Unknown option: ${arg}`); |
|
process.exit(1); |
|
} |
|
} |
|
|
|
return options; |
|
} |
|
|
|
function showHelp(): void { |
|
const help = ` |
|
${c.bold}compile-claude${c.reset} ${c.dim}— semantic CLAUDE.md compiler${c.reset} |
|
|
|
Compresses CLAUDE.src.md into CLAUDE.md using LLM semantic compression. |
|
Source+Compiled pattern: you edit .src.md, this tool produces .md |
|
|
|
${c.bold}Usage:${c.reset} |
|
compile-claude [options] |
|
|
|
${c.bold}Options:${c.reset} |
|
--print Preview compressed output + stats (don't write) |
|
--model <name> Claude model to use [default: sonnet] |
|
--source <file> Source file path [default: ./CLAUDE.src.md] |
|
--output <file> Output file path [default: ./CLAUDE.md] |
|
--timeout <ms> Inference timeout in ms [default: 120000] |
|
-h, --help Show help |
|
|
|
${c.bold}Examples:${c.reset} |
|
compile-claude # Compress ./CLAUDE.src.md -> ./CLAUDE.md |
|
compile-claude --print # Preview without writing |
|
compile-claude --model haiku # Use Haiku for faster compression |
|
compile-claude --source custom.md # Use different source file |
|
`; |
|
console.log(help); |
|
} |
|
|
|
/** |
|
* Run inference via Claude CLI. |
|
* Uses `claude --print` which works with both API keys and subscription auth. |
|
*/ |
|
async function runInference( |
|
systemPrompt: string, |
|
userPrompt: string, |
|
model: string, |
|
timeout: number |
|
): Promise<{ success: boolean; output: string; error?: string; latencyMs: number }> { |
|
const start = Date.now(); |
|
|
|
try { |
|
const proc = Bun.spawn( |
|
["claude", "--print", "--model", model, "--output-format", "text", "-p", userPrompt], |
|
{ |
|
stdin: "ignore", |
|
stdout: "pipe", |
|
stderr: "pipe", |
|
env: { |
|
...process.env, |
|
CLAUDE_CODE_SYSTEM_PROMPT: systemPrompt, |
|
}, |
|
} |
|
); |
|
|
|
const timeoutId = setTimeout(() => proc.kill(), timeout); |
|
|
|
const [stdout, stderr] = await Promise.all([ |
|
new Response(proc.stdout).text(), |
|
new Response(proc.stderr).text(), |
|
]); |
|
|
|
clearTimeout(timeoutId); |
|
const exitCode = await proc.exited; |
|
const latencyMs = Date.now() - start; |
|
|
|
if (exitCode !== 0) { |
|
return { success: false, output: "", error: stderr || `Exit code ${exitCode}`, latencyMs }; |
|
} |
|
|
|
return { success: true, output: stdout.trim(), latencyMs }; |
|
} catch (err: unknown) { |
|
const latencyMs = Date.now() - start; |
|
const message = err instanceof Error ? err.message : String(err); |
|
return { success: false, output: "", error: message, latencyMs }; |
|
} |
|
} |
|
|
|
async function main(): Promise<void> { |
|
const options = parseArgs(); |
|
const startTime = Date.now(); |
|
|
|
// Validate prompt file exists |
|
if (!existsSync(PROMPT_FILE)) { |
|
logError(`Error: Prompt file not found: ${PROMPT_FILE}`); |
|
logError(`Place prompt.txt in the same directory as this script.`); |
|
process.exit(1); |
|
} |
|
|
|
// Validate claude CLI is available |
|
const which = Bun.spawnSync(["which", "claude"]); |
|
if (which.exitCode !== 0) { |
|
logError(`Error: 'claude' CLI not found in PATH.`); |
|
logError(`Install: https://docs.anthropic.com/en/docs/claude-code`); |
|
process.exit(1); |
|
} |
|
|
|
// First-run: offer to create CLAUDE.src.md from CLAUDE.md |
|
if (!existsSync(options.source)) { |
|
if (existsSync(options.output)) { |
|
log(`${c.yellow}No source file found: ${options.source}${c.reset}`); |
|
log(`${c.blue}Found existing: ${options.output}${c.reset}`); |
|
log(""); |
|
|
|
const confirmed = await promptYesNo( |
|
`Copy ${options.output} -> ${options.source} as initial source? [Y/n] ` |
|
); |
|
|
|
if (confirmed) { |
|
copyFileSync(options.output, options.source); |
|
log(`${c.green}Created ${options.source} from ${options.output}${c.reset}`); |
|
log(`${c.dim}Edit ${options.source}, then run compile-claude again.${c.reset}`); |
|
process.exit(0); |
|
} else { |
|
logError(`Aborted. Create ${options.source} manually.`); |
|
process.exit(1); |
|
} |
|
} else { |
|
logError(`Error: Source file not found: ${options.source}`); |
|
log(`${c.dim}Create CLAUDE.src.md or specify --source <path>${c.reset}`); |
|
process.exit(1); |
|
} |
|
} |
|
|
|
// Read inputs |
|
const systemPrompt = readFileSync(PROMPT_FILE, "utf-8"); |
|
const sourceContent = readFileSync(options.source, "utf-8"); |
|
|
|
// Stats: before |
|
const sourceBytes = Buffer.byteLength(sourceContent, "utf-8"); |
|
const sourceLines = sourceContent.split("\n").length; |
|
const sourceTokens = Math.floor(sourceBytes / 4); |
|
|
|
// Header |
|
log(`${c.bold}compile-claude${c.reset} ${c.dim}— semantic CLAUDE.md compiler${c.reset}`); |
|
log(`${c.dim}──────────────────────────────────────────────${c.reset}`); |
|
log(` Source: ${c.blue}${options.source}${c.reset} (${sourceBytes} bytes, ~${sourceTokens} tokens, ${sourceLines} lines)`); |
|
log(` Model: ${c.blue}${options.model}${c.reset} (timeout: ${options.timeout}ms)`); |
|
log(`${c.dim}──────────────────────────────────────────────${c.reset}`); |
|
log(""); |
|
|
|
// Compress |
|
log(`${c.yellow}Compressing...${c.reset}`); |
|
|
|
const result = await runInference(systemPrompt, sourceContent, options.model, options.timeout); |
|
|
|
if (!result.success || !result.output) { |
|
logError(`Error: Inference failed - ${result.error || "empty output"}`); |
|
process.exit(1); |
|
} |
|
|
|
const compressed = result.output; |
|
|
|
// Stats: after |
|
const compressedBytes = Buffer.byteLength(compressed, "utf-8"); |
|
const compressedLines = compressed.split("\n").length; |
|
const compressedTokens = Math.floor(compressedBytes / 4); |
|
const reduction = sourceBytes > 0 ? Math.floor(((sourceBytes - compressedBytes) / sourceBytes) * 100) : 0; |
|
const savedBytes = sourceBytes - compressedBytes; |
|
const elapsed = Date.now() - startTime; |
|
|
|
// Output stats |
|
log(""); |
|
log(`${c.bold}Stats:${c.reset}`); |
|
log(`${c.dim}──────────────────────────────────────────────${c.reset}`); |
|
log(` Before: ${sourceBytes} bytes (~${sourceTokens} tokens, ${sourceLines} lines)`); |
|
log(` After: ${compressedBytes} bytes (~${compressedTokens} tokens, ${compressedLines} lines)`); |
|
log(` Saved: ${c.green}${savedBytes} bytes (${reduction}% reduction)${c.reset}`); |
|
log(` Time: ${c.blue}${(elapsed / 1000).toFixed(1)}s${c.reset} (inference: ${result.latencyMs}ms)`); |
|
log(`${c.dim}──────────────────────────────────────────────${c.reset}`); |
|
|
|
if (options.printOnly) { |
|
log(""); |
|
log(`${c.bold}Preview:${c.reset}`); |
|
log(`${c.dim}──────────────────────────────────────────────${c.reset}`); |
|
log(compressed); |
|
log(`${c.dim}──────────────────────────────────────────────${c.reset}`); |
|
log(""); |
|
log(`${c.yellow}Dry run — no files written. Remove --print to compile.${c.reset}`); |
|
} else { |
|
writeFileSync(options.output, compressed); |
|
log(""); |
|
log(`${c.green}Wrote: ${options.output}${c.reset} (${compressedBytes} bytes)`); |
|
} |
|
} |
|
|
|
// Run |
|
if (import.meta.main) { |
|
main().catch((err) => { |
|
logError(`Fatal error: ${err.message}`); |
|
process.exit(1); |
|
}); |
|
} |