import { spawn } from 'node:child_process'; import { resolveGeminiCli } from './resolve-gemini-cli'; type ThinkingMode = 'adaptive' | 'disabled' | 'enabled'; type ThinkingEffort = 'low' | 'medium' | 'high' | 'max'; export interface GeminiExecOptions { model?: string; systemPrompt?: string; thinkingMode?: ThinkingMode; thinkingBudgetTokens?: number; effort?: ThinkingEffort; timeoutMs?: number; } interface GeminiCliResult { text?: string; error?: string; } const DEFAULT_GEMINI_TIMEOUT_MS = 15 * 60 * 1000; /** * Allowlist-based env filter for Gemini CLI subprocess. * Passes through safe system vars and Google/Gemini-specific prefixes. */ const GEMINI_ENV_ALLOWLIST = new Set([ 'PATH', 'HOME', 'TERM', 'LANG', 'SHELL', 'TMPDIR', // Windows-essential 'SYSTEMROOT', 'COMSPEC', 'USERPROFILE', 'APPDATA', 'LOCALAPPDATA', 'PATHEXT', 'SYSTEMDRIVE', 'TEMP', 'TMP', 'HOMEDRIVE', 'HOMEPATH', ]); function filterGeminiEnv( env: Record, ): Record { const result: Record = {}; for (const [k, v] of Object.entries(env)) { if ( GEMINI_ENV_ALLOWLIST.has(k) || k.startsWith('GOOGLE_') || k.startsWith('GEMINI_') || k.startsWith('GCLOUD_') ) { result[k] = v; } } return result; } /** * Run Gemini CLI in non-interactive mode with JSON output. * Passes prompt via stdin to avoid command-line length limits. * The CLI handles its own authentication (OAuth or API key). */ export async function runGeminiExec( userPrompt: string, options: GeminiExecOptions = {}, ): Promise { const binPath = resolveGeminiCli(); if (!binPath) { return { error: 'Gemini CLI not found. Install it first.' }; } const prompt = buildPrompt(options.systemPrompt, userPrompt); const args = ['-o', 'json', '--approval-mode', 'plan']; if (options.model) { args.push('-m', options.model); } // Use -p with a minimal marker; full prompt piped via stdin. // Gemini CLI appends -p value after stdin content. args.push('-p', ' '); try { const result = await executeGeminiCommand( binPath, args, options.timeoutMs ?? DEFAULT_GEMINI_TIMEOUT_MS, prompt, ); return result; } catch (error) { return { error: error instanceof Error ? error.message : 'Gemini execution failed' }; } } /** * Stream Gemini CLI output in real-time using `stream-json` format. * Passes prompt via stdin. Yields text deltas as they arrive. */ export function streamGeminiExec( userPrompt: string, options: GeminiExecOptions = {}, ): { stream: AsyncGenerator<{ type: 'text' | 'error' | 'done'; content: string }>; kill: () => void; } { const binPath = resolveGeminiCli(); if (!binPath) { return { stream: (async function* () { yield { type: 'error' as const, content: 'Gemini CLI not found.' }; })(), kill: () => {}, }; } const prompt = buildPrompt(options.systemPrompt, userPrompt); const args = ['-o', 'stream-json', '--approval-mode', 'plan']; if (options.model) { args.push('-m', options.model); } // Use -p with minimal marker; full prompt piped via stdin. args.push('-p', ' '); const child = spawn(binPath, args, { env: filterGeminiEnv(process.env as Record), stdio: ['pipe', 'pipe', 'pipe'], ...(process.platform === 'win32' && { shell: true }), }); // Pipe prompt via stdin if (child.stdin) { child.stdin.write(prompt); child.stdin.end(); } const timeoutMs = options.timeoutMs ?? DEFAULT_GEMINI_TIMEOUT_MS; const timer = setTimeout(() => { child.kill('SIGTERM'); }, timeoutMs); async function* generateStream(): AsyncGenerator<{ type: 'text' | 'error' | 'done'; content: string; }> { let buffer = ''; child.stderr?.on('data', () => { /* discard stderr */ }); try { for await (const chunk of child.stdout!) { buffer += chunk.toString('utf-8'); let idx = buffer.indexOf('\n'); while (idx >= 0) { const line = buffer.slice(0, idx).trim(); buffer = buffer.slice(idx + 1); if (line) { const event = parseStreamJsonLine(line); if (event) yield event; } idx = buffer.indexOf('\n'); } } // Flush remaining buffer const tail = buffer.trim(); if (tail) { const event = parseStreamJsonLine(tail); if (event) yield event; } yield { type: 'done', content: '' }; } catch (error) { const msg = error instanceof Error ? error.message : 'Stream error'; yield { type: 'error', content: msg }; } finally { clearTimeout(timer); } } return { stream: generateStream(), kill: () => { clearTimeout(timer); child.kill('SIGTERM'); }, }; } function buildPrompt(systemPrompt: string | undefined, userPrompt: string): string { const userText = userPrompt.trim(); if (!systemPrompt?.trim()) return userText; return [ 'You are a design generation assistant. Follow the guidelines below to produce the requested output.', '', '--- GUIDELINES ---', systemPrompt.trim(), '', '--- TASK ---', userText, ].join('\n'); } async function executeGeminiCommand( binPath: string, args: string[], timeoutMs: number, stdinText?: string, ): Promise { return await new Promise((resolve, reject) => { const child = spawn(binPath, args, { env: filterGeminiEnv(process.env as Record), stdio: [stdinText ? 'pipe' : 'ignore', 'pipe', 'pipe'], ...(process.platform === 'win32' && { shell: true }), }); // Pipe prompt via stdin if (stdinText && child.stdin) { child.stdin.write(stdinText); child.stdin.end(); } let stdoutBuffer = ''; let stderrBuffer = ''; const timer = setTimeout(() => { child.kill('SIGTERM'); reject(new Error(`Gemini request timed out after ${Math.round(timeoutMs / 1000)}s.`)); }, timeoutMs); child.stdout!.on('data', (chunk: Buffer) => { stdoutBuffer += chunk.toString('utf-8'); }); child.stderr!.on('data', (chunk: Buffer) => { stderrBuffer += chunk.toString('utf-8'); }); child.on('error', (err) => { clearTimeout(timer); reject(err); }); child.on('close', (code) => { clearTimeout(timer); // Parse JSON output — Gemini CLI always outputs a JSON object at the end of stdout. // Error text / stack traces may appear before it. const parsed = parseGeminiJsonOutput(stdoutBuffer); if (parsed) { if (parsed.response) { resolve({ text: parsed.response }); return; } if (parsed.errorMessage) { resolve({ error: friendlyGeminiApiError(parsed.errorMessage) }); return; } } if (code !== 0) { // Extract meaningful error from stderr or stdout const errorMsg = extractGeminiError(stdoutBuffer, stderrBuffer); resolve({ error: errorMsg || `Gemini exited with code ${code ?? 'unknown'}.` }); return; } const raw = stdoutBuffer.trim(); resolve(raw ? { text: raw } : { error: 'Gemini returned no output.' }); }); }); } /** * Parse Gemini CLI JSON output. * The CLI may print error text before the final JSON object. * We search from the END of stdout for the last valid JSON block. */ function parseGeminiJsonOutput(raw: string): { response?: string; errorMessage?: string } | null { const trimmed = raw.trim(); if (!trimmed) return null; // Search backwards for the last top-level JSON object (starts with `{` at line beginning) const lines = trimmed.split('\n'); for (let i = lines.length - 1; i >= 0; i--) { const line = lines[i].trim(); if (!line.startsWith('{')) continue; // Try to parse from this line to the end const candidate = lines.slice(i).join('\n').trim(); try { const data = JSON.parse(candidate) as Record; // Must have session_id to be a valid Gemini CLI response if (!data.session_id && !data.response && !data.error) continue; const response = typeof data.response === 'string' ? data.response : undefined; // error can be a string or an object { type, message, code } let errorMessage: string | undefined; if (data.error) { if (typeof data.error === 'string') { errorMessage = data.error; } else if (typeof data.error === 'object' && data.error !== null) { const errObj = data.error as Record; errorMessage = typeof errObj.message === 'string' ? errObj.message : JSON.stringify(data.error); } } return { response, errorMessage }; } catch { /* not valid JSON from this point */ } } return null; } /** Extract a human-readable error from Gemini CLI stdout/stderr */ function extractGeminiError(stdout: string, stderr: string): string | null { // Look for quota errors const quotaMatch = stdout.match(/quota will reset after (\S+)/i) || stderr.match(/quota will reset after (\S+)/i); if (quotaMatch) { return `Gemini quota exhausted. Resets after ${quotaMatch[1]}.`; } // Look for TerminalQuotaError or other named errors const namedError = stdout.match(/(Terminal\w+Error|ApiError|AuthError):\s*(.+)/m); if (namedError) { return namedError[2].trim(); } // Stderr fallback const stderrTrimmed = stderr.trim(); if (stderrTrimmed) return stderrTrimmed; return null; } /** Map raw Gemini API errors to user-friendly messages */ function friendlyGeminiApiError(raw: string): string { if (/quota|exhausted|429|capacity/i.test(raw)) { const resetMatch = raw.match(/reset after (\S+)/i); return resetMatch ? `Gemini quota exhausted. Resets after ${resetMatch[1]}.` : 'Gemini quota exhausted. Please wait and try again.'; } if (/401|unauthenticated|auth/i.test(raw)) { return 'Gemini auth expired. Run "gemini" in your terminal to re-authenticate.'; } if (/\[object Object\]/.test(raw)) { return 'Gemini API error. Check your quota or try a different model.'; } return raw; } function parseStreamJsonLine( line: string, ): { type: 'text' | 'error' | 'done'; content: string } | null { // Skip non-JSON lines (e.g. "Loaded cached credentials.") if (!line.startsWith('{')) return null; let parsed: Record; try { parsed = JSON.parse(line) as Record; } catch { return null; } const type = typeof parsed.type === 'string' ? parsed.type : ''; if (type === 'message' && parsed.role === 'assistant') { const content = typeof parsed.content === 'string' ? parsed.content : ''; if (content) return { type: 'text', content }; } if (type === 'result') { // Check for error in result event if (parsed.status === 'error' && parsed.error) { const errObj = parsed.error as Record; const msg = typeof errObj.message === 'string' ? errObj.message : 'Unknown error'; return { type: 'error', content: friendlyGeminiApiError(msg) }; } return null; } if (type === 'error') { const content = typeof parsed.message === 'string' ? parsed.message : 'Unknown error'; return { type: 'error', content: friendlyGeminiApiError(content) }; } return null; }