diff --git a/src/cli.ts b/src/cli.ts index bce3e7b..443a8fb 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -17,12 +17,13 @@ program program .command("run") - .description("Run a task with N parallel Claude Code agents") + .description("Run a task with N parallel AI coding agents") .argument("", "The coding task to perform") .option("-n, --attempts ", "Number of parallel attempts", "3") .option("-t, --test-cmd ", "Test command to verify results (e.g., 'npm test')") .option("--timeout ", "Timeout per agent in seconds", "300") .option("--model ", "Claude model to use", "sonnet") + .option("-r, --runner ", "AI coding tool to use (default: claude-code)") .option("--verbose", "Show detailed output from each agent") .action(async (prompt: string, opts) => { const attempts = parseInt(opts.attempts, 10); @@ -50,6 +51,7 @@ program testCmd: opts.testCmd, timeout, model: opts.model, + runner: opts.runner, verbose: opts.verbose ?? false, }); }); diff --git a/src/commands/run.ts b/src/commands/run.ts index bc08163..e66459b 100644 --- a/src/commands/run.ts +++ b/src/commands/run.ts @@ -1,15 +1,31 @@ import { mkdir, writeFile } from "node:fs/promises"; import { join } from "node:path"; -import { runClaudeAgent } from "../runners/claude-code.js"; +import { getDefaultRunner, getRunner } from "../runners/registry.js"; import { analyzeConvergence, recommend } from "../scoring/convergence.js"; import { runTests } from "../scoring/test-runner.js"; import type { AgentResult, EnsembleResult, RunOptions } from "../types.js"; import { displayApplyInstructions, displayHeader, displayResults } from "../utils/display.js"; -import { cleanupBranches, createWorktree, removeWorktree } from "../utils/git.js"; +import { cleanupBranches, createWorktree } from "../utils/git.js"; export async function run(opts: RunOptions): Promise { displayHeader(opts.prompt, opts.attempts, opts.model); + // Resolve runner + const runner = opts.runner ? getRunner(opts.runner) : getDefaultRunner(); + if (!runner) { + console.error(` Unknown runner: ${opts.runner}`); + console.error(" Available runners: claude-code"); + process.exit(1); + } + + const isAvailable = await runner.available(); + if (!isAvailable) { + console.error( + ` Runner "${runner.name}" is not available. Is ${runner.description} installed?`, + ); + process.exit(1); + } + // Clean up any leftover worktrees/branches from previous runs await cleanupBranches().catch(() => {}); @@ -25,11 +41,17 @@ export async function run(opts: RunOptions): Promise { console.log(); // Phase 2: Run agents in parallel - console.log(` Running ${opts.attempts} agents in parallel...`); + console.log(` Running ${opts.attempts} agents in parallel (${runner.name})...`); console.log(); const agentPromises = worktrees.map(({ id, path }) => - runClaudeAgent(id, opts.prompt, path, opts.model, opts.timeout, opts.verbose), + runner.run(id, { + prompt: opts.prompt, + worktreePath: path, + model: opts.model, + timeout: opts.timeout, + verbose: opts.verbose, + }), ); const agents: AgentResult[] = await Promise.all(agentPromises); diff --git a/src/runners/base.ts b/src/runners/base.ts new file mode 100644 index 0000000..4533281 --- /dev/null +++ b/src/runners/base.ts @@ -0,0 +1,27 @@ +import type { AgentResult } from "../types.js"; + +export interface RunnerOptions { + prompt: string; + worktreePath: string; + model: string; + timeout: number; + verbose: boolean; +} + +/** + * Interface for AI coding tool runners. Each runner wraps a specific + * tool (Claude Code, Aider, etc.) and executes a task in a worktree. + */ +export interface Runner { + /** Unique identifier for this runner (e.g., "claude-code", "aider") */ + name: string; + + /** Human-readable description */ + description: string; + + /** Check if this runner's tool is installed and available */ + available(): Promise; + + /** Execute a task and return the result */ + run(id: number, opts: RunnerOptions): Promise; +} diff --git a/src/runners/claude-code.ts b/src/runners/claude-code.ts index 4c34798..1ce9b59 100644 --- a/src/runners/claude-code.ts +++ b/src/runners/claude-code.ts @@ -1,118 +1,127 @@ import { spawn } from "node:child_process"; import type { AgentResult } from "../types.js"; import { getDiff, getDiffStats } from "../utils/git.js"; +import type { Runner, RunnerOptions } from "./base.js"; -export async function runClaudeAgent( - id: number, - prompt: string, - worktreePath: string, - model: string, - timeout: number, - verbose: boolean, -): Promise { - const start = Date.now(); - +async function isClaudeInstalled(): Promise { return new Promise((resolve) => { - let output = ""; - let error = ""; - let settled = false; + const child = spawn("claude", ["--version"], { stdio: "ignore" }); + child.on("close", (code) => resolve(code === 0)); + child.on("error", () => resolve(false)); + }); +} - const args = [ - "-p", - prompt, - "--output-format", - "text", - "--model", - model, - "--max-turns", - "50", - "--allowedTools", - "Edit", - "Write", - "Read", - "Glob", - "Grep", - "Bash", - ]; +export const claudeCodeRunner: Runner = { + name: "claude-code", + description: "Claude Code CLI (claude -p)", - const child = spawn("claude", args, { - cwd: worktreePath, - stdio: ["ignore", "pipe", "pipe"], - env: { ...process.env }, - }); + available: isClaudeInstalled, - child.stdout.on("data", (data: Buffer) => { - const chunk = data.toString(); - output += chunk; - if (verbose) { - process.stdout.write(` [agent ${id}] ${chunk}`); - } - }); + async run(id: number, opts: RunnerOptions): Promise { + const start = Date.now(); - child.stderr.on("data", (data: Buffer) => { - error += data.toString(); - }); + return new Promise((resolve) => { + let output = ""; + let error = ""; + let settled = false; + + const args = [ + "-p", + opts.prompt, + "--output-format", + "text", + "--model", + opts.model, + "--max-turns", + "50", + "--allowedTools", + "Edit", + "Write", + "Read", + "Glob", + "Grep", + "Bash", + ]; + + const child = spawn("claude", args, { + cwd: opts.worktreePath, + stdio: ["ignore", "pipe", "pipe"], + env: { ...process.env }, + }); - const timer = setTimeout(() => { - if (!settled) { + child.stdout.on("data", (data: Buffer) => { + const chunk = data.toString(); + output += chunk; + if (opts.verbose) { + process.stdout.write(` [agent ${id}] ${chunk}`); + } + }); + + child.stderr.on("data", (data: Buffer) => { + error += data.toString(); + }); + + const timer = setTimeout(() => { + if (!settled) { + settled = true; + child.kill("SIGTERM"); + resolve({ + id, + worktree: opts.worktreePath, + status: "timeout", + exitCode: -1, + duration: Date.now() - start, + output, + error: `Timed out after ${opts.timeout}s`, + diff: "", + filesChanged: [], + linesAdded: 0, + linesRemoved: 0, + }); + } + }, opts.timeout * 1000); + + child.on("close", async (code) => { + clearTimeout(timer); + if (settled) return; settled = true; - child.kill("SIGTERM"); + + const duration = Date.now() - start; + const diff = await getDiff(opts.worktreePath); + const stats = await getDiffStats(opts.worktreePath); + resolve({ id, - worktree: worktreePath, - status: "timeout", + worktree: opts.worktreePath, + status: code === 0 ? "success" : "error", + exitCode: code ?? 1, + duration, + output, + error: error || undefined, + diff, + ...stats, + }); + }); + + child.on("error", (err) => { + clearTimeout(timer); + if (settled) return; + settled = true; + + resolve({ + id, + worktree: opts.worktreePath, + status: "error", exitCode: -1, duration: Date.now() - start, output, - error: `Timed out after ${timeout}s`, + error: err.message, diff: "", filesChanged: [], linesAdded: 0, linesRemoved: 0, }); - } - }, timeout * 1000); - - child.on("close", async (code) => { - clearTimeout(timer); - if (settled) return; - settled = true; - - const duration = Date.now() - start; - const diff = await getDiff(worktreePath); - const stats = await getDiffStats(worktreePath); - - resolve({ - id, - worktree: worktreePath, - status: code === 0 ? "success" : "error", - exitCode: code ?? 1, - duration, - output, - error: error || undefined, - diff, - ...stats, - }); - }); - - child.on("error", (err) => { - clearTimeout(timer); - if (settled) return; - settled = true; - - resolve({ - id, - worktree: worktreePath, - status: "error", - exitCode: -1, - duration: Date.now() - start, - output, - error: err.message, - diff: "", - filesChanged: [], - linesAdded: 0, - linesRemoved: 0, }); }); - }); -} + }, +}; diff --git a/src/runners/registry.test.ts b/src/runners/registry.test.ts new file mode 100644 index 0000000..64de6ad --- /dev/null +++ b/src/runners/registry.test.ts @@ -0,0 +1,36 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { getDefaultRunner, getRunner, listRunners } from "./registry.js"; + +describe("runner registry", () => { + it("returns claude-code runner by name", () => { + const runner = getRunner("claude-code"); + assert.ok(runner); + assert.equal(runner.name, "claude-code"); + }); + + it("returns undefined for unknown runner", () => { + const runner = getRunner("nonexistent"); + assert.equal(runner, undefined); + }); + + it("lists all available runners", () => { + const runners = listRunners(); + assert.ok(runners.length >= 1); + assert.ok(runners.some((r) => r.name === "claude-code")); + }); + + it("returns claude-code as default runner", () => { + const runner = getDefaultRunner(); + assert.equal(runner.name, "claude-code"); + }); + + it("claude-code runner has required interface fields", () => { + const runner = getRunner("claude-code"); + assert.ok(runner); + assert.equal(typeof runner.name, "string"); + assert.equal(typeof runner.description, "string"); + assert.equal(typeof runner.available, "function"); + assert.equal(typeof runner.run, "function"); + }); +}); diff --git a/src/runners/registry.ts b/src/runners/registry.ts new file mode 100644 index 0000000..12fc579 --- /dev/null +++ b/src/runners/registry.ts @@ -0,0 +1,16 @@ +import type { Runner } from "./base.js"; +import { claudeCodeRunner } from "./claude-code.js"; + +const runners = new Map([["claude-code", claudeCodeRunner]]); + +export function getRunner(name: string): Runner | undefined { + return runners.get(name); +} + +export function listRunners(): Runner[] { + return [...runners.values()]; +} + +export function getDefaultRunner(): Runner { + return claudeCodeRunner; +} diff --git a/src/types.ts b/src/types.ts index bde75a0..076d733 100644 --- a/src/types.ts +++ b/src/types.ts @@ -5,6 +5,7 @@ export interface RunOptions { timeout: number; model: string; verbose: boolean; + runner?: string; } export interface AgentResult {