diff --git a/.github/workflows/test-cli.yml b/.github/workflows/test-cli.yml index b075fed..d64c934 100644 --- a/.github/workflows/test-cli.yml +++ b/.github/workflows/test-cli.yml @@ -10,6 +10,8 @@ jobs: test-cli: name: CLI Tests runs-on: ubuntu-latest + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} steps: - uses: actions/checkout@v4 diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index caf75c2..b0e8ea1 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -3,6 +3,7 @@ import { Command } from "commander" import { add } from "./cmd/add" import { init } from "./cmd/init" +import { initRalphConfig, make } from "./cmd/make" import { upgrade } from "./cmd/upgrade" export async function run() { @@ -48,6 +49,34 @@ export async function run() { }) }) + program + .command("make [specfile]") + .alias("ralph") + .description("Run iterative AI-assisted development from a spec file") + .option( + "-i, --iterations ", + "Maximum number of iterations", + (value) => { + const parsed = Number.parseInt(value, 10) + return Number.isNaN(parsed) ? undefined : parsed + } + ) + .option("-p, --progress ", "Progress file path") + .action(async (specfile, options) => { + await make({ + specfile, + iterations: options.iterations, + progress: options.progress + }) + }) + + program + .command("make:init") + .description("Initialize ralph config file at .startupkit/ralph.json") + .action(() => { + initRalphConfig() + }) + program .command("help") .description("Show help information") diff --git a/packages/cli/src/cmd/make.integration.test.ts b/packages/cli/src/cmd/make.integration.test.ts new file mode 100644 index 0000000..7388478 --- /dev/null +++ b/packages/cli/src/cmd/make.integration.test.ts @@ -0,0 +1,217 @@ +import { execSync, spawn } from "node:child_process" +import fs from "node:fs" +import path from "node:path" +import { afterAll, beforeAll, describe, expect, it } from "vitest" + +const hasAnthropicKey = Boolean(process.env.ANTHROPIC_API_KEY) + +function isClaudeCliInstalled(): boolean { + try { + execSync("claude --version", { encoding: "utf-8", timeout: 5000 }) + return true + } catch { + return false + } +} + +const canRunClaudeTests = hasAnthropicKey && isClaudeCliInstalled() + +describe.skipIf(!canRunClaudeTests)( + "CLI make - Simple Claude Output Test", + () => { + const testDir = path.join(process.cwd(), "tmp/test-make-hello") + + beforeAll(() => { + if (fs.existsSync(testDir)) { + fs.rmSync(testDir, { recursive: true, force: true }) + } + fs.mkdirSync(path.join(testDir, ".startupkit"), { recursive: true }) + + fs.writeFileSync( + path.join(testDir, "SPEC.md"), + `# Simple Test + +Just output the word "HELLO_FROM_SPEC" to the console. Nothing else. +Then create .ralph-complete to signal you're done. +` + ) + + fs.writeFileSync(path.join(testDir, "progress.txt"), "") + + fs.writeFileSync( + path.join(testDir, ".startupkit", "ralph.json"), + JSON.stringify( + { + ai: "claude", + command: "claude", + args: [ + "--permission-mode", + "acceptEdits", + "--output-format", + "stream-json", + "--include-partial-messages", + "--verbose", + "-p" + ], + iterations: 1, + specfile: "SPEC.md", + progress: "progress.txt", + complete: ".ralph-complete", + prompt: + "Read SPEC.md and do exactly what it says. Output HELLO_FROM_SPEC to console then create .ralph-complete" + }, + null, + "\t" + ) + ) + }) + + afterAll(() => { + if (fs.existsSync(testDir)) { + fs.rmSync(testDir, { recursive: true, force: true }) + } + }) + + it("should call claude and output text from SPEC.md", async () => { + const cliPath = path.join(process.cwd(), "dist/cli.js") + + if (!fs.existsSync(cliPath)) { + throw new Error("CLI not built - run pnpm build first") + } + + const output = await new Promise((resolve, reject) => { + let stdout = "" + let stderr = "" + + const child = spawn("node", [cliPath, "make"], { + cwd: testDir, + env: { ...process.env }, + stdio: ["ignore", "pipe", "pipe"] + }) + + child.stdout.on("data", (data: Buffer) => { + const text = data.toString() + stdout += text + process.stdout.write(text) + }) + + child.stderr.on("data", (data: Buffer) => { + const text = data.toString() + stderr += text + process.stderr.write(text) + }) + + const timeoutId = setTimeout(() => { + child.kill() + reject(new Error("Test timed out after 60s")) + }, 60000) + + child.on("close", (code) => { + clearTimeout(timeoutId) + if (code === 0) { + resolve(stdout + stderr) + } else { + reject(new Error(`CLI exited with code ${code}\nstderr: ${stderr}`)) + } + }) + + child.on("error", (err) => { + clearTimeout(timeoutId) + reject(err) + }) + }) + + console.log("\n--- Output ---") + console.log(output) + console.log("--- End ---\n") + + expect(output).toContain("Starting ralph") + expect(output).toContain("AI: claude") + expect(output).toContain("Iteration 1") + expect(output).toContain("HELLO_FROM_SPEC") + }, 90000) + } +) + +describe("CLI make - Dry run without Claude", () => { + const testDir = path.join(process.cwd(), "tmp/test-make-dry") + + beforeAll(() => { + if (fs.existsSync(testDir)) { + fs.rmSync(testDir, { recursive: true, force: true }) + } + fs.mkdirSync(testDir, { recursive: true }) + + fs.writeFileSync(path.join(testDir, "SPEC.md"), "# Simple spec") + }) + + afterAll(() => { + if (fs.existsSync(testDir)) { + fs.rmSync(testDir, { recursive: true, force: true }) + } + }) + + it("should fail gracefully when spec file is missing", async () => { + const cliPath = path.join(process.cwd(), "dist/cli.js") + + if (!fs.existsSync(cliPath)) { + console.log("CLI not built, skipping test") + return + } + + const emptyDir = path.join(testDir, "empty") + fs.mkdirSync(emptyDir, { recursive: true }) + + const result = await new Promise<{ code: number; output: string }>( + (resolve) => { + let output = "" + + const child = spawn("node", [cliPath, "make"], { + cwd: emptyDir + }) + + child.stdout?.on("data", (data: Buffer) => { + output += data.toString() + }) + + child.stderr?.on("data", (data: Buffer) => { + output += data.toString() + }) + + child.on("close", (code) => { + resolve({ code: code ?? 1, output }) + }) + } + ) + + expect(result.code).toBe(1) + expect(result.output).toContain("Spec file not found") + }) + + it("should show help for make command", async () => { + const cliPath = path.join(process.cwd(), "dist/cli.js") + + if (!fs.existsSync(cliPath)) { + console.log("CLI not built, skipping test") + return + } + + const result = await new Promise((resolve) => { + let output = "" + + const child = spawn("node", [cliPath, "make", "--help"]) + + child.stdout?.on("data", (data: Buffer) => { + output += data.toString() + }) + + child.on("close", () => { + resolve(output) + }) + }) + + expect(result).toContain("Run iterative AI-assisted development") + expect(result).toContain("--iterations") + expect(result).toContain("--progress") + }) +}) diff --git a/packages/cli/src/cmd/make.test.ts b/packages/cli/src/cmd/make.test.ts new file mode 100644 index 0000000..37650ed --- /dev/null +++ b/packages/cli/src/cmd/make.test.ts @@ -0,0 +1,551 @@ +import fs from "node:fs" +import path from "node:path" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { + DEFAULT_CONFIG, + DEFAULT_PROMPT, + type RalphConfig, + type SpawnFn, + buildCommand, + buildPrompt, + loadRalphConfig, + parseStreamLine, + runIteration +} from "./make" + +describe("make command - unit tests", () => { + describe("parseStreamLine", () => { + it("should extract text from stream_event with delta", () => { + const line = JSON.stringify({ + type: "stream_event", + event: { delta: { text: "Hello world" } } + }) + + const result = parseStreamLine(line) + + expect(result).toBe("Hello world") + }) + + it("should return null for empty lines", () => { + expect(parseStreamLine("")).toBeNull() + expect(parseStreamLine(" ")).toBeNull() + expect(parseStreamLine("\t")).toBeNull() + }) + + it("should return null for stream_event without delta text", () => { + const line = JSON.stringify({ + type: "stream_event", + event: { other: "data" } + }) + + const result = parseStreamLine(line) + + expect(result).toBeNull() + }) + + it("should return null for non-stream_event types", () => { + const line = JSON.stringify({ + type: "other_event", + event: { delta: { text: "ignored" } } + }) + + const result = parseStreamLine(line) + + expect(result).toBeNull() + }) + + it("should return raw line for invalid JSON", () => { + const line = "This is not JSON" + + const result = parseStreamLine(line) + + expect(result).toBe("This is not JSON") + }) + + it("should handle partial JSON gracefully", () => { + const line = '{"type": "stream_event"' + + const result = parseStreamLine(line) + + expect(result).toBe('{"type": "stream_event"') + }) + }) + + describe("buildPrompt", () => { + it("should use default prompt when config.prompt is undefined", () => { + const config: RalphConfig = {} + + const result = buildPrompt(config, "SPEC.md", "progress.txt") + + expect(result).toBe(DEFAULT_PROMPT) + }) + + it("should replace SPEC.md with custom specfile", () => { + const config: RalphConfig = { + prompt: "Read SPEC.md and do things" + } + + const result = buildPrompt(config, "MYSPEC.md", "progress.txt") + + expect(result).toBe("Read MYSPEC.md and do things") + }) + + it("should replace progress.txt with custom progress file", () => { + const config: RalphConfig = { + prompt: "Read progress.txt for status" + } + + const result = buildPrompt(config, "SPEC.md", "status.log") + + expect(result).toBe("Read status.log for status") + }) + + it("should replace multiple occurrences of SPEC.md", () => { + const config: RalphConfig = { + prompt: "Read SPEC.md first. Update SPEC.md when done." + } + + const result = buildPrompt(config, "TODO.md", "progress.txt") + + expect(result).toBe("Read TODO.md first. Update TODO.md when done.") + }) + + it("should replace multiple occurrences of progress.txt", () => { + const config: RalphConfig = { + prompt: "Check progress.txt. Write to progress.txt." + } + + const result = buildPrompt(config, "SPEC.md", "log.txt") + + expect(result).toBe("Check log.txt. Write to log.txt.") + }) + + it("should handle both replacements together", () => { + const config: RalphConfig = { + prompt: "Read SPEC.md and progress.txt. Update SPEC.md." + } + + const result = buildPrompt(config, "plan.md", "history.txt") + + expect(result).toBe("Read plan.md and history.txt. Update plan.md.") + }) + }) + + describe("loadRalphConfig", () => { + const testDir = path.join(process.cwd(), "tmp/test-ralph-config") + const configDir = path.join(testDir, ".startupkit") + + beforeEach(() => { + if (fs.existsSync(testDir)) { + fs.rmSync(testDir, { recursive: true, force: true }) + } + fs.mkdirSync(configDir, { recursive: true }) + }) + + afterEach(() => { + if (fs.existsSync(testDir)) { + fs.rmSync(testDir, { recursive: true, force: true }) + } + }) + + it("should return default config when no config file exists", () => { + fs.rmSync(configDir, { recursive: true, force: true }) + + const result = loadRalphConfig(testDir) + + expect(result).toEqual(DEFAULT_CONFIG) + }) + + it("should load and merge config from ralph.json", () => { + const customConfig = { + iterations: 20, + specfile: "CUSTOM.md" + } + fs.writeFileSync( + path.join(configDir, "ralph.json"), + JSON.stringify(customConfig) + ) + + const result = loadRalphConfig(testDir) + + expect(result.iterations).toBe(20) + expect(result.specfile).toBe("CUSTOM.md") + expect(result.ai).toBe("claude") + expect(result.command).toBe("claude") + }) + + it("should override all default values with config file values", () => { + const customConfig: RalphConfig = { + ai: "gpt", + command: "openai", + args: ["--model", "gpt-4"], + iterations: 5, + specfile: "TODO.md", + progress: "log.txt", + complete: ".done", + prompt: "Custom prompt" + } + fs.writeFileSync( + path.join(configDir, "ralph.json"), + JSON.stringify(customConfig) + ) + + const result = loadRalphConfig(testDir) + + expect(result).toEqual(customConfig) + }) + + it("should return default config for invalid JSON", () => { + fs.writeFileSync(path.join(configDir, "ralph.json"), "{ invalid json }") + + const result = loadRalphConfig(testDir) + + expect(result).toEqual(DEFAULT_CONFIG) + }) + + it("should handle empty config file", () => { + fs.writeFileSync(path.join(configDir, "ralph.json"), "{}") + + const result = loadRalphConfig(testDir) + + expect(result).toEqual(DEFAULT_CONFIG) + }) + + it("should preserve custom args array", () => { + const customConfig = { + args: ["-p", "--verbose"] + } + fs.writeFileSync( + path.join(configDir, "ralph.json"), + JSON.stringify(customConfig) + ) + + const result = loadRalphConfig(testDir) + + expect(result.args).toEqual(["-p", "--verbose"]) + }) + }) + + describe("DEFAULT_CONFIG", () => { + it("should have claude as default AI", () => { + expect(DEFAULT_CONFIG.ai).toBe("claude") + expect(DEFAULT_CONFIG.command).toBe("claude") + }) + + it("should have correct default args for claude", () => { + expect(DEFAULT_CONFIG.args).toContain("--permission-mode") + expect(DEFAULT_CONFIG.args).toContain("acceptEdits") + expect(DEFAULT_CONFIG.args).toContain("--output-format") + expect(DEFAULT_CONFIG.args).toContain("stream-json") + expect(DEFAULT_CONFIG.args).toContain("-p") + }) + + it("should have sensible defaults", () => { + expect(DEFAULT_CONFIG.iterations).toBe(10) + expect(DEFAULT_CONFIG.specfile).toBe("SPEC.md") + expect(DEFAULT_CONFIG.progress).toBe("progress.txt") + expect(DEFAULT_CONFIG.complete).toBe(".ralph-complete") + }) + + it("should have a default prompt", () => { + expect(DEFAULT_CONFIG.prompt).toBeDefined() + expect(DEFAULT_CONFIG.prompt).toContain("SPEC.md") + expect(DEFAULT_CONFIG.prompt).toContain("progress.txt") + expect(DEFAULT_CONFIG.prompt).toContain(".ralph-complete") + }) + }) + + describe("DEFAULT_PROMPT", () => { + it("should mention reading SPEC.md", () => { + expect(DEFAULT_PROMPT).toContain("Read SPEC.md") + }) + + it("should mention progress.txt", () => { + expect(DEFAULT_PROMPT).toContain("progress.txt") + }) + + it("should mention single task constraint", () => { + expect(DEFAULT_PROMPT).toContain("ONLY WORK ON A SINGLE TASK") + }) + + it("should mention completion file", () => { + expect(DEFAULT_PROMPT).toContain(".ralph-complete") + }) + + it("should mention committing changes", () => { + expect(DEFAULT_PROMPT).toContain("Commit your changes") + }) + + it("should mention running tests", () => { + expect(DEFAULT_PROMPT).toContain("Run tests") + }) + }) + + describe("buildCommand", () => { + it("should use claude as default command", () => { + const config: RalphConfig = {} + const { command, args } = buildCommand(config, "test prompt") + + expect(command).toBe("claude") + }) + + it("should use custom command from config", () => { + const config: RalphConfig = { command: "openai" } + const { command } = buildCommand(config, "test prompt") + + expect(command).toBe("openai") + }) + + it("should append prompt to args", () => { + const config: RalphConfig = { args: ["--flag", "value"] } + const { args } = buildCommand(config, "my prompt") + + expect(args).toEqual(["--flag", "value", "my prompt"]) + }) + + it("should use default args when not specified", () => { + const config: RalphConfig = {} + const { args } = buildCommand(config, "test") + + expect(args).toContain("--permission-mode") + expect(args).toContain("acceptEdits") + expect(args).toContain("--output-format") + expect(args).toContain("stream-json") + expect(args[args.length - 1]).toBe("test") + }) + + it("should build correct claude command with default config", () => { + const { command, args } = buildCommand(DEFAULT_CONFIG, "Do the task") + + expect(command).toBe("claude") + expect(args).toEqual([ + "--permission-mode", + "acceptEdits", + "--output-format", + "stream-json", + "--include-partial-messages", + "--verbose", + "-p", + "Do the task" + ]) + }) + }) + + describe("runIteration - Claude invocation", () => { + it("should call spawn with claude command and correct args", async () => { + const spawnCalls: Array<{ command: string; args: string[] }> = [] + + const mockSpawn: SpawnFn = (command, args) => { + spawnCalls.push({ command, args }) + return { + stdout: { on: vi.fn() }, + stderr: { on: vi.fn() }, + on: (event: string, cb: (code: number) => void) => { + if (event === "close") setTimeout(() => cb(0), 0) + } + } + } + + const config: RalphConfig = { + command: "claude", + args: ["--permission-mode", "acceptEdits", "-p"] + } + + await runIteration(config, "Test prompt", mockSpawn) + + expect(spawnCalls).toHaveLength(1) + expect(spawnCalls[0].command).toBe("claude") + expect(spawnCalls[0].args).toContain("--permission-mode") + expect(spawnCalls[0].args).toContain("acceptEdits") + expect(spawnCalls[0].args).toContain("-p") + expect(spawnCalls[0].args[spawnCalls[0].args.length - 1]).toBe( + "Test prompt" + ) + }) + + it("should call custom AI command when configured", async () => { + const spawnCalls: Array<{ command: string; args: string[] }> = [] + + const mockSpawn: SpawnFn = (command, args) => { + spawnCalls.push({ command, args }) + return { + stdout: { on: vi.fn() }, + stderr: { on: vi.fn() }, + on: (event: string, cb: (code: number) => void) => { + if (event === "close") setTimeout(() => cb(0), 0) + } + } + } + + const config: RalphConfig = { + ai: "gpt-4", + command: "openai-cli", + args: ["--model", "gpt-4", "--prompt"] + } + + await runIteration(config, "Custom prompt", mockSpawn) + + expect(spawnCalls[0].command).toBe("openai-cli") + expect(spawnCalls[0].args).toEqual([ + "--model", + "gpt-4", + "--prompt", + "Custom prompt" + ]) + }) + + it("should reject when command exits with non-zero code", async () => { + const mockSpawn: SpawnFn = () => ({ + stdout: { on: vi.fn() }, + stderr: { on: vi.fn() }, + on: (event: string, cb: (code: number) => void) => { + if (event === "close") setTimeout(() => cb(1), 0) + } + }) + + await expect( + runIteration(DEFAULT_CONFIG, "test", mockSpawn) + ).rejects.toThrow("claude exited with code 1") + }) + + it("should reject when spawn errors", async () => { + const mockSpawn: SpawnFn = () => ({ + stdout: { on: vi.fn() }, + stderr: { on: vi.fn() }, + on: (event: string, cb: (err: Error) => void) => { + if (event === "error") + setTimeout(() => cb(new Error("spawn failed")), 0) + } + }) + + await expect( + runIteration(DEFAULT_CONFIG, "test", mockSpawn) + ).rejects.toThrow("spawn failed") + }) + + it("should process stdout through parseStreamLine", async () => { + const writtenOutput: string[] = [] + const originalWrite = process.stdout.write.bind(process.stdout) + process.stdout.write = ((chunk: string) => { + writtenOutput.push(chunk) + return true + }) as typeof process.stdout.write + + try { + const mockSpawn: SpawnFn = () => { + let stdoutCallback: ((data: Buffer) => void) | null = null + return { + stdout: { + on: (event: string, cb: (data: Buffer) => void) => { + if (event === "data") stdoutCallback = cb + } + }, + stderr: { on: vi.fn() }, + on: (event: string, cb: (code: number) => void) => { + if (event === "close") { + setTimeout(() => { + const jsonLine = JSON.stringify({ + type: "stream_event", + event: { delta: { text: "Hello from Claude" } } + }) + stdoutCallback?.(Buffer.from(`${jsonLine}\n`)) + cb(0) + }, 0) + } + } + } + } + + await runIteration(DEFAULT_CONFIG, "test", mockSpawn) + + expect(writtenOutput).toContain("Hello from Claude") + } finally { + process.stdout.write = originalWrite + } + }) + + it("should handle JSON split across multiple chunks", async () => { + const writtenOutput: string[] = [] + const originalWrite = process.stdout.write.bind(process.stdout) + process.stdout.write = ((chunk: string) => { + writtenOutput.push(chunk) + return true + }) as typeof process.stdout.write + + try { + const mockSpawn: SpawnFn = () => { + let stdoutCallback: ((data: Buffer) => void) | null = null + return { + stdout: { + on: (event: string, cb: (data: Buffer) => void) => { + if (event === "data") stdoutCallback = cb + } + }, + stderr: { on: vi.fn() }, + on: (event: string, cb: (code: number) => void) => { + if (event === "close") { + setTimeout(() => { + const jsonLine = JSON.stringify({ + type: "stream_event", + event: { delta: { text: "Split message" } } + }) + const midpoint = Math.floor(jsonLine.length / 2) + stdoutCallback?.(Buffer.from(jsonLine.slice(0, midpoint))) + stdoutCallback?.(Buffer.from(`${jsonLine.slice(midpoint)}\n`)) + cb(0) + }, 0) + } + } + } + } + + await runIteration(DEFAULT_CONFIG, "test", mockSpawn) + + expect(writtenOutput).toContain("Split message") + } finally { + process.stdout.write = originalWrite + } + }) + + it("should flush remaining buffer when stream closes without trailing newline", async () => { + const writtenOutput: string[] = [] + const originalWrite = process.stdout.write.bind(process.stdout) + process.stdout.write = ((chunk: string) => { + writtenOutput.push(chunk) + return true + }) as typeof process.stdout.write + + try { + const mockSpawn: SpawnFn = () => { + let stdoutCallback: ((data: Buffer) => void) | null = null + return { + stdout: { + on: (event: string, cb: (data: Buffer) => void) => { + if (event === "data") stdoutCallback = cb + } + }, + stderr: { on: vi.fn() }, + on: (event: string, cb: (code: number) => void) => { + if (event === "close") { + setTimeout(() => { + const jsonLine = JSON.stringify({ + type: "stream_event", + event: { delta: { text: "Final message" } } + }) + stdoutCallback?.(Buffer.from(jsonLine)) + cb(0) + }, 0) + } + } + } + } + + await runIteration(DEFAULT_CONFIG, "test", mockSpawn) + + expect(writtenOutput).toContain("Final message") + } finally { + process.stdout.write = originalWrite + } + }) + }) +}) diff --git a/packages/cli/src/cmd/make.ts b/packages/cli/src/cmd/make.ts new file mode 100644 index 0000000..698286e --- /dev/null +++ b/packages/cli/src/cmd/make.ts @@ -0,0 +1,223 @@ +import { spawn as nodeSpawn } from "node:child_process" +import { + existsSync, + mkdirSync, + readFileSync, + unlinkSync, + writeFileSync +} from "node:fs" +import path from "node:path" + +export interface RalphConfig { + ai?: string + command?: string + args?: string[] + iterations?: number + specfile?: string + progress?: string + complete?: string + prompt?: string +} + +interface MakeOptions { + specfile?: string + iterations?: number + progress?: string +} + +export const DEFAULT_PROMPT = `Read SPEC.md and progress.txt. Find the highest-priority incomplete task and implement it. Run tests and type checks. Update SPEC.md to mark what was done. Append progress to progress.txt. Commit your changes. ONLY WORK ON A SINGLE TASK. If all tasks complete, create file .ralph-complete` + +export const DEFAULT_CONFIG: RalphConfig = { + ai: "claude", + command: "claude", + args: [ + "--permission-mode", + "acceptEdits", + "--output-format", + "stream-json", + "--include-partial-messages", + "--verbose", + "-p" + ], + iterations: 10, + specfile: "SPEC.md", + progress: "progress.txt", + complete: ".ralph-complete", + prompt: DEFAULT_PROMPT +} + +export function loadRalphConfig(cwd: string): RalphConfig { + const configPath = path.join(cwd, ".startupkit", "ralph.json") + + if (!existsSync(configPath)) { + return DEFAULT_CONFIG + } + + try { + const content = readFileSync(configPath, "utf-8") + const config = JSON.parse(content) as RalphConfig + return { ...DEFAULT_CONFIG, ...config } + } catch { + console.warn(`Warning: Could not parse ${configPath}, using defaults`) + return DEFAULT_CONFIG + } +} + +function ensureConfigDir(cwd: string): void { + const configDir = path.join(cwd, ".startupkit") + if (!existsSync(configDir)) { + mkdirSync(configDir, { recursive: true }) + } +} + +export function buildPrompt( + config: RalphConfig, + specfile: string, + progress: string +): string { + let prompt = config.prompt ?? DEFAULT_PROMPT + prompt = prompt.replace(/SPEC\.md/g, specfile) + prompt = prompt.replace(/progress\.txt/g, progress) + return prompt +} + +export function parseStreamLine(line: string): string | null { + if (!line.trim()) return null + try { + const obj = JSON.parse(line) + if (obj.type === "stream_event" && obj.event?.delta?.text) { + return obj.event.delta.text + } + return null + } catch { + return line + } +} + +export async function make(options: MakeOptions): Promise { + const cwd = process.cwd() + const config = loadRalphConfig(cwd) + + const specfile = options.specfile ?? config.specfile ?? "SPEC.md" + const iterations = options.iterations ?? config.iterations ?? 10 + const progressFile = options.progress ?? config.progress ?? "progress.txt" + const completeFile = config.complete ?? ".ralph-complete" + const completePath = path.resolve(cwd, completeFile) + + if (!existsSync(specfile)) { + console.error(`Error: Spec file not found: ${specfile}`) + process.exit(1) + } + + ensureConfigDir(cwd) + + const prompt = buildPrompt(config, specfile, progressFile) + + console.log(`\n🚀 Starting ralph`) + console.log(` AI: ${config.ai ?? "claude"}`) + console.log(` Spec file: ${specfile}`) + console.log(` Progress file: ${progressFile}`) + console.log(` Max iterations: ${iterations}\n`) + + for (let i = 1; i <= iterations; i++) { + console.log("") + console.log("==========================================") + console.log(`=== Iteration ${i} ===`) + console.log("==========================================") + console.log("") + + await runIteration(config, prompt) + + console.log("") + + if (existsSync(completePath)) { + console.log(`\n✅ Spec complete after ${i} iteration(s).`) + unlinkSync(completePath) + return + } + } + + console.log( + `\n⚠️ Reached maximum iterations (${iterations}) without completion.` + ) +} + +export type SpawnFn = ( + command: string, + args: string[] +) => { + stdout: { on: (event: string, cb: (data: Buffer) => void) => void } | null + stderr: { on: (event: string, cb: (data: Buffer) => void) => void } | null + on: (event: string, cb: (codeOrErr: number | Error) => void) => void +} + +export function buildCommand( + config: RalphConfig, + prompt: string +): { command: string; args: string[] } { + const command = config.command ?? "claude" + const defaultArgs = DEFAULT_CONFIG.args ?? [] + const args = [...(config.args ?? defaultArgs), prompt] + return { command, args } +} + +export async function runIteration( + config: RalphConfig, + prompt: string, + spawnFn: SpawnFn = (cmd, args) => + nodeSpawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"] }) +): Promise { + return new Promise((resolve, reject) => { + const { command, args } = buildCommand(config, prompt) + + const child = spawnFn(command, args) + let stdoutBuffer = "" + + child.stdout?.on("data", (data: Buffer) => { + stdoutBuffer += data.toString() + const lines = stdoutBuffer.split("\n") + stdoutBuffer = lines.pop() ?? "" + for (const line of lines) { + const text = parseStreamLine(line) + if (text) process.stdout.write(text) + } + }) + + child.stderr?.on("data", (data: Buffer) => { + process.stderr.write(data) + }) + + child.on("close", (code) => { + if (stdoutBuffer) { + const text = parseStreamLine(stdoutBuffer) + if (text) process.stdout.write(text) + } + + if (code === 0) { + resolve() + } else { + reject(new Error(`${command} exited with code ${code}`)) + } + }) + + child.on("error", (err: Error) => { + console.error(`Error running ${command}:`, err.message) + reject(err) + }) + }) +} + +export function initRalphConfig(): void { + const cwd = process.cwd() + const configDir = path.join(cwd, ".startupkit") + const configPath = path.join(configDir, "ralph.json") + + if (existsSync(configPath)) { + console.log(`Config already exists at ${configPath}`) + return + } + + ensureConfigDir(cwd) + writeFileSync(configPath, JSON.stringify(DEFAULT_CONFIG, null, "\t")) + console.log(`Created ralph config at ${configPath}`) +} diff --git a/templates/repo/.startupkit/ralph.json b/templates/repo/.startupkit/ralph.json new file mode 100644 index 0000000..3c21c5a --- /dev/null +++ b/templates/repo/.startupkit/ralph.json @@ -0,0 +1,16 @@ +{ + "ai": "claude", + "command": "claude", + "args": [ + "--permission-mode", "acceptEdits", + "--output-format", "stream-json", + "--include-partial-messages", + "--verbose", + "-p" + ], + "iterations": 10, + "specfile": "SPEC.md", + "progress": "progress.txt", + "complete": ".ralph-complete", + "prompt": "Read SPEC.md and progress.txt. Find the highest-priority incomplete task and implement it. Run tests and type checks. Update SPEC.md to mark what was done. Append progress to progress.txt. Commit your changes. ONLY WORK ON A SINGLE TASK. If all tasks complete, create file .ralph-complete" +}