/** * Run a `celilo …` subcommand and stream its stdout/stderr through * the supplied callbacks. Used by the remediation modal — when the * user submits, we spawn the same celilo entry point with the edited * args and let the TUI's reducer absorb the output line-by-line. * * The same Bun/Node binary that's running the TUI is reused * (`process.argv[0]` + `process.argv[1]`) so we don't depend on * `celilo` being on PATH or worry about which build is "the" build. */ export interface RunCeliloHandlers { onLine: (stream: 'stdout' | 'stderr', text: string) => void; onExit: (exitCode: number) => void; } /** * Parse a shell-ish command string into argv. Honors `"..."` and * `'...'` quoting and `\` escapes. Not a full shell parser — it * does not expand variables, run subshells, or honor pipes. The * remediation modal pre-fills a single command, and that's what we * support. */ export function parseCommand(input: string): string[] { const args: string[] = []; let current = ''; let inQuote: '"' | "'" | null = null; let escaped = false; for (const c of input) { if (escaped) { current += c; escaped = false; continue; } if (c === '\\') { escaped = true; continue; } if (inQuote === c) { inQuote = null; continue; } if (inQuote === null && (c === '"' || c === "'")) { inQuote = c; continue; } if (inQuote === null && /\s/.test(c)) { if (current) args.push(current); current = ''; continue; } current += c; } if (current) args.push(current); return args; } /** * Strip a leading `celilo` token if the user typed one. The runner * always invokes the celilo entry point itself, so the `celilo` * prefix is implicit. */ function stripCeliloPrefix(args: string[]): string[] { if (args.length > 0 && (args[0] === 'celilo' || args[0].endsWith('/celilo'))) { return args.slice(1); } return args; } /** * Run `celilo ` and stream its output. Returns a function that * can be called to terminate the running process (best-effort kill). */ export function runCelilo(commandString: string, handlers: RunCeliloHandlers): () => void { const parsed = stripCeliloPrefix(parseCommand(commandString)); // Reuse the parent process's invocation. argv[0] is bun/node, // argv[1] is the entry script (or the bin path for compiled use). const exe = process.argv[0]; const entry = process.argv[1]; const proc = Bun.spawn([exe, entry, ...parsed], { stdout: 'pipe', stderr: 'pipe', stdin: 'ignore', env: { ...process.env }, }); void streamLines(proc.stdout, (line) => handlers.onLine('stdout', line)); void streamLines(proc.stderr, (line) => handlers.onLine('stderr', line)); proc.exited.then((code) => { handlers.onExit(typeof code === 'number' ? code : -1); }); return () => { try { proc.kill(); } catch { // Process may already have exited; ignore. } }; } /** * Read a ReadableStream of bytes and call `cb` for each newline- * delimited line. Trailing partial lines are flushed when the * stream closes. */ async function streamLines( stream: ReadableStream | undefined, cb: (line: string) => void, ): Promise { if (!stream) return; const reader = stream.getReader(); const decoder = new TextDecoder(); let buffer = ''; try { while (true) { const { value, done } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split('\n'); buffer = lines.pop() ?? ''; for (const line of lines) cb(line); } buffer += decoder.decode(); if (buffer.length > 0) cb(buffer); } finally { reader.releaseLock(); } }