From b7fdae74190ab6ea45a81c1d6f545c33a2b75ee5 Mon Sep 17 00:00:00 2001 From: Ian Hunter Date: Sun, 11 Jan 2026 16:56:29 -0600 Subject: [PATCH 1/9] adding startupkit make --- packages/cli/src/cli.ts | 22 ++ packages/cli/src/cmd/make.integration.test.ts | 158 ++++++++++ packages/cli/src/cmd/make.test.ts | 283 ++++++++++++++++++ packages/cli/src/cmd/make.ts | 180 +++++++++++ templates/repo/.startupkit/ralph.json | 16 + 5 files changed, 659 insertions(+) create mode 100644 packages/cli/src/cmd/make.integration.test.ts create mode 100644 packages/cli/src/cmd/make.test.ts create mode 100644 packages/cli/src/cmd/make.ts create mode 100644 templates/repo/.startupkit/ralph.json diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index caf75c2..2385826 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 { make, initRalphConfig } from "./cmd/make" import { upgrade } from "./cmd/upgrade" export async function run() { @@ -48,6 +49,27 @@ 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", Number.parseInt) + .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..c3ab61f --- /dev/null +++ b/packages/cli/src/cmd/make.integration.test.ts @@ -0,0 +1,158 @@ +import fs from "node:fs" +import path from "node:path" +import { execSync, spawn } from "node:child_process" +import { afterAll, beforeAll, describe, expect, it } from "vitest" + +describe("CLI make - Integration Test with Claude", () => { + const testDir = path.join(process.cwd(), "tmp/test-make-ralph") + + beforeAll(() => { + if (fs.existsSync(testDir)) { + fs.rmSync(testDir, { recursive: true, force: true }) + } + fs.mkdirSync(testDir, { recursive: true }) + + fs.writeFileSync( + path.join(testDir, "SPEC.md"), + `# Test Spec + +## Tasks + +- [ ] Create a file called hello.txt with the content "Hello from Ralph" + +## Completion + +When done, create the file .ralph-complete +` + ) + + fs.writeFileSync(path.join(testDir, "progress.txt"), "") + }) + + afterAll(() => { + if (fs.existsSync(testDir)) { + fs.rmSync(testDir, { recursive: true, force: true }) + } + }) + + it( + "should run claude and execute a simple task", + () => { + const cliPath = path.join(process.cwd(), "dist/cli.js") + + if (!fs.existsSync(cliPath)) { + console.log("CLI not built, skipping integration test") + return + } + + const output = execSync(`node ${cliPath} make --iterations 1`, { + cwd: testDir, + encoding: "utf-8", + stdio: ["inherit", "pipe", "pipe"], + timeout: 120000 + }) + + console.log(output) + + expect(output).toContain("Starting ralph") + expect(output).toContain("Iteration 1") + + const helloFile = path.join(testDir, "hello.txt") + + console.log("\n--- Test Results ---") + console.log(`hello.txt exists: ${fs.existsSync(helloFile)}`) + + if (fs.existsSync(helloFile)) { + const content = fs.readFileSync(helloFile, "utf-8") + console.log(`hello.txt content: ${content}`) + expect(content).toContain("Hello from Ralph") + } + + expect(fs.existsSync(helloFile)).toBeTruthy() + }, + 180000 + ) +}) + +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..3432170 --- /dev/null +++ b/packages/cli/src/cmd/make.test.ts @@ -0,0 +1,283 @@ +import fs from "node:fs" +import path from "node:path" +import { afterEach, beforeEach, describe, expect, it } from "vitest" +import { + buildPrompt, + DEFAULT_CONFIG, + DEFAULT_PROMPT, + loadRalphConfig, + parseStreamLine, + type RalphConfig +} 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") + }) + }) +}) diff --git a/packages/cli/src/cmd/make.ts b/packages/cli/src/cmd/make.ts new file mode 100644 index 0000000..28693fc --- /dev/null +++ b/packages/cli/src/cmd/make.ts @@ -0,0 +1,180 @@ +import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync } from "node:fs" +import path from "node:path" +import { spawn as nodeSpawn } from "node:child_process" + +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.`) +} + +async function runIteration(config: RalphConfig, prompt: string): Promise { + return new Promise((resolve, reject) => { + const command = config.command ?? "claude" + const args = [...(config.args ?? []), prompt] + + const child = nodeSpawn(command, args, { + stdio: ["inherit", "pipe", "pipe"] + }) + + child.stdout?.on("data", (data: Buffer) => { + const lines = data.toString().split("\n") + 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 (code === 0) { + resolve() + } else { + reject(new Error(`${command} exited with code ${code}`)) + } + }) + + child.on("error", (err) => { + 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" +} From b29141de7c59943690d411992e7cca1dd28d8151 Mon Sep 17 00:00:00 2001 From: Ian Hunter Date: Sun, 11 Jan 2026 18:36:30 -0600 Subject: [PATCH 2/9] startupkit make command --- packages/cli/src/cmd/make.test.ts | 179 +++++++++++++++++++++++++++++- packages/cli/src/cmd/make.ts | 27 +++-- 2 files changed, 197 insertions(+), 9 deletions(-) diff --git a/packages/cli/src/cmd/make.test.ts b/packages/cli/src/cmd/make.test.ts index 3432170..6c883bf 100644 --- a/packages/cli/src/cmd/make.test.ts +++ b/packages/cli/src/cmd/make.test.ts @@ -1,13 +1,16 @@ import fs from "node:fs" import path from "node:path" -import { afterEach, beforeEach, describe, expect, it } from "vitest" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import { + buildCommand, buildPrompt, DEFAULT_CONFIG, DEFAULT_PROMPT, loadRalphConfig, parseStreamLine, - type RalphConfig + runIteration, + type RalphConfig, + type SpawnFn } from "./make" describe("make command - unit tests", () => { @@ -280,4 +283,176 @@ describe("make command - unit 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 + + 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)) + cb(0) + }, 0) + } + } + } + } + + await runIteration(DEFAULT_CONFIG, "test", mockSpawn) + + process.stdout.write = originalWrite + + expect(writtenOutput).toContain("Hello from Claude") + }) + }) }) diff --git a/packages/cli/src/cmd/make.ts b/packages/cli/src/cmd/make.ts index 28693fc..c7b3f03 100644 --- a/packages/cli/src/cmd/make.ts +++ b/packages/cli/src/cmd/make.ts @@ -128,14 +128,27 @@ export async function make(options: MakeOptions): Promise { console.log(`\n⚠️ Reached maximum iterations (${iterations}) without completion.`) } -async function runIteration(config: RalphConfig, prompt: string): Promise { +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 args = [...(config.args ?? []), prompt] + return { command, args } +} + +export async function runIteration( + config: RalphConfig, + prompt: string, + spawnFn: SpawnFn = (cmd, args) => nodeSpawn(cmd, args, { stdio: ["inherit", "pipe", "pipe"] }) +): Promise { return new Promise((resolve, reject) => { - const command = config.command ?? "claude" - const args = [...(config.args ?? []), prompt] + const { command, args } = buildCommand(config, prompt) - const child = nodeSpawn(command, args, { - stdio: ["inherit", "pipe", "pipe"] - }) + const child = spawnFn(command, args) child.stdout?.on("data", (data: Buffer) => { const lines = data.toString().split("\n") @@ -157,7 +170,7 @@ async function runIteration(config: RalphConfig, prompt: string): Promise } }) - child.on("error", (err) => { + child.on("error", (err: Error) => { console.error(`Error running ${command}:`, err.message) reject(err) }) From ed82c6d9f8c8f2799b5a38f20001c0bef1199b96 Mon Sep 17 00:00:00 2001 From: Ian Hunter Date: Sun, 11 Jan 2026 19:02:49 -0600 Subject: [PATCH 3/9] fixes --- packages/cli/src/cli.ts | 11 +- packages/cli/src/cmd/make.integration.test.ts | 56 ++++---- packages/cli/src/cmd/make.test.ts | 124 +++++++++++++----- packages/cli/src/cmd/make.ts | 44 +++++-- 4 files changed, 157 insertions(+), 78 deletions(-) diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 2385826..b0e8ea1 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -3,7 +3,7 @@ import { Command } from "commander" import { add } from "./cmd/add" import { init } from "./cmd/init" -import { make, initRalphConfig } from "./cmd/make" +import { initRalphConfig, make } from "./cmd/make" import { upgrade } from "./cmd/upgrade" export async function run() { @@ -53,7 +53,14 @@ export async function run() { .command("make [specfile]") .alias("ralph") .description("Run iterative AI-assisted development from a spec file") - .option("-i, --iterations ", "Maximum number of iterations", Number.parseInt) + .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({ diff --git a/packages/cli/src/cmd/make.integration.test.ts b/packages/cli/src/cmd/make.integration.test.ts index c3ab61f..501f6d3 100644 --- a/packages/cli/src/cmd/make.integration.test.ts +++ b/packages/cli/src/cmd/make.integration.test.ts @@ -1,6 +1,6 @@ +import { execSync, spawn } from "node:child_process" import fs from "node:fs" import path from "node:path" -import { execSync, spawn } from "node:child_process" import { afterAll, beforeAll, describe, expect, it } from "vitest" describe("CLI make - Integration Test with Claude", () => { @@ -35,43 +35,39 @@ When done, create the file .ralph-complete } }) - it( - "should run claude and execute a simple task", - () => { - const cliPath = path.join(process.cwd(), "dist/cli.js") + it("should run claude and execute a simple task", () => { + const cliPath = path.join(process.cwd(), "dist/cli.js") - if (!fs.existsSync(cliPath)) { - console.log("CLI not built, skipping integration test") - return - } + if (!fs.existsSync(cliPath)) { + console.log("CLI not built, skipping integration test") + return + } - const output = execSync(`node ${cliPath} make --iterations 1`, { - cwd: testDir, - encoding: "utf-8", - stdio: ["inherit", "pipe", "pipe"], - timeout: 120000 - }) + const output = execSync(`node ${cliPath} make --iterations 1`, { + cwd: testDir, + encoding: "utf-8", + stdio: ["inherit", "pipe", "pipe"], + timeout: 120000 + }) - console.log(output) + console.log(output) - expect(output).toContain("Starting ralph") - expect(output).toContain("Iteration 1") + expect(output).toContain("Starting ralph") + expect(output).toContain("Iteration 1") - const helloFile = path.join(testDir, "hello.txt") + const helloFile = path.join(testDir, "hello.txt") - console.log("\n--- Test Results ---") - console.log(`hello.txt exists: ${fs.existsSync(helloFile)}`) + console.log("\n--- Test Results ---") + console.log(`hello.txt exists: ${fs.existsSync(helloFile)}`) - if (fs.existsSync(helloFile)) { - const content = fs.readFileSync(helloFile, "utf-8") - console.log(`hello.txt content: ${content}`) - expect(content).toContain("Hello from Ralph") - } + if (fs.existsSync(helloFile)) { + const content = fs.readFileSync(helloFile, "utf-8") + console.log(`hello.txt content: ${content}`) + expect(content).toContain("Hello from Ralph") + } - expect(fs.existsSync(helloFile)).toBeTruthy() - }, - 180000 - ) + expect(fs.existsSync(helloFile)).toBeTruthy() + }, 180000) }) describe("CLI make - Dry run without Claude", () => { diff --git a/packages/cli/src/cmd/make.test.ts b/packages/cli/src/cmd/make.test.ts index 6c883bf..9940318 100644 --- a/packages/cli/src/cmd/make.test.ts +++ b/packages/cli/src/cmd/make.test.ts @@ -2,15 +2,15 @@ import fs from "node:fs" import path from "node:path" import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import { - buildCommand, - buildPrompt, DEFAULT_CONFIG, DEFAULT_PROMPT, + type RalphConfig, + type SpawnFn, + buildCommand, + buildPrompt, loadRalphConfig, parseStreamLine, - runIteration, - type RalphConfig, - type SpawnFn + runIteration } from "./make" describe("make command - unit tests", () => { @@ -196,10 +196,7 @@ describe("make command - unit tests", () => { }) it("should return default config for invalid JSON", () => { - fs.writeFileSync( - path.join(configDir, "ralph.json"), - "{ invalid json }" - ) + fs.writeFileSync(path.join(configDir, "ralph.json"), "{ invalid json }") const result = loadRalphConfig(testDir) @@ -322,8 +319,10 @@ describe("make command - unit tests", () => { expect(command).toBe("claude") expect(args).toEqual([ - "--permission-mode", "acceptEdits", - "--output-format", "stream-json", + "--permission-mode", + "acceptEdits", + "--output-format", + "stream-json", "--include-partial-messages", "--verbose", "-p", @@ -359,7 +358,9 @@ describe("make command - unit tests", () => { 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") + expect(spawnCalls[0].args[spawnCalls[0].args.length - 1]).toBe( + "Test prompt" + ) }) it("should call custom AI command when configured", async () => { @@ -385,7 +386,12 @@ describe("make command - unit tests", () => { await runIteration(config, "Custom prompt", mockSpawn) expect(spawnCalls[0].command).toBe("openai-cli") - expect(spawnCalls[0].args).toEqual(["--model", "gpt-4", "--prompt", "Custom prompt"]) + expect(spawnCalls[0].args).toEqual([ + "--model", + "gpt-4", + "--prompt", + "Custom prompt" + ]) }) it("should reject when command exits with non-zero code", async () => { @@ -407,7 +413,8 @@ describe("make command - unit tests", () => { 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) + if (event === "error") + setTimeout(() => cb(new Error("spawn failed")), 0) } }) @@ -424,35 +431,80 @@ describe("make command - unit tests", () => { return true }) as typeof process.stdout.write - 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)) - cb(0) - }, 0) + 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 - await runIteration(DEFAULT_CONFIG, "test", mockSpawn) + 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) + } + } + } + } - process.stdout.write = originalWrite + await runIteration(DEFAULT_CONFIG, "test", mockSpawn) - expect(writtenOutput).toContain("Hello from Claude") + expect(writtenOutput).toContain("Split message") + } finally { + process.stdout.write = originalWrite + } }) }) }) diff --git a/packages/cli/src/cmd/make.ts b/packages/cli/src/cmd/make.ts index c7b3f03..0bd77e1 100644 --- a/packages/cli/src/cmd/make.ts +++ b/packages/cli/src/cmd/make.ts @@ -1,6 +1,12 @@ -import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync } from "node:fs" -import path from "node:path" 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 @@ -25,8 +31,10 @@ export const DEFAULT_CONFIG: RalphConfig = { ai: "claude", command: "claude", args: [ - "--permission-mode", "acceptEdits", - "--output-format", "stream-json", + "--permission-mode", + "acceptEdits", + "--output-format", + "stream-json", "--include-partial-messages", "--verbose", "-p" @@ -62,7 +70,11 @@ function ensureConfigDir(cwd: string): void { } } -export function buildPrompt(config: RalphConfig, specfile: string, progress: string): string { +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) @@ -125,16 +137,24 @@ export async function make(options: MakeOptions): Promise { } } - console.log(`\n⚠️ Reached maximum iterations (${iterations}) without completion.`) + console.log( + `\n⚠️ Reached maximum iterations (${iterations}) without completion.` + ) } -export type SpawnFn = (command: string, args: string[]) => { +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[] } { +export function buildCommand( + config: RalphConfig, + prompt: string +): { command: string; args: string[] } { const command = config.command ?? "claude" const args = [...(config.args ?? []), prompt] return { command, args } @@ -143,15 +163,19 @@ export function buildCommand(config: RalphConfig, prompt: string): { command: st export async function runIteration( config: RalphConfig, prompt: string, - spawnFn: SpawnFn = (cmd, args) => nodeSpawn(cmd, args, { stdio: ["inherit", "pipe", "pipe"] }) + spawnFn: SpawnFn = (cmd, args) => + nodeSpawn(cmd, args, { stdio: ["inherit", "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) => { - const lines = data.toString().split("\n") + 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) From 2811909b2044f06413d6a59bea7294b955c0fdfb Mon Sep 17 00:00:00 2001 From: Ian Hunter Date: Mon, 12 Jan 2026 06:28:39 -0600 Subject: [PATCH 4/9] mayke integration tests working --- packages/cli/src/cmd/make.integration.test.ts | 123 +++++++++++++----- packages/cli/src/cmd/make.ts | 2 +- 2 files changed, 91 insertions(+), 34 deletions(-) diff --git a/packages/cli/src/cmd/make.integration.test.ts b/packages/cli/src/cmd/make.integration.test.ts index 501f6d3..32f8733 100644 --- a/packages/cli/src/cmd/make.integration.test.ts +++ b/packages/cli/src/cmd/make.integration.test.ts @@ -3,30 +3,66 @@ import fs from "node:fs" import path from "node:path" import { afterAll, beforeAll, describe, expect, it } from "vitest" -describe("CLI make - Integration Test with Claude", () => { - const testDir = path.join(process.cwd(), "tmp/test-make-ralph") +function assertClaudeAvailable(): void { + if (!process.env.ANTHROPIC_API_KEY) { + throw new Error("ANTHROPIC_API_KEY environment variable is required") + } + try { + execSync("claude --version", { encoding: "utf-8", timeout: 5000 }) + } catch { + throw new Error( + "Claude CLI is not installed. Run: npm install -g @anthropic-ai/claude-code" + ) + } +} + +describe("CLI make - Simple Claude Output Test", () => { + const testDir = path.join(process.cwd(), "tmp/test-make-hello") beforeAll(() => { + assertClaudeAvailable() if (fs.existsSync(testDir)) { fs.rmSync(testDir, { recursive: true, force: true }) } - fs.mkdirSync(testDir, { recursive: true }) + fs.mkdirSync(path.join(testDir, ".startupkit"), { recursive: true }) fs.writeFileSync( path.join(testDir, "SPEC.md"), - `# Test Spec - -## Tasks + `# Simple Test -- [ ] Create a file called hello.txt with the content "Hello from Ralph" - -## Completion - -When done, create the file .ralph-complete +Just output the word "HELLO_FROM_SPEC" to the console. Nothing else. Do not create any files. +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(() => { @@ -35,39 +71,60 @@ When done, create the file .ralph-complete } }) - it("should run claude and execute a simple task", () => { + it("should call claude and output text from SPEC.md", async () => { const cliPath = path.join(process.cwd(), "dist/cli.js") if (!fs.existsSync(cliPath)) { - console.log("CLI not built, skipping integration test") - return + throw new Error("CLI not built - run pnpm build first") } - const output = execSync(`node ${cliPath} make --iterations 1`, { - cwd: testDir, - encoding: "utf-8", - stdio: ["inherit", "pipe", "pipe"], - timeout: 120000 - }) + const output = await new Promise((resolve, reject) => { + let stdout = "" + let stderr = "" - console.log(output) + const child = spawn("node", [cliPath, "make"], { + cwd: testDir, + env: { ...process.env }, + stdio: ["ignore", "pipe", "pipe"] + }) - expect(output).toContain("Starting ralph") - expect(output).toContain("Iteration 1") + child.stdout.on("data", (data: Buffer) => { + const text = data.toString() + stdout += text + process.stdout.write(text) + }) - const helloFile = path.join(testDir, "hello.txt") + child.stderr.on("data", (data: Buffer) => { + const text = data.toString() + stderr += text + process.stderr.write(text) + }) - console.log("\n--- Test Results ---") - console.log(`hello.txt exists: ${fs.existsSync(helloFile)}`) + child.on("close", (code) => { + if (code === 0) { + resolve(stdout + stderr) + } else { + reject(new Error(`CLI exited with code ${code}\nstderr: ${stderr}`)) + } + }) - if (fs.existsSync(helloFile)) { - const content = fs.readFileSync(helloFile, "utf-8") - console.log(`hello.txt content: ${content}`) - expect(content).toContain("Hello from Ralph") - } + child.on("error", reject) - expect(fs.existsSync(helloFile)).toBeTruthy() - }, 180000) + setTimeout(() => { + child.kill() + reject(new Error("Test timed out after 60s")) + }, 60000) + }) + + 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", () => { diff --git a/packages/cli/src/cmd/make.ts b/packages/cli/src/cmd/make.ts index 0bd77e1..d31871d 100644 --- a/packages/cli/src/cmd/make.ts +++ b/packages/cli/src/cmd/make.ts @@ -164,7 +164,7 @@ export async function runIteration( config: RalphConfig, prompt: string, spawnFn: SpawnFn = (cmd, args) => - nodeSpawn(cmd, args, { stdio: ["inherit", "pipe", "pipe"] }) + nodeSpawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"] }) ): Promise { return new Promise((resolve, reject) => { const { command, args } = buildCommand(config, prompt) From ec686612185c8c2a8ef0414ea6e2f37a5c8ec9f1 Mon Sep 17 00:00:00 2001 From: Ian Hunter Date: Mon, 12 Jan 2026 06:32:53 -0600 Subject: [PATCH 5/9] Add env --- .github/workflows/test-cli.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/test-cli.yml b/.github/workflows/test-cli.yml index b075fed..2220e03 100644 --- a/.github/workflows/test-cli.yml +++ b/.github/workflows/test-cli.yml @@ -6,6 +6,9 @@ on: - main pull_request: +env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + jobs: test-cli: name: CLI Tests From c593dc7218028225d312d2ba817df8664efb1321 Mon Sep 17 00:00:00 2001 From: Ian Hunter Date: Mon, 12 Jan 2026 06:35:02 -0600 Subject: [PATCH 6/9] fix --- packages/cli/src/cmd/make.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/cmd/make.ts b/packages/cli/src/cmd/make.ts index d31871d..d5f532b 100644 --- a/packages/cli/src/cmd/make.ts +++ b/packages/cli/src/cmd/make.ts @@ -156,7 +156,8 @@ export function buildCommand( prompt: string ): { command: string; args: string[] } { const command = config.command ?? "claude" - const args = [...(config.args ?? []), prompt] + const defaultArgs = DEFAULT_CONFIG.args ?? [] + const args = [...(config.args ?? defaultArgs), prompt] return { command, args } } From 5e3409f7bff80196eea418a42326d513adeead1e Mon Sep 17 00:00:00 2001 From: Ian Hunter Date: Mon, 12 Jan 2026 06:36:45 -0600 Subject: [PATCH 7/9] fixes --- packages/cli/src/cmd/make.integration.test.ts | 18 ++++---- packages/cli/src/cmd/make.test.ts | 41 +++++++++++++++++++ packages/cli/src/cmd/make.ts | 5 +++ 3 files changed, 57 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/cmd/make.integration.test.ts b/packages/cli/src/cmd/make.integration.test.ts index 32f8733..8533919 100644 --- a/packages/cli/src/cmd/make.integration.test.ts +++ b/packages/cli/src/cmd/make.integration.test.ts @@ -30,7 +30,7 @@ describe("CLI make - Simple Claude Output Test", () => { path.join(testDir, "SPEC.md"), `# Simple Test -Just output the word "HELLO_FROM_SPEC" to the console. Nothing else. Do not create any files. +Just output the word "HELLO_FROM_SPEC" to the console. Nothing else. Then create .ralph-complete to signal you're done. ` ) @@ -100,7 +100,13 @@ Then create .ralph-complete to signal you're done. 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 { @@ -108,12 +114,10 @@ Then create .ralph-complete to signal you're done. } }) - child.on("error", reject) - - setTimeout(() => { - child.kill() - reject(new Error("Test timed out after 60s")) - }, 60000) + child.on("error", (err) => { + clearTimeout(timeoutId) + reject(err) + }) }) console.log("\n--- Output ---") diff --git a/packages/cli/src/cmd/make.test.ts b/packages/cli/src/cmd/make.test.ts index 9940318..37650ed 100644 --- a/packages/cli/src/cmd/make.test.ts +++ b/packages/cli/src/cmd/make.test.ts @@ -506,5 +506,46 @@ describe("make command - unit tests", () => { 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 index d5f532b..698286e 100644 --- a/packages/cli/src/cmd/make.ts +++ b/packages/cli/src/cmd/make.ts @@ -188,6 +188,11 @@ export async function runIteration( }) child.on("close", (code) => { + if (stdoutBuffer) { + const text = parseStreamLine(stdoutBuffer) + if (text) process.stdout.write(text) + } + if (code === 0) { resolve() } else { From 6fbb1996de46e730f292a671edb1d9e941fc349e Mon Sep 17 00:00:00 2001 From: Ian Hunter Date: Mon, 12 Jan 2026 06:43:44 -0600 Subject: [PATCH 8/9] fix env --- .github/workflows/test-cli.yml | 5 ++--- packages/cli/src/cmd/make.integration.test.ts | 17 ++++++++--------- 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/.github/workflows/test-cli.yml b/.github/workflows/test-cli.yml index 2220e03..d64c934 100644 --- a/.github/workflows/test-cli.yml +++ b/.github/workflows/test-cli.yml @@ -6,13 +6,12 @@ on: - main pull_request: -env: - ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} - 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/cmd/make.integration.test.ts b/packages/cli/src/cmd/make.integration.test.ts index 8533919..e0bb6a7 100644 --- a/packages/cli/src/cmd/make.integration.test.ts +++ b/packages/cli/src/cmd/make.integration.test.ts @@ -3,24 +3,23 @@ import fs from "node:fs" import path from "node:path" import { afterAll, beforeAll, describe, expect, it } from "vitest" -function assertClaudeAvailable(): void { - if (!process.env.ANTHROPIC_API_KEY) { - throw new Error("ANTHROPIC_API_KEY environment variable is required") - } +const hasAnthropicKey = Boolean(process.env.ANTHROPIC_API_KEY) + +function isClaudeCliInstalled(): boolean { try { execSync("claude --version", { encoding: "utf-8", timeout: 5000 }) + return true } catch { - throw new Error( - "Claude CLI is not installed. Run: npm install -g @anthropic-ai/claude-code" - ) + return false } } -describe("CLI make - Simple Claude Output Test", () => { +const canRunClaudeTests = hasAnthropicKey && isClaudeCliInstalled() + +describe.skipIf(!canRunClaudeTests)("CLI make - Simple Claude Output Test", () => { const testDir = path.join(process.cwd(), "tmp/test-make-hello") beforeAll(() => { - assertClaudeAvailable() if (fs.existsSync(testDir)) { fs.rmSync(testDir, { recursive: true, force: true }) } From 3196fb4df5b7fd248daaddcf78dfd3e31702d1d4 Mon Sep 17 00:00:00 2001 From: Ian Hunter Date: Mon, 12 Jan 2026 06:44:56 -0600 Subject: [PATCH 9/9] lft: --- packages/cli/src/cmd/make.integration.test.ts | 191 +++++++++--------- 1 file changed, 97 insertions(+), 94 deletions(-) diff --git a/packages/cli/src/cmd/make.integration.test.ts b/packages/cli/src/cmd/make.integration.test.ts index e0bb6a7..7388478 100644 --- a/packages/cli/src/cmd/make.integration.test.ts +++ b/packages/cli/src/cmd/make.integration.test.ts @@ -16,119 +16,122 @@ function isClaudeCliInstalled(): boolean { 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 }) +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 + 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" + 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 }) - } - }) + 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") + 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") - } + 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 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"] - }) + 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.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) - }) + 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}`)) - } - }) + 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) + child.on("error", (err) => { + clearTimeout(timeoutId) + reject(err) + }) }) - }) - console.log("\n--- Output ---") - console.log(output) - console.log("--- End ---\n") + 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) -}) + 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")