diff --git a/.changeset/extract-headless-runner.md b/.changeset/extract-headless-runner.md new file mode 100644 index 00000000..c921ae79 --- /dev/null +++ b/.changeset/extract-headless-runner.md @@ -0,0 +1,10 @@ +--- +"create-expert": patch +--- + +Refactor: Extract headless runner from CLI + +- Extract headless execution logic to `src/lib/headless-runner.ts` +- Reduce CLI file size from 377 to 230 lines (39% reduction) +- CLI now only handles argument parsing and validation +- Enable unit testing of headless execution logic diff --git a/apps/create-expert/bin/cli.ts b/apps/create-expert/bin/cli.ts index 846051c5..4201f9fb 100644 --- a/apps/create-expert/bin/cli.ts +++ b/apps/create-expert/bin/cli.ts @@ -5,14 +5,12 @@ import { join } from "node:path" import { Command } from "commander" import { config } from "dotenv" import packageJson from "../package.json" with { type: "json" } - -import type { PerstackEvent } from "../src/index.js" import { detectAllLLMs, detectAllRuntimes, - formatPerstackEvent, generateProjectFiles, getDefaultModel, + runHeadlessExecution, } from "../src/index.js" import type { LLMProvider, RuntimeType } from "../src/tui/index.js" import { renderWizard } from "../src/tui/index.js" @@ -99,7 +97,6 @@ interface HeadlessParams { async function runHeadless(params: HeadlessParams): Promise { const { cwd, isImprovement, expertName, improvements, options } = params - // Validate provider const providerInput = options.provider || "anthropic" if (!isValidProvider(providerInput)) { console.error( @@ -109,7 +106,6 @@ async function runHeadless(params: HeadlessParams): Promise { } const provider: LLMProvider = providerInput - // Validate runtime const runtimeInput = options.runtime || "default" const isDefaultRuntime = runtimeInput === "default" if (!isDefaultRuntime && !isValidRuntime(runtimeInput)) { @@ -122,7 +118,6 @@ async function runHeadless(params: HeadlessParams): Promise { ? "default" : (runtimeInput as RuntimeType) - // Validate description for new projects const description = isImprovement ? improvements || "" : options.description if (!description) { if (isImprovement) { @@ -135,7 +130,6 @@ async function runHeadless(params: HeadlessParams): Promise { process.exit(1) } - // Validate API key const envVarName = getEnvVarName(provider) if (isDefaultRuntime && !process.env[envVarName]) { console.error( @@ -151,157 +145,20 @@ async function runHeadless(params: HeadlessParams): Promise { generateProjectFiles({ cwd, provider, model, runtime }) } - // Build query const query = isImprovement ? `Improve the Expert "${expertName}": ${description}` : `Create a new Expert based on these requirements: ${description}` - // Build args for perstack run (headless) - const runtimeArg = isDefaultRuntime ? [] : ["--runtime", runtime] - const args = ["perstack", "run", "create-expert", query, "--workspace", cwd, ...runtimeArg] - - const useJson = options.json === true - - if (useJson) { - // JSON mode: pass through all output - console.log(`\nšŸš€ Running: npx ${args.join(" ")}\n`) - const proc = spawn("npx", args, { - cwd, - env: process.env, - stdio: "inherit", - }) - proc.on("exit", (code) => { - process.exit(code || 0) - }) - } else { - // Human-readable mode: parse JSON and show internal state - console.log(`\nšŸš€ Creating Expert...\n`) - const proc = spawn("npx", args, { - cwd, - env: process.env, - stdio: ["inherit", "pipe", "pipe"], // Also pipe stderr for formatted output - }) - - let buffer = "" - let stderrBuffer = "" - let finalResult: string | null = null - let hasError = false // Track if any error occurred during execution - let lastErrorMessage: string | null = null // Store last error message for summary - - let stepCounter = 0 - let rootExpert: string | null = null - - const processLine = (line: string): void => { - const trimmed = line.trim() - if (!trimmed) return - try { - const event = JSON.parse(trimmed) as PerstackEvent - if (event.type === "startGeneration" && event.stepNumber) { - stepCounter = event.stepNumber - } - const formatted = formatPerstackEvent(event, stepCounter) - if (formatted.isError) { - hasError = true - if (event.error) { - lastErrorMessage = event.error - } - } - for (const l of formatted.lines) { - console.log(l) - } - if (event.type === "startRun" && rootExpert === null) { - rootExpert = event.expertKey ?? null - } - if ( - event.type === "completeRun" && - event.text && - (event.expertKey ?? null) === rootExpert - ) { - finalResult = event.text - } - } catch { - // Ignore non-JSON lines - } - } - - // Process stderr for error messages (formatted output instead of raw dump) - const processStderr = (line: string): void => { - const trimmed = line.trim() - if (!trimmed) return - - // Check for critical error patterns - if (trimmed.includes("APICallError") || trimmed.includes("Error:")) { - hasError = true - // Extract first line of error message - const firstLine = trimmed.split("\n")[0] || trimmed - const truncated = firstLine.length > 100 ? `${firstLine.slice(0, 100)}...` : firstLine - console.log(`[stderr] āŒ ${truncated}`) - lastErrorMessage = truncated - } - // Skip stack traces and other verbose output - } - - proc.stdout?.on("data", (data: Buffer) => { - buffer += data.toString() - const lines = buffer.split("\n") - buffer = lines.pop() ?? "" - for (const line of lines) { - processLine(line) - } - }) - - proc.stderr?.on("data", (data: Buffer) => { - stderrBuffer += data.toString() - const lines = stderrBuffer.split("\n") - stderrBuffer = lines.pop() ?? "" - for (const line of lines) { - processStderr(line) - } - }) - - // Handle child process close: process remaining buffer and display results - // Using 'close' instead of 'exit' ensures stdio streams are fully drained - proc.on("close", (code) => { - if (buffer) { - processLine(buffer) - buffer = "" - } - if (stderrBuffer) { - processStderr(stderrBuffer) - stderrBuffer = "" - } - - console.log() // Newline after output - console.log("─".repeat(60)) - - // code is null when process is killed by signal, treat as failure - const exitCode = code ?? 1 - - // Determine final status: check both exit code and error flag - const failed = exitCode !== 0 || hasError - - if (failed) { - console.log("āŒ FAILED") - if (lastErrorMessage) { - console.log(` Last error: ${lastErrorMessage.slice(0, 120)}`) - } - if (exitCode !== 0) { - console.log(` Exit code: ${exitCode}`) - } - } else if (finalResult) { - console.log("āœ… COMPLETED") - console.log("─".repeat(60)) - console.log("RESULT:") - console.log(finalResult) - } else { - console.log("āœ… COMPLETED (no output)") - } - console.log("─".repeat(60)) + const result = await runHeadlessExecution({ + cwd, + provider, + model, + runtime, + query, + jsonOutput: options.json === true, + }) - // Exit with error code if we detected errors, even if process exited 0 - process.exit(failed ? 1 : 0) - }) // End of close handler - } + process.exit(result.success ? 0 : 1) } interface InteractiveParams { diff --git a/apps/create-expert/src/index.ts b/apps/create-expert/src/index.ts index 9a34a8d5..9264360b 100644 --- a/apps/create-expert/src/index.ts +++ b/apps/create-expert/src/index.ts @@ -21,6 +21,8 @@ export { getExpertName, shortenCallId, } from "./lib/event-formatter.js" +export type { HeadlessRunnerOptions, HeadlessRunnerResult } from "./lib/headless-runner.js" +export { runHeadlessExecution } from "./lib/headless-runner.js" export type { ProjectGenerationOptions, ProjectGenerationResult } from "./lib/project-generator.js" export { generateProjectFiles } from "./lib/project-generator.js" export type { LLMInfo, LLMProvider, RuntimeInfo, RuntimeType, WizardResult } from "./tui/index.js" diff --git a/apps/create-expert/src/lib/headless-runner.ts b/apps/create-expert/src/lib/headless-runner.ts new file mode 100644 index 00000000..6718101a --- /dev/null +++ b/apps/create-expert/src/lib/headless-runner.ts @@ -0,0 +1,166 @@ +import { spawn } from "node:child_process" + +import type { LLMProvider, RuntimeType } from "../tui/index.js" +import type { PerstackEvent } from "./event-formatter.js" +import { formatPerstackEvent } from "./event-formatter.js" + +export interface HeadlessRunnerOptions { + cwd: string + provider: LLMProvider + model: string + runtime: RuntimeType | "default" + query: string + jsonOutput?: boolean +} + +export interface HeadlessRunnerResult { + success: boolean + exitCode: number + result?: string + error?: string +} + +export function runHeadlessExecution( + options: HeadlessRunnerOptions, +): Promise { + const { cwd, runtime, query, jsonOutput } = options + const isDefaultRuntime = runtime === "default" + const runtimeArg = isDefaultRuntime ? [] : ["--runtime", runtime] + const args = ["perstack", "run", "create-expert", query, "--workspace", cwd, ...runtimeArg] + + return new Promise((resolve) => { + if (jsonOutput) { + console.log(`\nšŸš€ Running: npx ${args.join(" ")}\n`) + const proc = spawn("npx", args, { + cwd, + env: process.env, + stdio: "inherit", + }) + proc.on("exit", (code) => { + const exitCode = code ?? 1 + resolve({ + success: exitCode === 0, + exitCode, + }) + }) + } else { + console.log(`\nšŸš€ Creating Expert...\n`) + const proc = spawn("npx", args, { + cwd, + env: process.env, + stdio: ["inherit", "pipe", "pipe"], + }) + + let buffer = "" + let stderrBuffer = "" + let finalResult: string | null = null + let hasError = false + let lastErrorMessage: string | null = null + let stepCounter = 0 + let rootExpert: string | null = null + + const processLine = (line: string): void => { + const trimmed = line.trim() + if (!trimmed) return + try { + const event = JSON.parse(trimmed) as PerstackEvent + if (event.type === "startGeneration" && event.stepNumber) { + stepCounter = event.stepNumber + } + const formatted = formatPerstackEvent(event, stepCounter) + if (formatted.isError) { + hasError = true + if (event.error) { + lastErrorMessage = event.error + } + } + for (const l of formatted.lines) { + console.log(l) + } + if (event.type === "startRun" && rootExpert === null) { + rootExpert = event.expertKey ?? null + } + if ( + event.type === "completeRun" && + event.text && + (event.expertKey ?? null) === rootExpert + ) { + finalResult = event.text + } + } catch { + // Ignore non-JSON lines + } + } + + const processStderr = (line: string): void => { + const trimmed = line.trim() + if (!trimmed) return + if (trimmed.includes("APICallError") || trimmed.includes("Error:")) { + hasError = true + const firstLine = trimmed.split("\n")[0] || trimmed + const truncated = firstLine.length > 100 ? `${firstLine.slice(0, 100)}...` : firstLine + console.log(`[stderr] āŒ ${truncated}`) + lastErrorMessage = truncated + } + } + + proc.stdout?.on("data", (data: Buffer) => { + buffer += data.toString() + const lines = buffer.split("\n") + buffer = lines.pop() ?? "" + for (const line of lines) { + processLine(line) + } + }) + + proc.stderr?.on("data", (data: Buffer) => { + stderrBuffer += data.toString() + const lines = stderrBuffer.split("\n") + stderrBuffer = lines.pop() ?? "" + for (const line of lines) { + processStderr(line) + } + }) + + proc.on("close", (code) => { + if (buffer) { + processLine(buffer) + } + if (stderrBuffer) { + processStderr(stderrBuffer) + } + + console.log() + console.log("─".repeat(60)) + + const exitCode = code ?? 1 + const failed = exitCode !== 0 || hasError + + if (failed) { + console.log("āŒ FAILED") + if (lastErrorMessage) { + console.log(` Last error: ${lastErrorMessage.slice(0, 120)}`) + } + if (exitCode !== 0) { + console.log(` Exit code: ${exitCode}`) + } + } else if (finalResult) { + console.log("āœ… COMPLETED") + console.log("─".repeat(60)) + console.log("RESULT:") + console.log(finalResult) + } else { + console.log("āœ… COMPLETED (no output)") + } + console.log("─".repeat(60)) + + resolve({ + success: !failed, + exitCode: failed ? 1 : 0, + result: finalResult ?? undefined, + error: lastErrorMessage ?? undefined, + }) + }) + } + }) +}