Compare commits

..

8 Commits

Author SHA1 Message Date
tgrosinger 1bcd105694 OpenCode: Add OpenCode as a new provider
The OpenCode provider allows using a variety of models with an agent
harness that can gather more information from the codebase as required
(like with claude-code, codex, or gemini-cli).

This is an alternative to using OpenRouter directly, where the api
provider is more like a chatbot and cannot gather any additional context
beyond what was handed to it.
2026-06-10 08:57:24 -07:00
tgrosinger d1d25cebd2 Allow specifying tmp dir when preparing prompt 2026-06-10 08:55:55 -07:00
tgrosinger b53f731024 Claude: Use default xhigh effort
With opus-4.8, Claude defaults to "high". Bump up one level for review.
2026-06-10 08:55:55 -07:00
tgrosinger 6dc85680ef Codex: Restrict permissions 2026-06-10 08:55:55 -07:00
tgrosinger 41a4a8576c Add a flag to disable jokes 2026-06-10 08:55:55 -07:00
tgrosinger a216750027 OpenRouter: Add OpenRouter as a new provider 2026-06-10 08:55:55 -07:00
tgrosinger 7433e5740d Claude: Remove dangerously-skip-permissions
Instead, hard-code a list of allowed tools for claude that gives it
general read access.
2026-06-10 08:54:27 -07:00
Faraz Yashar fda54bad78 feat: support antigravity (#11)
Thanks for contributing!
2026-06-05 15:37:33 -07:00
5 changed files with 220 additions and 2 deletions
+7
View File
@@ -34,6 +34,13 @@ export const AVAILABLE_REVIEWERS: ReviewerOption[] = [
description: 'Uses your Gemini CLI (Google account, no API key needed)',
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',
name: 'OpenCode (via OpenRouter)',
+194
View File
@@ -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
}
}
}
+9 -2
View File
@@ -6,6 +6,7 @@ import { OpenAIProvider } from './openai.js'
import { ClaudeCodeProvider } from './claude-code.js'
import { CodexCliProvider } from './codex-cli.js'
import { GeminiCliProvider } from './gemini-cli.js'
import { AntigravityCliProvider } from './antigravity.js'
import { GeminiProvider } from './gemini.js'
import { OpencodeCliProvider } from './opencode-cli.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' }
// 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]
const OPENROUTER_PREFIX = 'openrouter/'
@@ -47,7 +48,7 @@ export function isCliModel(model: string): boolean {
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)) {
return 'openrouter'
}
@@ -101,6 +102,12 @@ export function createProvider(model: string, config: MagpieConfig): AIProvider
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)
if (providerName === 'qwen-code') {
checkCliBinary('qwen', 'Qwen Code')
+1
View File
@@ -6,6 +6,7 @@ import { homedir } from 'os'
const PROVIDER_CONTEXT_MAP: Record<string, string[]> = {
'claude-code': ['CLAUDE.md', 'AGENTS.md', 'GEMINI.md'],
'gemini-cli': ['GEMINI.md', 'AGENTS.md', 'CLAUDE.md'],
'antigravity': ['GEMINI.md', 'AGENTS.md', 'CLAUDE.md'],
'codex-cli': ['AGENTS.md', 'CLAUDE.md', 'GEMINI.md'],
}
const DEFAULT_CONTEXT_FILES = ['CLAUDE.md', 'AGENTS.md', 'GEMINI.md']
+9
View File
@@ -44,6 +44,10 @@ describe('Provider Factory', () => {
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)', () => {
expect(getProviderForModel('opencode-cli')).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')
})
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', () => {
const provider = createProvider('opencode-cli', mockConfig)
expect(provider.name).toBe('opencode-cli')