Compare commits
8 Commits
2163ea45d2
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1bcd105694 | |||
| d1d25cebd2 | |||
| b53f731024 | |||
| 6dc85680ef | |||
| 41a4a8576c | |||
| a216750027 | |||
| 7433e5740d | |||
| fda54bad78 |
@@ -34,6 +34,13 @@ export const AVAILABLE_REVIEWERS: ReviewerOption[] = [
|
|||||||
description: 'Uses your Gemini CLI (Google account, no API key needed)',
|
description: 'Uses your Gemini CLI (Google account, no API key needed)',
|
||||||
needsApiKey: false
|
needsApiKey: false
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'antigravity',
|
||||||
|
name: 'Antigravity CLI',
|
||||||
|
model: 'antigravity',
|
||||||
|
description: 'Uses your Antigravity CLI (agy) — Google account, no API key needed',
|
||||||
|
needsApiKey: false
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'opencode-cli',
|
id: 'opencode-cli',
|
||||||
name: 'OpenCode (via OpenRouter)',
|
name: 'OpenCode (via OpenRouter)',
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import { spawn } from 'child_process'
|
||||||
|
import type { AIProvider, Message, CliProviderOptions } from './types.js'
|
||||||
|
import { CliSessionHelper } from './session-helper.js'
|
||||||
|
import { preparePromptForCli } from '../utils/prompt-file.js'
|
||||||
|
import { withRetry } from '../utils/retry.js'
|
||||||
|
|
||||||
|
// Antigravity (`agy`) is Google's rebrand of the Gemini CLI. Compared to `gemini`:
|
||||||
|
// - auto-approve is `--dangerously-skip-permissions` (was `-y`)
|
||||||
|
// - print mode emits plain text on stdout (no `-o json` / `-o stream-json`)
|
||||||
|
// - there is no `--model` flag; agy pins its own model, so cliModel is ignored
|
||||||
|
//
|
||||||
|
// Unlike gemini-cli, this provider is stateless: agy's `--conversation` resume
|
||||||
|
// re-prints the *entire* assistant transcript on every turn, which would duplicate
|
||||||
|
// prior responses. Instead each call runs a fresh conversation with the full
|
||||||
|
// message history in the prompt (the same approach the API providers use), so each
|
||||||
|
// run returns exactly one response. We therefore expose no session id.
|
||||||
|
export class AntigravityCliProvider implements AIProvider {
|
||||||
|
name = 'antigravity'
|
||||||
|
private cwd: string
|
||||||
|
private timeout: number // ms, 0 = no timeout
|
||||||
|
private formatter = new CliSessionHelper()
|
||||||
|
|
||||||
|
constructor(_options?: CliProviderOptions) {
|
||||||
|
// No API key needed (uses Google account). agy has no --model flag, so cliModel is ignored.
|
||||||
|
this.cwd = process.cwd()
|
||||||
|
this.timeout = 15 * 60 * 1000 // 15 minutes default
|
||||||
|
}
|
||||||
|
|
||||||
|
setCwd(cwd: string) {
|
||||||
|
this.cwd = cwd
|
||||||
|
}
|
||||||
|
|
||||||
|
async chat(messages: Message[], systemPrompt?: string): Promise<string> {
|
||||||
|
const prompt = this.formatter.buildPrompt(messages, systemPrompt)
|
||||||
|
return withRetry(() => this.runAgy(prompt))
|
||||||
|
}
|
||||||
|
|
||||||
|
async *chatStream(messages: Message[], systemPrompt?: string): AsyncGenerator<string, void, unknown> {
|
||||||
|
const prompt = this.formatter.buildPrompt(messages, systemPrompt)
|
||||||
|
yield* this.runAgyStream(prompt)
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildArgs(): string[] {
|
||||||
|
const args = ['--dangerously-skip-permissions']
|
||||||
|
if (this.timeout > 0) {
|
||||||
|
args.push('--print-timeout', `${Math.max(1, Math.ceil(this.timeout / 60000))}m`)
|
||||||
|
}
|
||||||
|
// Prompt is read from stdin via the `-` sentinel
|
||||||
|
args.push('--print', '-')
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
|
||||||
|
private runAgy(prompt: string): Promise<string> {
|
||||||
|
const { prompt: stdinPrompt, cleanup } = preparePromptForCli(prompt)
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const child = spawn('agy', this.buildArgs(), {
|
||||||
|
cwd: this.cwd,
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe']
|
||||||
|
})
|
||||||
|
|
||||||
|
// Suppress EPIPE: if child exits early, close handler reports the real error
|
||||||
|
child.stdin.on('error', () => {})
|
||||||
|
child.stdin.write(stdinPrompt)
|
||||||
|
child.stdin.end()
|
||||||
|
|
||||||
|
let output = ''
|
||||||
|
let error = ''
|
||||||
|
|
||||||
|
child.stdout.on('data', (data) => {
|
||||||
|
output += data.toString()
|
||||||
|
})
|
||||||
|
|
||||||
|
child.stderr.on('data', (data) => {
|
||||||
|
error += data.toString()
|
||||||
|
})
|
||||||
|
|
||||||
|
child.on('close', (code) => {
|
||||||
|
cleanup()
|
||||||
|
if (code !== 0) {
|
||||||
|
reject(new Error(`Antigravity CLI exited with code ${code}: ${error}`))
|
||||||
|
} else {
|
||||||
|
resolve(output.trim())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
child.on('error', (err) => {
|
||||||
|
cleanup()
|
||||||
|
reject(new Error(`Failed to run agy CLI: ${err.message}`))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private async *runAgyStream(prompt: string): AsyncGenerator<string, void, unknown> {
|
||||||
|
const { prompt: stdinPrompt, cleanup } = preparePromptForCli(prompt)
|
||||||
|
|
||||||
|
const child = spawn('agy', this.buildArgs(), {
|
||||||
|
cwd: this.cwd,
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe']
|
||||||
|
})
|
||||||
|
|
||||||
|
const chunks: string[] = []
|
||||||
|
let resolveNext: ((value: { chunk: string | null }) => void) | null = null
|
||||||
|
let done = false
|
||||||
|
let error: Error | null = null
|
||||||
|
let lastActivity = Date.now()
|
||||||
|
let stderrBuf = ''
|
||||||
|
|
||||||
|
// Timeout checker - kill if no activity for too long
|
||||||
|
const timeoutChecker = this.timeout > 0 ? setInterval(() => {
|
||||||
|
if (Date.now() - lastActivity > this.timeout) {
|
||||||
|
child.kill('SIGTERM')
|
||||||
|
// Force kill if SIGTERM is ignored
|
||||||
|
const forceKill = setTimeout(() => {
|
||||||
|
try { child.kill('SIGKILL') } catch {}
|
||||||
|
}, 5000)
|
||||||
|
forceKill.unref()
|
||||||
|
done = true
|
||||||
|
const stderr = stderrBuf.trim()
|
||||||
|
error = new Error(`Antigravity CLI timed out after ${this.timeout / 1000}s of inactivity${stderr ? ': ' + stderr.slice(-500) : ''}`)
|
||||||
|
if (resolveNext) {
|
||||||
|
resolveNext({ chunk: null })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 10000) : null // Check every 10s
|
||||||
|
|
||||||
|
const pushChunk = (chunk: string) => {
|
||||||
|
if (resolveNext) {
|
||||||
|
resolveNext({ chunk })
|
||||||
|
resolveNext = null
|
||||||
|
} else {
|
||||||
|
chunks.push(chunk)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Print mode streams the plain-text response on stdout — yield chunks as-is
|
||||||
|
child.stdout.on('data', (data) => {
|
||||||
|
lastActivity = Date.now()
|
||||||
|
pushChunk(data.toString())
|
||||||
|
})
|
||||||
|
|
||||||
|
child.stderr.on('data', (data) => {
|
||||||
|
lastActivity = Date.now() // Activity on stderr also counts
|
||||||
|
stderrBuf += data.toString()
|
||||||
|
if (stderrBuf.length > 10000) stderrBuf = stderrBuf.slice(-10000)
|
||||||
|
})
|
||||||
|
|
||||||
|
child.on('close', (code) => {
|
||||||
|
cleanup()
|
||||||
|
if (timeoutChecker) clearInterval(timeoutChecker)
|
||||||
|
done = true
|
||||||
|
if (code !== 0 && !error) {
|
||||||
|
const stderr = stderrBuf.trim()
|
||||||
|
error = new Error(`Antigravity CLI exited with code ${code}${stderr ? ': ' + stderr.slice(-500) : ''}`)
|
||||||
|
}
|
||||||
|
if (resolveNext) {
|
||||||
|
resolveNext({ chunk: null })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
child.on('error', (err) => {
|
||||||
|
cleanup()
|
||||||
|
if (timeoutChecker) clearInterval(timeoutChecker)
|
||||||
|
done = true
|
||||||
|
error = new Error(`Failed to run agy CLI: ${err.message}`)
|
||||||
|
if (resolveNext) {
|
||||||
|
resolveNext({ chunk: null })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Write prompt to stdin and close
|
||||||
|
// Suppress EPIPE: if child exits early, close handler reports the real error
|
||||||
|
child.stdin.on('error', () => {})
|
||||||
|
child.stdin.write(stdinPrompt)
|
||||||
|
child.stdin.end()
|
||||||
|
|
||||||
|
while (!done || chunks.length > 0) {
|
||||||
|
if (chunks.length > 0) {
|
||||||
|
yield chunks.shift()!
|
||||||
|
} else if (!done) {
|
||||||
|
const result = await new Promise<{ chunk: string | null }>((resolve) => {
|
||||||
|
resolveNext = resolve
|
||||||
|
})
|
||||||
|
if (result.chunk !== null) {
|
||||||
|
yield result.chunk
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { OpenAIProvider } from './openai.js'
|
|||||||
import { ClaudeCodeProvider } from './claude-code.js'
|
import { ClaudeCodeProvider } from './claude-code.js'
|
||||||
import { CodexCliProvider } from './codex-cli.js'
|
import { CodexCliProvider } from './codex-cli.js'
|
||||||
import { GeminiCliProvider } from './gemini-cli.js'
|
import { GeminiCliProvider } from './gemini-cli.js'
|
||||||
|
import { AntigravityCliProvider } from './antigravity.js'
|
||||||
import { GeminiProvider } from './gemini.js'
|
import { GeminiProvider } from './gemini.js'
|
||||||
import { OpencodeCliProvider } from './opencode-cli.js'
|
import { OpencodeCliProvider } from './opencode-cli.js'
|
||||||
import { QwenCodeProvider } from './qwen-code.js'
|
import { QwenCodeProvider } from './qwen-code.js'
|
||||||
@@ -15,7 +16,7 @@ import { checkCliBinary } from './cli-check.js'
|
|||||||
|
|
||||||
// Parse CLI model string: 'gemini-cli:gemini-2.5-pro' → { provider: 'gemini-cli', cliModel: 'gemini-2.5-pro' }
|
// Parse CLI model string: 'gemini-cli:gemini-2.5-pro' → { provider: 'gemini-cli', cliModel: 'gemini-2.5-pro' }
|
||||||
// Plain 'gemini-cli' → { provider: 'gemini-cli', cliModel: undefined }
|
// Plain 'gemini-cli' → { provider: 'gemini-cli', cliModel: undefined }
|
||||||
const CLI_PROVIDERS = ['claude-code', 'codex-cli', 'gemini-cli', 'opencode-cli', 'qwen-code'] as const
|
const CLI_PROVIDERS = ['claude-code', 'codex-cli', 'gemini-cli', 'antigravity', 'opencode-cli', 'qwen-code'] as const
|
||||||
type CliProviderName = typeof CLI_PROVIDERS[number]
|
type CliProviderName = typeof CLI_PROVIDERS[number]
|
||||||
|
|
||||||
const OPENROUTER_PREFIX = 'openrouter/'
|
const OPENROUTER_PREFIX = 'openrouter/'
|
||||||
@@ -47,7 +48,7 @@ export function isCliModel(model: string): boolean {
|
|||||||
return (CLI_PROVIDERS as readonly string[]).includes(provider)
|
return (CLI_PROVIDERS as readonly string[]).includes(provider)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getProviderForModel(model: string): 'anthropic' | 'openai' | 'google' | 'claude-code' | 'codex-cli' | 'gemini-cli' | 'opencode-cli' | 'qwen-code' | 'minimax' | 'mock' | 'openrouter' {
|
export function getProviderForModel(model: string): 'anthropic' | 'openai' | 'google' | 'claude-code' | 'codex-cli' | 'gemini-cli' | 'antigravity' | 'opencode-cli' | 'qwen-code' | 'minimax' | 'mock' | 'openrouter' {
|
||||||
if (model.startsWith(OPENROUTER_PREFIX)) {
|
if (model.startsWith(OPENROUTER_PREFIX)) {
|
||||||
return 'openrouter'
|
return 'openrouter'
|
||||||
}
|
}
|
||||||
@@ -101,6 +102,12 @@ export function createProvider(model: string, config: MagpieConfig): AIProvider
|
|||||||
return new GeminiCliProvider({ cliModel })
|
return new GeminiCliProvider({ cliModel })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Antigravity CLI (`agy`) doesn't need API key config (uses Google account)
|
||||||
|
if (providerName === 'antigravity') {
|
||||||
|
checkCliBinary('agy', 'Antigravity')
|
||||||
|
return new AntigravityCliProvider({ cliModel })
|
||||||
|
}
|
||||||
|
|
||||||
// Qwen Code CLI doesn't need API key config (uses OAuth)
|
// Qwen Code CLI doesn't need API key config (uses OAuth)
|
||||||
if (providerName === 'qwen-code') {
|
if (providerName === 'qwen-code') {
|
||||||
checkCliBinary('qwen', 'Qwen Code')
|
checkCliBinary('qwen', 'Qwen Code')
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { homedir } from 'os'
|
|||||||
const PROVIDER_CONTEXT_MAP: Record<string, string[]> = {
|
const PROVIDER_CONTEXT_MAP: Record<string, string[]> = {
|
||||||
'claude-code': ['CLAUDE.md', 'AGENTS.md', 'GEMINI.md'],
|
'claude-code': ['CLAUDE.md', 'AGENTS.md', 'GEMINI.md'],
|
||||||
'gemini-cli': ['GEMINI.md', 'AGENTS.md', 'CLAUDE.md'],
|
'gemini-cli': ['GEMINI.md', 'AGENTS.md', 'CLAUDE.md'],
|
||||||
|
'antigravity': ['GEMINI.md', 'AGENTS.md', 'CLAUDE.md'],
|
||||||
'codex-cli': ['AGENTS.md', 'CLAUDE.md', 'GEMINI.md'],
|
'codex-cli': ['AGENTS.md', 'CLAUDE.md', 'GEMINI.md'],
|
||||||
}
|
}
|
||||||
const DEFAULT_CONTEXT_FILES = ['CLAUDE.md', 'AGENTS.md', 'GEMINI.md']
|
const DEFAULT_CONTEXT_FILES = ['CLAUDE.md', 'AGENTS.md', 'GEMINI.md']
|
||||||
|
|||||||
@@ -44,6 +44,10 @@ describe('Provider Factory', () => {
|
|||||||
expect(getProviderForModel('codex-cli')).toBe('codex-cli')
|
expect(getProviderForModel('codex-cli')).toBe('codex-cli')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should return antigravity for antigravity model', () => {
|
||||||
|
expect(getProviderForModel('antigravity')).toBe('antigravity')
|
||||||
|
})
|
||||||
|
|
||||||
it('should return opencode-cli for opencode-cli model (with and without :model suffix)', () => {
|
it('should return opencode-cli for opencode-cli model (with and without :model suffix)', () => {
|
||||||
expect(getProviderForModel('opencode-cli')).toBe('opencode-cli')
|
expect(getProviderForModel('opencode-cli')).toBe('opencode-cli')
|
||||||
expect(getProviderForModel('opencode-cli:openrouter/anthropic/claude-sonnet-4')).toBe('opencode-cli')
|
expect(getProviderForModel('opencode-cli:openrouter/anthropic/claude-sonnet-4')).toBe('opencode-cli')
|
||||||
@@ -91,6 +95,11 @@ describe('Provider Factory', () => {
|
|||||||
expect(provider.name).toBe('codex-cli')
|
expect(provider.name).toBe('codex-cli')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should create antigravity provider', () => {
|
||||||
|
const provider = createProvider('antigravity', mockConfig)
|
||||||
|
expect(provider.name).toBe('antigravity')
|
||||||
|
})
|
||||||
|
|
||||||
it('should create opencode-cli provider with no extra config', () => {
|
it('should create opencode-cli provider with no extra config', () => {
|
||||||
const provider = createProvider('opencode-cli', mockConfig)
|
const provider = createProvider('opencode-cli', mockConfig)
|
||||||
expect(provider.name).toBe('opencode-cli')
|
expect(provider.name).toBe('opencode-cli')
|
||||||
|
|||||||
Reference in New Issue
Block a user