'use strict'; const { execFile, execFileSync } = require('child_process'); const fs = require('fs'); const os = require('os'); const path = require('path'); // AI agent 工具介接:依 antigravity → codex → claude 順序偵測可用工具, // 以非互動模式(stdin 餵提示)執行 sub agent 並取回最終回覆。 const TOOLS = [ { name: 'antigravity', // 需人工確認:antigravity 的非互動執行參數尚未驗證(開發機無此工具),此處先比照 codex exec 的形式。 buildArgs: ({ model }) => ['exec', ...(model ? ['-m', model] : []), '-'], resultFrom: 'stdout', }, { name: 'codex', buildArgs: ({ model, lastMessageFile }) => [ 'exec', '--skip-git-repo-check', '--sandbox', 'read-only', '--output-last-message', lastMessageFile, ...(model ? ['-m', model] : []), '-', ], resultFrom: 'lastMessageFile', }, { name: 'claude', buildArgs: ({ model }) => ['-p', '--output-format', 'text', ...(model ? ['--model', model] : [])], resultFrom: 'stdout', }, ]; /** * 依固定優先序(antigravity → codex → claude)偵測本機可用的 AI CLI 工具。 * * 逐一以同步方式執行 ` --version`(逾時 30 秒),第一個成功者即中選, * 並取其 stdout 第一行作為版本字串;偵測失敗(未安裝、不可執行、逾時) * 則靜默換下一個工具。本函式不會拋出例外。 * * 注意:antigravity 的非互動執行參數尚未驗證(需人工確認),本函式僅確認 * `--version` 可執行,不保證後續 runAgent 的參數組合正確。 * * @returns {{ name: string, buildArgs: Function, resultFrom: string, version: string } | null} * 中選工具的描述物件(TOOLS 項目加上 version 欄位);所有工具皆不可用時回傳 null。 * @remarks * 使用情境:action 主流程(步驟 3)啟動審查前呼叫一次,取得工具描述後交給 * runAgent 執行;若回傳 null,主流程會記 ERR 並以失敗收場(無工具即無法審查)。 */ function detectTool() { for (const tool of TOOLS) { try { const version = execFileSync(tool.name, ['--version'], { encoding: 'utf8', timeout: 30_000 }) .trim() .split('\n')[0]; return { ...tool, version }; } catch { // 不可用(未安裝或無法執行)→ 換下一個。 } } return null; } /** * 以非互動模式執行一次 sub agent:把提示從 stdin 餵給偵測到的 AI CLI 工具, * 等子行程結束後回傳最終文字回覆。 * * 依 tool.resultFrom 決定結果來源:stdout(antigravity、claude),或 * codex 專用的 --output-last-message 暫存檔(codex exec 的 stdout 夾雜過程 * 訊息,改讀工具寫出的最終回覆檔,讀取後即刪除)。 * * 本函式永不 reject:任何失敗(非零退出碼、逾時、maxBuffer 超限)都以 * { ok: false, error } resolve,由呼叫端決定降級行為;並對 child.stdin * 掛空 error handler,避免工具提早結束時 EPIPE 造成整個 action 噴例外。 * * 注意:antigravity 的非互動參數尚未驗證(需人工確認),以該工具執行時 * 可能因參數不符而以 ok: false 收場。 * * @param {{ name: string, buildArgs: Function, resultFrom: string }} tool * 工具描述物件(通常來自 detectTool() 的回傳值)。 * @param {Object} options 執行選項(解構參數)。 * @param {string} [options.model] 指定模型名稱;未給時不帶模型參數,使用工具預設模型。 * @param {string} options.prompt 要餵給 agent 的完整提示文字,經 stdin 寫入。 * @param {string} [options.cwd] 子行程工作目錄;影響工具讀取檔案的相對路徑基準。 * @param {number} [options.timeoutMs=600000] 子行程逾時毫秒數(預設 10 分鐘),逾時即終止並回報 ok: false。 * @returns {Promise<{ ok: boolean, output: string, stderr: string, error: Error | null }>} * ok 表示子行程是否成功結束;output 為最終回覆文字(codex 取自 * --output-last-message 檔,其餘取 stdout);stderr 供除錯;error 為失敗原因(成功時為 null)。 * @remarks * 使用情境:審查流程對每個角色組好提示後呼叫本函式, * 例如 `const r = await runAgent(tool, { model, prompt, cwd: workspace });` * 再以 `r.ok ? extractJson(r.output) : null` 取回結構化 findings, * 失敗時記 log 並跳過該角色,不中斷整個 action。 */ function runAgent(tool, { model, prompt, cwd, timeoutMs = 600_000 }) { return new Promise((resolve) => { const lastMessageFile = path.join( os.tmpdir(), `ai-review-${process.pid}-${Math.random().toString(36).slice(2)}.txt`, ); const args = tool.buildArgs({ model, lastMessageFile }); const child = execFile( tool.name, args, { cwd, encoding: 'utf8', timeout: timeoutMs, maxBuffer: 64 * 1024 * 1024 }, (error, stdout, stderr) => { let output = stdout || ''; // codex exec 的 stdout 夾雜過程訊息,改讀 --output-last-message 寫出的最終回覆。 if (tool.resultFrom === 'lastMessageFile' && fs.existsSync(lastMessageFile)) { const last = fs.readFileSync(lastMessageFile, 'utf8').trim(); if (last) output = last; fs.rmSync(lastMessageFile, { force: true }); } resolve({ ok: !error, output, stderr: stderr || '', error }); }, ); child.stdin.on('error', () => { // 工具提早結束時避免 EPIPE 讓整個 action 噴例外。 }); child.stdin.write(prompt); child.stdin.end(); }); } /** * 從 agent 的自由文字回覆中萃取 JSON,容忍 Markdown code fence 與前後雜訊。 * * 處理順序:先取第一個 ``` 或 ```json fence 的內文;再依序以 * 「第一個 [ 到最後一個 ]」、「第一個 { 到最後一個 }」的最大範圍切片 * 嘗試 JSON.parse(陣列優先);最後退而直接 parse 整段文字。 * 本函式不會拋出例外,所有 parse 失敗一律回傳 null。 * * @param {string} text agent 回覆的原始文字;可為空或 null/undefined。 * @returns {any | null} 解析成功的 JSON 值(通常為 findings 陣列或物件);無法解析時為 null。 * @remarks * 使用情境:搭配 runAgent 使用——LLM 即使被要求輸出純 JSON,實務上仍常 * 包在 ```json fence 內或前後夾說明文字,例如 * `const findings = extractJson(result.output) ?? [];` * 可穩定取回 review findings;回傳 null 時呼叫端應視為該次回覆無效並降級處理。 */ function extractJson(text) { if (!text) return null; let t = text.trim(); const fence = /```(?:json)?\s*([\s\S]*?)```/i.exec(t); if (fence) t = fence[1].trim(); for (const [open, close] of [['[', ']'], ['{', '}']]) { const start = t.indexOf(open); const end = t.lastIndexOf(close); if (start !== -1 && end > start) { try { return JSON.parse(t.slice(start, end + 1)); } catch { // 換下一種括號組合再試。 } } } try { return JSON.parse(t); } catch { return null; } } module.exports = { TOOLS, detectTool, runAgent, extractJson };