feat(ai-code-review): 支援 Antigravity CLI
This commit is contained in:
@@ -52,15 +52,16 @@ jobs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
|
tools="$(node --input-type=module -e "import { getLLMCLICommands } from './app/config.js'; console.log(getLLMCLICommands().join(' '));")"
|
||||||
found=""
|
found=""
|
||||||
for tool in codex claude opencode; do
|
for tool in $tools; do
|
||||||
if command -v "$tool" >/dev/null 2>&1; then
|
if command -v "$tool" >/dev/null 2>&1; then
|
||||||
found="${found} ${tool}"
|
found="${found} ${tool}"
|
||||||
"$tool" --version || true
|
"$tool" --version || true
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
if [ -z "$found" ]; then
|
if [ -z "$found" ]; then
|
||||||
echo "找不到可用的 AI 助理 CLI(需要 codex、claude 或 opencode 其中之一)" >&2
|
echo "找不到可用的 AI 助理 CLI(需要 ${tools} 其中之一)" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
echo "可用 AI 助理 CLI:${found}"
|
echo "可用 AI 助理 CLI:${found}"
|
||||||
|
|||||||
+16
-2
@@ -42,6 +42,16 @@ const CLI_CANDIDATES = [
|
|||||||
command: 'claude',
|
command: 'claude',
|
||||||
defaultModel: 'sonnet',
|
defaultModel: 'sonnet',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provider: 'antigravity',
|
||||||
|
command: 'agy',
|
||||||
|
defaultModel: 'gemini-2.5-flash',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provider: 'antigravity',
|
||||||
|
command: 'antigravity',
|
||||||
|
defaultModel: 'gemini-2.5-flash',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
provider: 'opencode',
|
provider: 'opencode',
|
||||||
command: 'opencode',
|
command: 'opencode',
|
||||||
@@ -49,6 +59,10 @@ const CLI_CANDIDATES = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export function getLLMCLICommands() {
|
||||||
|
return CLI_CANDIDATES.map(c => c.command);
|
||||||
|
}
|
||||||
|
|
||||||
function commandExists(command) {
|
function commandExists(command) {
|
||||||
try {
|
try {
|
||||||
execFileSync('/bin/sh', ['-lc', `command -v ${command}`], { stdio: 'ignore' });
|
execFileSync('/bin/sh', ['-lc', `command -v ${command}`], { stdio: 'ignore' });
|
||||||
@@ -61,11 +75,11 @@ function commandExists(command) {
|
|||||||
/**
|
/**
|
||||||
* 依環境變數解析並回傳 LLM 提供者設定。
|
* 依環境變數解析並回傳 LLM 提供者設定。
|
||||||
*
|
*
|
||||||
* 優先使用 `AI_ASSISTANT_CLI` 指定的 CLI;未指定時依序偵測 codex、claude、opencode。
|
* 優先使用 `AI_ASSISTANT_CLI` 指定的 CLI;未指定時依序偵測 codex、claude、antigravity、opencode。
|
||||||
* model 優先取 `MODEL`,再相容舊的 `OPENCODE_MODEL`,最後使用各 CLI 預設值。
|
* model 優先取 `MODEL`,再相容舊的 `OPENCODE_MODEL`,最後使用各 CLI 預設值。
|
||||||
*
|
*
|
||||||
* @param {{ commandExistsFn?: (command: string) => boolean }} [deps] - 可注入的 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` 表示沒有可用的提供者。
|
* LLM 設定物件;`provider` 為 `null` 表示沒有可用的提供者。
|
||||||
*/
|
*/
|
||||||
export function getLLMConfig({ commandExistsFn = commandExists } = {}) {
|
export function getLLMConfig({ commandExistsFn = commandExists } = {}) {
|
||||||
|
|||||||
+7
-4
@@ -23,13 +23,16 @@ function buildPrompt(systemPrompt, userContent) {
|
|||||||
].join('\n');
|
].join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
function cliArgs(provider, model, promptFile = null) {
|
function cliArgs(provider, model, promptFile = null, prompt = null) {
|
||||||
if (provider === 'codex') {
|
if (provider === 'codex') {
|
||||||
return ['exec', '--model', model, '--sandbox', 'read-only', '--ask-for-approval', 'never', '--skip-git-repo-check', '-'];
|
return ['exec', '--model', model, '--sandbox', 'read-only', '--ask-for-approval', 'never', '--skip-git-repo-check', '-'];
|
||||||
}
|
}
|
||||||
if (provider === 'claude') {
|
if (provider === 'claude') {
|
||||||
return ['--print', '--model', model, '--permission-mode', 'dontAsk', '--no-session-persistence'];
|
return ['--print', '--model', model, '--permission-mode', 'dontAsk', '--no-session-persistence'];
|
||||||
}
|
}
|
||||||
|
if (provider === 'antigravity') {
|
||||||
|
return ['-p', prompt, '--model', model];
|
||||||
|
}
|
||||||
if (provider === 'opencode') {
|
if (provider === 'opencode') {
|
||||||
return ['run', '--model', model, '--format', 'default', '--file', promptFile, '請依附件 prompt.md 的完整內容執行,並只輸出要求的最終結果。'];
|
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');
|
promptFile = join(tempDir, 'prompt.md');
|
||||||
await writeFile(promptFile, prompt);
|
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 maxBuffer = Number(process.env.AI_ASSISTANT_MAX_BUFFER || 20 * 1024 * 1024);
|
||||||
const timeout = Number(process.env.AI_ASSISTANT_TIMEOUT_MS || 15 * 60 * 1000);
|
const timeout = Number(process.env.AI_ASSISTANT_TIMEOUT_MS || 15 * 60 * 1000);
|
||||||
try {
|
try {
|
||||||
@@ -86,7 +89,7 @@ async function runAssistantCLI({ provider, command, model }, prompt) {
|
|||||||
if (code === 0) resolve(stdout.trim());
|
if (code === 0) resolve(stdout.trim());
|
||||||
else reject(Object.assign(new Error(`${provider} CLI exited with ${code ?? signal}`), { stdout, stderr }));
|
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 {
|
} finally {
|
||||||
if (tempDir) await rm(tempDir, { recursive: true, force: true });
|
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) {
|
export async function chat(systemPrompt, userContent) {
|
||||||
const cfg = getLLMConfig();
|
const cfg = getLLMConfig();
|
||||||
const { provider, command, model } = cfg;
|
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}`);
|
line(`[LLM] provider=${provider} command=${command} model=${model}`);
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -106,7 +106,7 @@ export async function verifyCommentToken(token = GITEA_COMMENT_TOKEN) {
|
|||||||
*/
|
*/
|
||||||
export async function verifyLLM() {
|
export async function verifyLLM() {
|
||||||
const { provider, command, model } = getLLMConfig();
|
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' };
|
if (!model) return { ok: false, provider, error: '未設定 MODEL' };
|
||||||
return { ok: true, provider, command, model };
|
return { ok: true, provider, command, model };
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-1
@@ -1,6 +1,6 @@
|
|||||||
import { describe, it, beforeEach, afterEach } from 'node:test';
|
import { describe, it, beforeEach, afterEach } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { getLLMConfig, getOpenCodeHttpsAgent } from '../config.js';
|
import { getLLMCLICommands, getLLMConfig, getOpenCodeHttpsAgent } from '../config.js';
|
||||||
|
|
||||||
const ENV_KEYS = [
|
const ENV_KEYS = [
|
||||||
'AI_ASSISTANT_CLI', 'MODEL', 'OPENCODE_MODEL',
|
'AI_ASSISTANT_CLI', 'MODEL', 'OPENCODE_MODEL',
|
||||||
@@ -19,6 +19,10 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('getLLMConfig', () => {
|
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', () => {
|
it('returns null provider when no env vars set', () => {
|
||||||
const cfg = getLLMConfig({ commandExistsFn: () => false });
|
const cfg = getLLMConfig({ commandExistsFn: () => false });
|
||||||
assert.equal(cfg.provider, null);
|
assert.equal(cfg.provider, null);
|
||||||
@@ -43,6 +47,13 @@ describe('getLLMConfig', () => {
|
|||||||
assert.equal(cfg.model, 'gpt-5-mini');
|
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', () => {
|
it('can force a CLI with AI_ASSISTANT_CLI', () => {
|
||||||
process.env.AI_ASSISTANT_CLI = 'opencode';
|
process.env.AI_ASSISTANT_CLI = 'opencode';
|
||||||
process.env.OPENCODE_MODEL = 'google/gemini-2.5-pro';
|
process.env.OPENCODE_MODEL = 'google/gemini-2.5-pro';
|
||||||
|
|||||||
+19
-2
@@ -31,13 +31,13 @@ async function installFakeCLI(command = 'codex') {
|
|||||||
const script = join(tempDir, command);
|
const script = join(tempDir, command);
|
||||||
await writeFile(script, `#!/bin/sh
|
await writeFile(script, `#!/bin/sh
|
||||||
if [ -n "$FAKE_AI_ARGS_PATH" ]; then printf '%s\\n' "$*" > "$FAKE_AI_ARGS_PATH"; fi
|
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_STDERR" ]; then printf '%s' "$FAKE_AI_STDERR" >&2; fi
|
||||||
if [ -n "$FAKE_AI_STDOUT" ]; then printf '%s' "$FAKE_AI_STDOUT"; fi
|
if [ -n "$FAKE_AI_STDOUT" ]; then printf '%s' "$FAKE_AI_STDOUT"; fi
|
||||||
exit "\${FAKE_AI_EXIT:-0}"
|
exit "\${FAKE_AI_EXIT:-0}"
|
||||||
`);
|
`);
|
||||||
await chmod(script, 0o755);
|
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') };
|
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/);
|
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, /<system>\nsys\n<\/system>/);
|
||||||
|
assert.equal(await readFile(stdinPath, 'utf8'), '');
|
||||||
|
});
|
||||||
|
|
||||||
it('throws an error when the CLI fails instead of exiting the process', async () => {
|
it('throws an error when the CLI fails instead of exiting the process', async () => {
|
||||||
await installFakeCLI('codex');
|
await installFakeCLI('codex');
|
||||||
process.env.FAKE_AI_EXIT = '2';
|
process.env.FAKE_AI_EXIT = '2';
|
||||||
|
|||||||
@@ -120,6 +120,7 @@ describe('fetchAccountQuota', () => {
|
|||||||
it('reports 不適用 for local platforms', async () => {
|
it('reports 不適用 for local platforms', async () => {
|
||||||
assert.equal((await fetchAccountQuota('ollama', {})).available, false);
|
assert.equal((await fetchAccountQuota('ollama', {})).available, false);
|
||||||
assert.equal((await fetchAccountQuota('opencode', {})).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 () => {
|
it('degrades gracefully when the quota call throws', async () => {
|
||||||
|
|||||||
@@ -197,6 +197,7 @@ const QUOTA_STRATEGIES = {
|
|||||||
return { available: false, reason: 'OpenAI 帳號額度需 dashboard session 權限,API key 無法取得' };
|
return { available: false, reason: 'OpenAI 帳號額度需 dashboard session 權限,API key 無法取得' };
|
||||||
},
|
},
|
||||||
claude: async () => ({ available: false, reason: 'Anthropic 額度需 Admin API 權限,一般 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 無法直接查詢' }),
|
gemini: async () => ({ available: false, reason: 'Gemini 額度由 Google Cloud quota 管理,API key 無法直接查詢' }),
|
||||||
amazonq: async () => ({ available: false, reason: 'Amazon Q 額度由 AWS 帳務管理,需 AWS 憑證查詢' }),
|
amazonq: async () => ({ available: false, reason: 'Amazon Q 額度由 AWS 帳務管理,需 AWS 憑證查詢' }),
|
||||||
ollama: async () => ({ available: false, reason: '本地服務,無帳號額度概念' }),
|
ollama: async () => ({ available: false, reason: '本地服務,無帳號額度概念' }),
|
||||||
|
|||||||
Reference in New Issue
Block a user