Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/test-cli.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 29 additions & 0 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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 <number>",
"Maximum number of iterations",
(value) => {
const parsed = Number.parseInt(value, 10)
return Number.isNaN(parsed) ? undefined : parsed
}
)
.option("-p, --progress <file>", "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")
Expand Down
217 changes: 217 additions & 0 deletions packages/cli/src/cmd/make.integration.test.ts
Original file line number Diff line number Diff line change
@@ -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<string>((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<string>((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")
})
})
Loading