From 6f997083efd02c8366131334cde952d5e8690aa3 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Sat, 27 Jun 2026 13:47:44 +0000 Subject: [PATCH] =?UTF-8?q?feat(ai-code-review):=20=E6=94=AF=E6=8F=B4=20An?= =?UTF-8?q?tigravity=20CLI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/ci.yaml | 5 +++-- app/config.js | 18 ++++++++++++++++-- app/llm.js | 11 +++++++---- app/preflight.js | 2 +- app/test/config.test.js | 13 ++++++++++++- app/test/llm.test.js | 21 +++++++++++++++++++-- app/test/usage.test.js | 1 + app/usage.js | 1 + 8 files changed, 60 insertions(+), 12 deletions(-) diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 8f63b1f..c5155fc 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -52,15 +52,16 @@ jobs: shell: bash run: | set -e + tools="$(node --input-type=module -e "import { getLLMCLICommands } from './app/config.js'; console.log(getLLMCLICommands().join(' '));")" found="" - for tool in codex claude opencode; do + for tool in $tools; do if command -v "$tool" >/dev/null 2>&1; then found="${found} ${tool}" "$tool" --version || true fi done if [ -z "$found" ]; then - echo "找不到可用的 AI 助理 CLI(需要 codex、claude 或 opencode 其中之一)" >&2 + echo "找不到可用的 AI 助理 CLI(需要 ${tools} 其中之一)" >&2 exit 1 fi echo "可用 AI 助理 CLI:${found}" diff --git a/app/config.js b/app/config.js index e76389c..5efc5b4 100644 --- a/app/config.js +++ b/app/config.js @@ -42,6 +42,16 @@ const CLI_CANDIDATES = [ command: 'claude', defaultModel: 'sonnet', }, + { + provider: 'antigravity', + command: 'agy', + defaultModel: 'gemini-2.5-flash', + }, + { + provider: 'antigravity', + command: 'antigravity', + defaultModel: 'gemini-2.5-flash', + }, { provider: 'opencode', command: 'opencode', @@ -49,6 +59,10 @@ const CLI_CANDIDATES = [ }, ]; +export function getLLMCLICommands() { + return CLI_CANDIDATES.map(c => c.command); +} + function commandExists(command) { try { execFileSync('/bin/sh', ['-lc', `command -v ${command}`], { stdio: 'ignore' }); @@ -61,11 +75,11 @@ function commandExists(command) { /** * 依環境變數解析並回傳 LLM 提供者設定。 * - * 優先使用 `AI_ASSISTANT_CLI` 指定的 CLI;未指定時依序偵測 codex、claude、opencode。 + * 優先使用 `AI_ASSISTANT_CLI` 指定的 CLI;未指定時依序偵測 codex、claude、antigravity、opencode。 * model 優先取 `MODEL`,再相容舊的 `OPENCODE_MODEL`,最後使用各 CLI 預設值。 * * @param {{ commandExistsFn?: (command: string) => boolean }} [deps] - 可注入的 CLI 偵測函式,供測試使用。 - * @returns {{ provider: ('codex'|'claude'|'opencode'|null), apiKeys: string[], baseURL: null, model: (string|null), command: (string|null) }} + * @returns {{ provider: ('codex'|'claude'|'antigravity'|'opencode'|null), apiKeys: string[], baseURL: null, model: (string|null), command: (string|null) }} * LLM 設定物件;`provider` 為 `null` 表示沒有可用的提供者。 */ export function getLLMConfig({ commandExistsFn = commandExists } = {}) { diff --git a/app/llm.js b/app/llm.js index 7a27866..470439f 100644 --- a/app/llm.js +++ b/app/llm.js @@ -23,13 +23,16 @@ function buildPrompt(systemPrompt, userContent) { ].join('\n'); } -function cliArgs(provider, model, promptFile = null) { +function cliArgs(provider, model, promptFile = null, prompt = null) { if (provider === 'codex') { return ['exec', '--model', model, '--sandbox', 'read-only', '--ask-for-approval', 'never', '--skip-git-repo-check', '-']; } if (provider === 'claude') { return ['--print', '--model', model, '--permission-mode', 'dontAsk', '--no-session-persistence']; } + if (provider === 'antigravity') { + return ['-p', prompt, '--model', model]; + } if (provider === 'opencode') { return ['run', '--model', model, '--format', 'default', '--file', promptFile, '請依附件 prompt.md 的完整內容執行,並只輸出要求的最終結果。']; } @@ -50,7 +53,7 @@ async function runAssistantCLI({ provider, command, model }, prompt) { promptFile = join(tempDir, 'prompt.md'); await writeFile(promptFile, prompt); } - const args = cliArgs(provider, model, promptFile); + const args = cliArgs(provider, model, promptFile, prompt); const maxBuffer = Number(process.env.AI_ASSISTANT_MAX_BUFFER || 20 * 1024 * 1024); const timeout = Number(process.env.AI_ASSISTANT_TIMEOUT_MS || 15 * 60 * 1000); try { @@ -86,7 +89,7 @@ async function runAssistantCLI({ provider, command, model }, prompt) { if (code === 0) resolve(stdout.trim()); else reject(Object.assign(new Error(`${provider} CLI exited with ${code ?? signal}`), { stdout, stderr })); }); - child.stdin.end(provider === 'opencode' ? '' : prompt); + child.stdin.end(provider === 'opencode' || provider === 'antigravity' ? '' : prompt); }); } finally { if (tempDir) await rm(tempDir, { recursive: true, force: true }); @@ -107,7 +110,7 @@ async function runAssistantCLI({ provider, command, model }, prompt) { export async function chat(systemPrompt, userContent) { const cfg = getLLMConfig(); const { provider, command, model } = cfg; - if (!provider || !command) throw new Error('未偵測到可用 AI 助理 CLI,請安裝 codex、claude 或 opencode'); + if (!provider || !command) throw new Error('未偵測到可用 AI 助理 CLI,請安裝 codex、claude、antigravity 或 opencode'); line(`[LLM] provider=${provider} command=${command} model=${model}`); diff --git a/app/preflight.js b/app/preflight.js index ca7586d..e707238 100644 --- a/app/preflight.js +++ b/app/preflight.js @@ -106,7 +106,7 @@ export async function verifyCommentToken(token = GITEA_COMMENT_TOKEN) { */ export async function verifyLLM() { const { provider, command, model } = getLLMConfig(); - if (!provider || !command) return { ok: false, error: '未偵測到可用 AI 助理 CLI,請安裝 codex、claude 或 opencode' }; + if (!provider || !command) return { ok: false, error: '未偵測到可用 AI 助理 CLI,請安裝 codex、claude、antigravity 或 opencode' }; if (!model) return { ok: false, provider, error: '未設定 MODEL' }; return { ok: true, provider, command, model }; } diff --git a/app/test/config.test.js b/app/test/config.test.js index 942acc3..986b349 100644 --- a/app/test/config.test.js +++ b/app/test/config.test.js @@ -1,6 +1,6 @@ import { describe, it, beforeEach, afterEach } from 'node:test'; import assert from 'node:assert/strict'; -import { getLLMConfig, getOpenCodeHttpsAgent } from '../config.js'; +import { getLLMCLICommands, getLLMConfig, getOpenCodeHttpsAgent } from '../config.js'; const ENV_KEYS = [ 'AI_ASSISTANT_CLI', 'MODEL', 'OPENCODE_MODEL', @@ -19,6 +19,10 @@ afterEach(() => { }); describe('getLLMConfig', () => { + it('exports the supported assistant CLI commands', () => { + assert.deepEqual(getLLMCLICommands(), ['codex', 'claude', 'agy', 'antigravity', 'opencode']); + }); + it('returns null provider when no env vars set', () => { const cfg = getLLMConfig({ commandExistsFn: () => false }); assert.equal(cfg.provider, null); @@ -43,6 +47,13 @@ describe('getLLMConfig', () => { assert.equal(cfg.model, 'gpt-5-mini'); }); + it('detects Antigravity through the agy command', () => { + const cfg = getLLMConfig({ commandExistsFn: command => command === 'agy' }); + assert.equal(cfg.provider, 'antigravity'); + assert.equal(cfg.command, 'agy'); + assert.equal(cfg.model, 'gemini-2.5-flash'); + }); + it('can force a CLI with AI_ASSISTANT_CLI', () => { process.env.AI_ASSISTANT_CLI = 'opencode'; process.env.OPENCODE_MODEL = 'google/gemini-2.5-pro'; diff --git a/app/test/llm.test.js b/app/test/llm.test.js index 5d7bd5c..0864089 100644 --- a/app/test/llm.test.js +++ b/app/test/llm.test.js @@ -31,13 +31,13 @@ async function installFakeCLI(command = 'codex') { const script = join(tempDir, command); await writeFile(script, `#!/bin/sh if [ -n "$FAKE_AI_ARGS_PATH" ]; then printf '%s\\n' "$*" > "$FAKE_AI_ARGS_PATH"; fi -if [ -n "$FAKE_AI_STDIN_PATH" ]; then cat > "$FAKE_AI_STDIN_PATH"; else cat >/dev/null; fi +if [ -n "$FAKE_AI_STDIN_PATH" ]; then /bin/cat > "$FAKE_AI_STDIN_PATH"; else /bin/cat >/dev/null; fi if [ -n "$FAKE_AI_STDERR" ]; then printf '%s' "$FAKE_AI_STDERR" >&2; fi if [ -n "$FAKE_AI_STDOUT" ]; then printf '%s' "$FAKE_AI_STDOUT"; fi exit "\${FAKE_AI_EXIT:-0}" `); await chmod(script, 0o755); - process.env.PATH = `${tempDir}:${saved.PATH || ''}`; + process.env.PATH = tempDir; return { stdinPath: join(tempDir, 'stdin.txt'), argsPath: join(tempDir, 'args.txt') }; } @@ -73,6 +73,23 @@ describe('chat - assistant CLI', async () => { assert.match(await readFile(argsPath, 'utf8'), /run --model google\/gemini-2.5-pro/); }); + it('runs Antigravity through agy with MODEL and prompt argument', async () => { + const { stdinPath, argsPath } = await installFakeCLI('agy'); + process.env.AI_ASSISTANT_CLI = 'agy'; + process.env.MODEL = 'gemini-2.5-pro'; + process.env.FAKE_AI_STDOUT = 'antigravity response'; + process.env.FAKE_AI_STDIN_PATH = stdinPath; + process.env.FAKE_AI_ARGS_PATH = argsPath; + + const result = await chat('sys', 'user'); + + assert.equal(result, 'antigravity response'); + const args = await readFile(argsPath, 'utf8'); + assert.match(args, /-p .*--model gemini-2.5-pro/s); + assert.match(args, /\nsys\n<\/system>/); + assert.equal(await readFile(stdinPath, 'utf8'), ''); + }); + it('throws an error when the CLI fails instead of exiting the process', async () => { await installFakeCLI('codex'); process.env.FAKE_AI_EXIT = '2'; diff --git a/app/test/usage.test.js b/app/test/usage.test.js index d6b5f4e..6ce0c95 100644 --- a/app/test/usage.test.js +++ b/app/test/usage.test.js @@ -120,6 +120,7 @@ describe('fetchAccountQuota', () => { it('reports 不適用 for local platforms', async () => { assert.equal((await fetchAccountQuota('ollama', {})).available, false); assert.equal((await fetchAccountQuota('opencode', {})).available, false); + assert.equal((await fetchAccountQuota('antigravity', {})).available, false); }); it('degrades gracefully when the quota call throws', async () => { diff --git a/app/usage.js b/app/usage.js index 97eb2df..3e37821 100644 --- a/app/usage.js +++ b/app/usage.js @@ -197,6 +197,7 @@ const QUOTA_STRATEGIES = { return { available: false, reason: 'OpenAI 帳號額度需 dashboard session 權限,API key 無法取得' }; }, claude: async () => ({ available: false, reason: 'Anthropic 額度需 Admin API 權限,一般 API key 無法取得' }), + antigravity: async () => ({ available: false, reason: 'Antigravity 額度由 Google 帳務/方案管理,CLI 無法直接查詢' }), gemini: async () => ({ available: false, reason: 'Gemini 額度由 Google Cloud quota 管理,API key 無法直接查詢' }), amazonq: async () => ({ available: false, reason: 'Amazon Q 額度由 AWS 帳務管理,需 AWS 憑證查詢' }), ollama: async () => ({ available: false, reason: '本地服務,無帳號額度概念' }),