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
10 changes: 10 additions & 0 deletions .changeset/extract-headless-runner.md
Original file line number Diff line number Diff line change
@@ -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
163 changes: 10 additions & 153 deletions apps/create-expert/bin/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -99,7 +97,6 @@ interface HeadlessParams {
async function runHeadless(params: HeadlessParams): Promise<void> {
const { cwd, isImprovement, expertName, improvements, options } = params

// Validate provider
const providerInput = options.provider || "anthropic"
if (!isValidProvider(providerInput)) {
console.error(
Expand All @@ -109,7 +106,6 @@ async function runHeadless(params: HeadlessParams): Promise<void> {
}
const provider: LLMProvider = providerInput

// Validate runtime
const runtimeInput = options.runtime || "default"
const isDefaultRuntime = runtimeInput === "default"
if (!isDefaultRuntime && !isValidRuntime(runtimeInput)) {
Expand All @@ -122,7 +118,6 @@ async function runHeadless(params: HeadlessParams): Promise<void> {
? "default"
: (runtimeInput as RuntimeType)

// Validate description for new projects
const description = isImprovement ? improvements || "" : options.description
if (!description) {
if (isImprovement) {
Expand All @@ -135,7 +130,6 @@ async function runHeadless(params: HeadlessParams): Promise<void> {
process.exit(1)
}

// Validate API key
const envVarName = getEnvVarName(provider)
if (isDefaultRuntime && !process.env[envVarName]) {
console.error(
Expand All @@ -151,157 +145,20 @@ async function runHeadless(params: HeadlessParams): Promise<void> {
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)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

JSON mode no longer preserves actual process exit codes

The original JSON mode used process.exit(code || 0) which preserved actual non-zero exit codes from the child process (e.g., 137 for OOM kills). After the refactoring, the CLI does process.exit(result.success ? 0 : 1), which normalizes all failures to exit code 1. Users relying on specific exit codes to detect different failure modes (such as signal-killed processes or specific error conditions) will now always receive 1 for any failure.

Fix in Cursor Fix in Web

}

interface InteractiveParams {
Expand Down
2 changes: 2 additions & 0 deletions apps/create-expert/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Loading