/** * Resolve and invoke the Homer CLI without reimplementing control-plane semantics. */ import { spawn } from "node:child_process"; import { accessSync, constants } from "node:fs"; import { delimiter, isAbsolute } from "node:path"; export interface RunHomerResult { exitCode: number; stdout: string; stderr: string; argv: string[]; } export interface RunHomerOpts { cwd?: string; env?: NodeJS.ProcessEnv; /** Default 120_000 */ timeoutMs?: number; } export type HomerInvocation = | { kind: "bin"; command: string; prefixArgs: string[] } | { kind: "npx"; command: string; prefixArgs: string[] }; /** * Resolution order: * 1. HOMER_BIN env (file path or command name) * 2. `homer` on PATH * 3. `npx --yes --package=@pelec/homer homer` */ export function resolveHomerInvocation( env: NodeJS.ProcessEnv = process.env, ): HomerInvocation { const homerBin = env.HOMER_BIN?.trim(); if (homerBin) { // .js/.mjs entrypoints need node (esp. Windows). if (/\.(c|m)?js$/i.test(homerBin)) { return { kind: "bin", command: process.execPath, prefixArgs: [homerBin], }; } return { kind: "bin", command: homerBin, prefixArgs: [] }; } if (isOnPath("homer", env)) { return { kind: "bin", command: "homer", prefixArgs: [] }; } return { kind: "npx", command: process.platform === "win32" ? "npx.cmd" : "npx", prefixArgs: ["--yes", "--package=@pelec/homer", "homer"], }; } export function buildHomerArgv( invocation: HomerInvocation, args: string[], ): { command: string; argv: string[] } { return { command: invocation.command, argv: [...invocation.prefixArgs, ...args], }; } function isOnPath(cmd: string, env: NodeJS.ProcessEnv): boolean { const pathEnv = env.PATH ?? env.Path ?? ""; const exts = process.platform === "win32" ? (env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM").split(";").filter(Boolean) : [""]; for (const dir of pathEnv.split(delimiter)) { if (!dir) continue; for (const ext of exts) { const candidate = process.platform === "win32" && !cmd.includes(".") ? `${dir}\\${cmd}${ext}` : `${dir}/${cmd}${ext}`; try { accessSync(candidate, constants.X_OK); return true; } catch { try { accessSync(candidate, constants.F_OK); return true; } catch { /* continue */ } } } } // Absolute HOMER_BIN already handled; bare command may still be spawnable via shell if (isAbsolute(cmd)) { try { accessSync(cmd, constants.F_OK); return true; } catch { return false; } } return false; } export function runHomerCli( args: string[], opts: RunHomerOpts = {}, ): Promise { const env = { ...process.env, ...opts.env }; const invocation = resolveHomerInvocation(env); const { command, argv } = buildHomerArgv(invocation, args); const timeoutMs = opts.timeoutMs ?? 120_000; return new Promise((resolve, reject) => { // Never shell:true — Windows breaks on spaces in process.execPath (e.g. Program Files). const child = spawn(command, argv, { cwd: opts.cwd, env, shell: false, windowsHide: true, }); let stdout = ""; let stderr = ""; let settled = false; const timer = setTimeout(() => { if (settled) return; settled = true; child.kill("SIGTERM"); reject(new Error(`homer timed out after ${timeoutMs}ms: ${command} ${argv.join(" ")}`)); }, timeoutMs); child.stdout?.on("data", (chunk: Buffer | string) => { stdout += String(chunk); }); child.stderr?.on("data", (chunk: Buffer | string) => { stderr += String(chunk); }); child.on("error", (err) => { if (settled) return; settled = true; clearTimeout(timer); reject(err); }); child.on("close", (code) => { if (settled) return; settled = true; clearTimeout(timer); resolve({ exitCode: code ?? 1, stdout, stderr, argv: [command, ...argv], }); }); }); } /** Format tool result text for the model. */ export function formatCliResult(result: RunHomerResult): string { const parts = [ `exit_code: ${result.exitCode}`, `argv: ${result.argv.join(" ")}`, ]; if (result.stdout.trim()) parts.push(`--- stdout ---\n${result.stdout.trimEnd()}`); if (result.stderr.trim()) parts.push(`--- stderr ---\n${result.stderr.trimEnd()}`); return parts.join("\n"); }