diff --git a/CLAUDE.md b/CLAUDE.md index d7063cc1..c802a6c2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -190,7 +190,7 @@ If you find yourself reaching for `setTimeout` to "check again later", you're po - `models/pricing.ts` — Anthropic `MODEL_PRICING` and `MODEL_CONTEXT_WINDOW` tables plus `getPricing()` / `getContextWindow()` lookup helpers. - `types/` — cross-repo wire types (`preview`, `beads`, `streaming`, `file-change`) plus `types/events.ts`: the `USER_EVENTS` constant map of every per-user SSE event name (canonical here; mirrored at `codeagent-mobile/packages/shared/src/types/events.ts`). New event-producing/consuming code references `USER_EVENTS.*`, never a hand-typed string. -⚠️ **The TUI chrome/selector *parsers* do NOT live in shared anymore** (old `protocol/parseChrome.ts` / `selector.ts` / `filterChrome.ts` are gone). Glyphs and conventions vary per agent, so each PTY agent owns its own fixture-driven parsers next to its runtime strategy: `apps/cli/src/agents//parsing.ts` (e.g. `cursor/parsing.ts`, `aider/parsing.ts` — `parseChrome` / `filterChrome` / `detectSelector`), consumed via the runtime strategy and `streaming-emitter.service.ts`. ACP agents (claude/codex/gemini) get typed streaming and need no chrome parsing. `apps/cli/src/services/parseChrome.ts` is a vestigial one-line re-export of `@codeam/shared` with no remaining importers — don't add logic there. +⚠️ **The TUI chrome/selector *parsers* do NOT live in shared anymore** (old `protocol/parseChrome.ts` / `selector.ts` / `filterChrome.ts` are gone). Glyphs and conventions vary per agent, so each PTY agent owns its own fixture-driven parsers next to its runtime strategy: `apps/cli/src/agents//parsing.ts` (e.g. `cursor/parsing.ts`, `aider/parsing.ts` — `parseChrome` / `filterChrome` / `detectSelector`), consumed via the runtime strategy and `streaming-emitter.service.ts`. ACP agents (claude/codex/gemini/cursor/kimi/opencode/antigravity) get typed streaming and need no chrome parsing. `apps/cli/src/services/parseChrome.ts` is a vestigial one-line re-export of `@codeam/shared` with no remaining importers — don't add logic there. Tests live next to the modules in `packages/shared/__tests__/` and run via `(cd packages/shared && npm run test)` or are picked up by the CI job automatically. diff --git a/apps/box/Dockerfile b/apps/box/Dockerfile index 40c87ffc..f11ddd6c 100644 --- a/apps/box/Dockerfile +++ b/apps/box/Dockerfile @@ -19,7 +19,7 @@ # # ── Pre-installed agents + tooling (deploy-speed optimization) ───────────── # EVERY supported agent CLI (claude-code, codex, cursor-agent, coderabbit, -# gemini, kimi, aider), the GitHub + GitLab CLIs (gh, glab), and Beads + Dolt +# gemini, kimi, opencode, antigravity/agy, aider), the GitHub + GitLab CLIs (gh, glab), and Beads + Dolt # are baked into the image so a rescued user's FIRST deploy skips the # per-session installs entirely (each of those otherwise shells out to a # curl|sh / npm installer on first use). A box boots agent-ready. The deploy @@ -32,7 +32,7 @@ # cache go to NON-home locations that survive the mount: # • npm-global agents + codeam-cli → /usr/local/{lib,bin} # • curl-installer agents (claude/cursor/kimi/coderabbit) → /opt/agents -# • dolt / aider / gh / glab / composer → /usr/local/bin +# • dolt / aider / gh / glab / composer / agy → /usr/local/bin # • php (apt) → /usr/bin # # ⚠️ SIZE RULE: NEVER `chown -R` a large tree in its own RUN — Docker's @@ -228,6 +228,44 @@ RUN mkdir -p "${AGENT_HOME}" \ if [ -x "$b" ]; then echo " OK $b"; else echo " MISS $b (installs on first deploy)"; fi; \ done +# ── Antigravity CLI (`agy`) → /usr/local/bin (root-owned, pinned, no self-update) ─ +# Not via install.sh: that script always fetches the LATEST manifest (no +# version flag), so the image would float. We download the exact release the +# installer would have picked — same bucket, same per-platform tarball, same +# sha512 the manifest publishes +# (https://antigravity-cli-auto-updater-974169037036.us-central1.run.app/manifests/linux_.json) +# — and verify it. Bump AGY_RELEASE + both AGY_SHA512_* together from those +# manifests (guarded by __tests__/box/agy-version-pin.test.ts). +# +# /usr/local/bin, NOT ~/.local/bin: the runtime `/home/box` volume would shadow +# a home install. The codespace/self-hosted install snippets are +# `command -v agy`-guarded, so the baked binary short-circuits them. +# +# ⚠️ SELF-UPDATE: agy updates itself in the background and RELAUNCHES after an +# update ("Failed to relaunch after update" is in the binary) — inside a live +# `agy acp` session that would swap the binary under us and break the pin. +# AGY_CLI_DISABLE_AUTO_UPDATE=1 (an env knob the binary reads; undocumented) +# turns it off for every box process, and the binary is root-owned 0755 so a +# stray update attempt can't rewrite it either. Upgrades ship with the image. +ARG AGY_RELEASE=1.5.2-4951595792859136 +ARG AGY_SHA512_AMD64=aa876c0813572efaf85ed47983cc937414705d40490cbdb54f6e255656a4f8744ae38e457823e924fa8270b348124e0521d3cda3fec6824bb2d14b98a7a05baa +ARG AGY_SHA512_ARM64=9a141da7b218c640a962188b388905f065cc307425bbeff6e74c07f95266f93526f6d0dee9bad876b79431403f5b30ef4e6514f5f44fbf06dca2a077f54f6175 +ENV AGY_CLI_DISABLE_AUTO_UPDATE=1 +RUN set -eu; \ + case "$(dpkg --print-architecture)" in \ + amd64) dir=linux-x64; file=cli_linux_x64.tar.gz; sha="${AGY_SHA512_AMD64}" ;; \ + arm64) dir=linux-arm; file=cli_linux_arm64.tar.gz; sha="${AGY_SHA512_ARM64}" ;; \ + *) echo "agy: unsupported arch $(dpkg --print-architecture)" >&2; exit 1 ;; \ + esac; \ + curl -fsSL --connect-timeout 15 --max-time 300 \ + "https://storage.googleapis.com/antigravity-public/antigravity-cli/${AGY_RELEASE}/${dir}/${file}" \ + -o /tmp/agy.tar.gz; \ + echo "${sha} /tmp/agy.tar.gz" | sha512sum -c -; \ + tar -xzf /tmp/agy.tar.gz -C /tmp antigravity; \ + install -m 0755 /tmp/antigravity /usr/local/bin/agy; \ + rm -f /tmp/agy.tar.gz /tmp/antigravity; \ + agy --version + # ── aider (pip) ──────────────────────────────────────────────────────────── # Debian's system Python is PEP-668 "externally managed" → --break-system-packages # (the container is single-user; no other Python consumer to protect). diff --git a/apps/cli/__tests__/agents/antigravity.test.ts b/apps/cli/__tests__/agents/antigravity.test.ts new file mode 100644 index 00000000..9ff82514 --- /dev/null +++ b/apps/cli/__tests__/agents/antigravity.test.ts @@ -0,0 +1,170 @@ +/** + * Antigravity CLI (`agy`) — the four seams a new agent breaks at: ACP dispatch, + * self-hosted credential delivery (both auth kinds), the headless one-shot + * (Preview detection + AI summaries), and `codeam link` token validation. + */ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { + getAcpAdapter, + requiresAcp, + resetAcpAdapterCacheForTests, +} from '../../src/agents/acp/adapters'; +import { provisionAgentCredentials } from '../../src/commands/host/agent-provisioning'; +import { AntigravityRuntimeStrategy } from '../../src/agents/antigravity/runtime'; +import { validateLocalAntigravityToken } from '../../src/agents/antigravity/local-token'; +import type { OsStrategy } from '../../src/os'; + +const OAUTH_BLOB = JSON.stringify({ + token: { + access_token: 'ya29.test', + token_type: 'Bearer', + refresh_token: '1//refresh', + expiry: '2026-11-06T13:00:00Z', + }, + auth_method: 'consumer', +}); + +let tmp: string; +beforeEach(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'codeam-agy-')); +}); +afterEach(() => { + fs.rmSync(tmp, { recursive: true, force: true }); +}); + +const tokenFile = (): string => + path.join(tmp, '.gemini', 'antigravity-cli', 'antigravity-oauth-token'); +const settingsFile = (): string => path.join(tmp, '.gemini', 'antigravity-cli', 'settings.json'); +const readSettings = (): Record => + JSON.parse(fs.readFileSync(settingsFile(), 'utf8')) as Record; + +describe('antigravity ACP adapter', () => { + beforeEach(() => resetAcpAdapterCacheForTests()); + + it('runs over ACP via the native `agy acp` server', () => { + expect(requiresAcp('antigravity')).toBe(true); + const spec = getAcpAdapter('antigravity'); + expect(spec?.command).toBe('agy'); + expect(spec?.requiresAgentBinary).toBe('agy'); + }); + + it('does NOT skip permissions — our runner answers session/request_permission', () => { + expect(getAcpAdapter('antigravity')?.args).toEqual(['acp']); + }); +}); + +describe('antigravity self-hosted credential provisioning', () => { + it('oauth_token: writes the blob verbatim to agy’s token file, 0600, no env', () => { + const env = provisionAgentCredentials('antigravity', { kind: 'oauth_token', value: OAUTH_BLOB }, tmp); + expect(env).toEqual({}); + expect(fs.readFileSync(tokenFile(), 'utf8')).toBe(OAUTH_BLOB); + if (process.platform !== 'win32') { + expect(fs.statSync(tokenFile()).mode & 0o777).toBe(0o600); + } + }); + + it('oauth_token: rejects a non-JSON or token-less blob instead of writing it', () => { + expect(() => + provisionAgentCredentials('antigravity', { kind: 'oauth_token', value: 'ya29.bare' }, tmp), + ).toThrow(/not valid JSON/); + expect(() => + provisionAgentCredentials( + 'antigravity', + { kind: 'oauth_token', value: JSON.stringify({ access_token: 'flat' }) }, + tmp, + ), + ).toThrow(/token\.access_token/); + expect(fs.existsSync(tokenFile())).toBe(false); + }); + + it('api_key: exports GEMINI_API_KEY AND sets modelProvider=gemini (env alone does nothing)', () => { + const env = provisionAgentCredentials('antigravity', { kind: 'api_key', value: 'AIzaTest' }, tmp); + expect(env).toEqual({ GEMINI_API_KEY: 'AIzaTest' }); + expect(readSettings()).toEqual({ modelProvider: 'gemini' }); + if (process.platform !== 'win32') { + expect(fs.statSync(settingsFile()).mode & 0o777).toBe(0o600); + } + }); + + it('api_key: merges into an existing settings.json and removes a shadowing OAuth login', () => { + provisionAgentCredentials('antigravity', { kind: 'oauth_token', value: OAUTH_BLOB }, tmp); + fs.writeFileSync(settingsFile(), JSON.stringify({ theme: 'dark' })); + provisionAgentCredentials('antigravity', { kind: 'api_key', value: 'AIzaTest' }, tmp); + expect(fs.existsSync(tokenFile())).toBe(false); + expect(readSettings()).toEqual({ theme: 'dark', modelProvider: 'gemini' }); + }); + + it('switching back to oauth_token drops modelProvider and keeps the other settings', () => { + provisionAgentCredentials('antigravity', { kind: 'api_key', value: 'AIzaTest' }, tmp); + fs.writeFileSync(settingsFile(), JSON.stringify({ theme: 'dark', modelProvider: 'gemini' })); + provisionAgentCredentials('antigravity', { kind: 'oauth_token', value: OAUTH_BLOB }, tmp); + expect(readSettings()).toEqual({ theme: 'dark' }); + expect(fs.readFileSync(tokenFile(), 'utf8')).toBe(OAUTH_BLOB); + }); +}); + +describe.skipIf(process.platform === 'win32')('AntigravityRuntimeStrategy.generateOneShot', () => { + /** A fake `agy` on disk: an executable shell script with the given body. */ + function fakeAgy(body: string): string { + const bin = path.join(tmp, 'agy'); + fs.writeFileSync(bin, `#!/bin/sh\n${body}\n`, { mode: 0o755 }); + return bin; + } + const osWith = (bin: string | null): OsStrategy => + ({ + id: 'linux', + findInPath: () => bin, + buildLaunch: (cmd: string, args: string[]) => ({ cmd, args }), + }) as unknown as OsStrategy; + + it('runs `agy -p --print-timeout s` and returns its stdout', async () => { + // Echo argv back, one arg per line, so the test sees exactly what agy got. + const bin = fakeAgy('for a in "$@"; do echo "$a"; done'); + const out = await new AntigravityRuntimeStrategy(osWith(bin)).generateOneShot('detect it', { + timeoutMs: 30_000, + }); + expect(out?.split('\n')).toEqual(['-p', 'detect it', '--print-timeout', '30s']); + }); + + it('returns null when agy hangs (signed out, waiting for a pasted code)', async () => { + const bin = fakeAgy('sleep 30'); + const out = await new AntigravityRuntimeStrategy(osWith(bin)).generateOneShot('x', { + timeoutMs: 1, + }); + expect(out).toBeNull(); + }, 15_000); + + it('returns null and surfaces stdout when agy exits non-zero', async () => { + const bin = fakeAgy('echo "API_KEY_INVALID"; exit 1'); + let failed = ''; + const out = await new AntigravityRuntimeStrategy(osWith(bin)).generateOneShot('x', { + onFailedOutput: (s) => { + failed = s; + }, + }); + expect(out).toBeNull(); + expect(failed).toBe('API_KEY_INVALID'); + }); + + it('returns null when agy is not installed', async () => { + expect(await new AntigravityRuntimeStrategy(osWith(null)).generateOneShot('x')).toBeNull(); + }); +}); + +describe('validateLocalAntigravityToken (codeam link)', () => { + it('a blob with a refresh token is valid even past its access-token expiry', () => { + expect(validateLocalAntigravityToken({ method: 'oauth', credential: OAUTH_BLOB, source: 'flat-file' }).status).toBe('valid'); + }); + + it('a refresh-less blob past its expiry is expired', () => { + const credential = JSON.stringify({ token: { access_token: 'a', expiry: '2020-01-01T00:00:00Z' } }); + expect(validateLocalAntigravityToken({ method: 'oauth', credential, source: 'flat-file' }).status).toBe('expired'); + }); + + it('an API key is valid when present', () => { + expect(validateLocalAntigravityToken({ method: 'api_key', credential: 'AIza', source: 'flat-file' }).status).toBe('valid'); + }); +}); diff --git a/apps/cli/__tests__/box/agy-version-pin.test.ts b/apps/cli/__tests__/box/agy-version-pin.test.ts new file mode 100644 index 00000000..4ccac3a1 --- /dev/null +++ b/apps/cli/__tests__/box/agy-version-pin.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; + +/** + * The Box image bakes Antigravity CLI (`agy`) at an EXACT release, sha512- + * verified, into /usr/local/bin (outside the runtime-shadowed /home/box), with + * its background self-update turned off. The official install.sh always pulls + * the LATEST manifest and agy relaunches itself after an update, so without + * these a rebuild floats and a live `agy acp` session can have its binary + * swapped underneath it. + */ +const ROOT = path.resolve(__dirname, '../../../..'); +const dockerfile = fs.readFileSync(path.join(ROOT, 'apps/box/Dockerfile'), 'utf8'); + +function arg(name: string): string { + return new RegExp(`^ARG ${name}=(\\S+)$`, 'm').exec(dockerfile)?.[1] ?? ''; +} + +describe('Antigravity CLI (agy) pin in the Box image', () => { + it('pins an exact release and a sha512 per architecture', () => { + expect(arg('AGY_RELEASE')).toMatch(/^\d+\.\d+\.\d+-\d+$/); + expect(arg('AGY_SHA512_AMD64')).toMatch(/^[0-9a-f]{128}$/); + expect(arg('AGY_SHA512_ARM64')).toMatch(/^[0-9a-f]{128}$/); + }); + + it('downloads through the pinned release and verifies the checksum', () => { + expect(dockerfile).toContain( + 'https://storage.googleapis.com/antigravity-public/antigravity-cli/${AGY_RELEASE}/', + ); + expect(dockerfile).toMatch(/sha512sum -c -/); + // Never the floating installer, which always resolves the latest manifest. + expect(dockerfile).not.toContain('antigravity.google/cli/install.sh'); + }); + + it('installs to /usr/local/bin, not under the shadowed /home/box volume', () => { + expect(dockerfile).toContain('install -m 0755 /tmp/antigravity /usr/local/bin/agy'); + expect(dockerfile).toMatch(/agy --version/); + }); + + it('disables the background self-update for every box process', () => { + expect(dockerfile).toMatch(/^ENV AGY_CLI_DISABLE_AUTO_UPDATE=1$/m); + }); +}); diff --git a/apps/cli/__tests__/fixtures/agent-install-cases.ts b/apps/cli/__tests__/fixtures/agent-install-cases.ts index ec8b3dad..59c60b67 100644 --- a/apps/cli/__tests__/fixtures/agent-install-cases.ts +++ b/apps/cli/__tests__/fixtures/agent-install-cases.ts @@ -45,6 +45,10 @@ export const AGENT_INSTALL_CASES: Record = { note: 'curl installer → ~/.local/bin; adapter spec is cached with the BARE name pre-install', }, opencode: { kind: 'real', note: 'curl installer → ~/.opencode/bin (off PATH by construction)' }, + antigravity: { + kind: 'real', + note: 'no-sudo curl installer → ~/.local/bin/agy (glibc/musl build picked by the script)', + }, aider: { kind: 'real', note: 'pip falls back to a --user install → ~/.local/bin; probed via the runtime prepareLaunch', diff --git a/apps/cli/__tests__/host/agent-provisioning.matrix.test.ts b/apps/cli/__tests__/host/agent-provisioning.matrix.test.ts index 33576048..bb8fe4da 100644 --- a/apps/cli/__tests__/host/agent-provisioning.matrix.test.ts +++ b/apps/cli/__tests__/host/agent-provisioning.matrix.test.ts @@ -33,6 +33,9 @@ function sampleValue(agentId: string, kind: AgentAuthKind): string { if (kind === 'api_key') return 'sk-test-key'; if (agentId === 'claude') return 'sk-ant-oat01-test'; if (agentId === 'cursor') return JSON.stringify({ accessToken: 'a', refreshToken: 'r', userId: 'u' }); + if (agentId === 'antigravity') { + return JSON.stringify({ token: { access_token: 'a', refresh_token: 'r' }, auth_method: 'consumer' }); + } if (agentId === 'codex') { return JSON.stringify({ auth_mode: 'chatgpt', tokens: { id_token: 'x', access_token: 'a', refresh_token: 'r', account_id: 'acc' } }); } @@ -54,7 +57,7 @@ describe('provisionAgentCredentials — every enabled agent × auth kind is prov ); it('covers the agents users can deploy today', () => { - expect(enabled.map((m) => m.id)).toEqual(expect.arrayContaining(['claude', 'codex', 'aider', 'opencode'])); + expect(enabled.map((m) => m.id)).toEqual(expect.arrayContaining(['claude', 'codex', 'aider', 'opencode', 'antigravity'])); }); it.each(rows)('%s / %s does not throw UnsupportedAgentError', (agentId, kind) => { diff --git a/apps/cli/src/agents/acp/adapters.ts b/apps/cli/src/agents/acp/adapters.ts index 921f4378..bba666ec 100644 --- a/apps/cli/src/agents/acp/adapters.ts +++ b/apps/cli/src/agents/acp/adapters.ts @@ -34,6 +34,7 @@ import { } from './agent-binary'; import { ensureKimiInstalled, augmentKimiPath } from '../kimi/installer'; import { ensureOpencodeInstalled, augmentOpencodePath } from '../opencode/installer'; +import { ensureAntigravityInstalled } from '../antigravity/installer'; import { isLocalSession } from '../../baton/gate'; // CommonJS module — `require` is already in scope. Aliased so we @@ -295,6 +296,28 @@ const REGISTRY: Partial AdapterSpec | null>> = { return waitForCommandOnPath('opencode', o); }, }), + // Antigravity CLI speaks ACP natively via `agy acp` (official since agy 1.5.0, + // 2026-11-04): protocolVersion 1, `loadSession: true`. Same AdapterSpec shape + // as gemini/cursor/kimi. Auth reaches agy through the files the provisioner + // writes under ~/.gemini/antigravity-cli/ (+ GEMINI_API_KEY via the ACP + // client's `env: { ...process.env, ...extraEnv }` spawn). + // + // ⚠️ NO `--dangerously-skip-permissions`: no other adapter skips permissions — + // agy sends normal `session/request_permission` requests and OUR runner + // answers them (auto-approve on codespace/self-hosted, the mobile prompt on a + // local pair; see `permission-gate.ts`), exactly as for every other agent. + antigravity: () => ({ + command: 'agy', + args: ['acp'], + requiresAgentBinary: 'agy', + waitForBinary: async (o) => { + if (isLocalSession()) await ensureAntigravityInstalled(); + // install.sh drops agy in ~/.local/bin — fold it into a stale PATH (an + // in-session switch installs mid-run). The box bakes /usr/local/bin/agy. + augmentUserLocalBinPaths(); + return waitForCommandOnPath('agy', o); + }, + }), }; /** @@ -348,7 +371,7 @@ export function getAcpAdapter(agent: AgentId): AdapterSpec | null { * Pure, STATIC dispatch predicate: does this agent run over ACP? * * `true` ⇒ ACP is the agent's ONLY launch path (claude / codex / - * gemini / cursor / kimi today). There is no env flag and no PTY + * gemini / cursor / kimi / opencode / antigravity today). There is no env flag and no PTY * fallback for these agents — the session must run over the typed * protocol or fail loudly. `false` ⇒ the agent has no ACP adapter and * runs over the legacy PTY runtime (aider, coderabbit). diff --git a/apps/cli/src/agents/acp/agent-binary.ts b/apps/cli/src/agents/acp/agent-binary.ts index 9176dce1..c6d061bd 100644 --- a/apps/cli/src/agents/acp/agent-binary.ts +++ b/apps/cli/src/agents/acp/agent-binary.ts @@ -312,6 +312,8 @@ export interface AugmentUserLocalBinPathsDeps { * (mirrors `kimi/installer.ts` `kimiBinDir`). * - opencode → `$OPENCODE_HOME/bin`, default `~/.opencode/bin` * (mirrors `opencode/installer.ts` `opencodeBinDir`). + * - antigravity → official install.sh → `~/.local/bin/agy` (box image: + * `/usr/local/bin/agy`, already on PATH). * - aider → pip `--user` → `~/.local/bin`. * - coderabbit → vendor script → `~/.local/bin`. */ diff --git a/apps/cli/src/agents/antigravity/installer.ts b/apps/cli/src/agents/antigravity/installer.ts new file mode 100644 index 00000000..01795695 --- /dev/null +++ b/apps/cli/src/agents/antigravity/installer.ts @@ -0,0 +1,62 @@ +/** + * Best-effort on-demand installer for Antigravity CLI (`agy`) — LOCAL pair + * parity with the codespace bootstrap (`INSTALL_SNIPPETS.antigravity`) and the + * baked box image (`/usr/local/bin/agy`). + * + * When a user runs `codeam pair` with Antigravity selected but `agy` isn't + * installed, the ACP adapter would otherwise spawn `agy acp` and fail with a raw + * `ENOENT`. Instead we run Google's official no-sudo installer + * (`curl -fsSL https://antigravity.google/cli/install.sh | bash`, which drops the + * binary in `~/.local/bin`), fold that dir into PATH, and re-probe — mirroring + * opencode's `ensureOpencodeInstalled`. + */ + +import { spawn, spawnSync } from 'node:child_process'; +import { log } from '../../services/logger'; +import { augmentUserLocalBinPaths } from '../acp/agent-binary'; + +export const ANTIGRAVITY_INSTALL_URL = 'https://antigravity.google/cli/install.sh'; + +/** True only when a runnable `agy` is on PATH — a `--version` that exits 0. */ +function agyRuns(): boolean { + const r = spawnSync('agy', ['--version'], { stdio: 'ignore', timeout: 15_000 }); + return !r.error && r.status === 0; +} + +async function runInstaller(): Promise { + return new Promise((resolve) => { + const proc = spawn('sh', ['-c', `curl -fsSL ${ANTIGRAVITY_INSTALL_URL} | bash`], { + stdio: 'inherit', + }); + proc.on('close', (code) => resolve(code === 0)); + proc.on('error', () => resolve(false)); + }); +} + +/** + * Ensure a runnable `agy` is on PATH, installing it on demand. Returns true when + * `agy` is (now) runnable, false when installation isn't possible/failed (the + * caller then surfaces the normal not-found error). Never throws. + */ +export async function ensureAntigravityInstalled(): Promise { + augmentUserLocalBinPaths(); // maybe already in ~/.local/bin, just off PATH + if (agyRuns()) return true; + + if (process.platform === 'win32') { + console.error( + '\n ✗ Antigravity CLI is not installed.\n' + + ' Install it from https://antigravity.google/cli (Windows installer),\n' + + ' then re-run `codeam pair`.\n', + ); + return false; + } + + console.log('\n Antigravity CLI not found — installing via the official script…\n'); + const ok = await runInstaller(); + augmentUserLocalBinPaths(); + if (!ok) { + log.warn('antigravity', 'installer failed — agy will be reported as not installed'); + return false; + } + return agyRuns(); +} diff --git a/apps/cli/src/agents/antigravity/local-token.ts b/apps/cli/src/agents/antigravity/local-token.ts new file mode 100644 index 00000000..9c439229 --- /dev/null +++ b/apps/cli/src/agents/antigravity/local-token.ts @@ -0,0 +1,86 @@ +/** + * Antigravity CLI (`agy`) on-disk auth layout — shared by `codeam link + * antigravity` (capture) and the self-hosted credential provisioner (write). + * + * agy keeps its state under `~/.gemini/antigravity-cli/`: + * - `antigravity-oauth-token` — the OAuth login state, a JSON blob + * `{"token":{"access_token","token_type","refresh_token","expiry"},"auth_method":"consumer"}` + * (`expiry` is RFC 3339). + * - `settings.json` — `modelProvider: "gemini"` switches agy to a Gemini API + * key read from `GEMINI_API_KEY`. The env var alone does nothing: without + * that setting agy still asks to sign in. + */ + +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import type { LocalAgentToken, LocalAgentTokenValidation } from '../strategy'; + +export function antigravityDir(home: string = os.homedir()): string { + return path.join(home, '.gemini', 'antigravity-cli'); +} + +export function antigravityOauthTokenPath(home: string = os.homedir()): string { + return path.join(antigravityDir(home), 'antigravity-oauth-token'); +} + +export function antigravitySettingsPath(home: string = os.homedir()): string { + return path.join(antigravityDir(home), 'settings.json'); +} + +export function antigravityCredentialsPaths(): string[] { + return [antigravityOauthTokenPath()]; +} + +/** The parts of the OAuth blob we check. */ +interface AntigravityOauthBlob { + token: { access_token: string; refresh_token?: string; expiry?: string }; +} + +function isRecord(v: unknown): v is Record { + return typeof v === 'object' && v !== null && !Array.isArray(v); +} + +/** Type guard for agy's OAuth login-state blob (needs a non-empty access_token). */ +export function isAntigravityOauthBlob(v: unknown): v is AntigravityOauthBlob { + if (!isRecord(v) || !isRecord(v.token)) return false; + const { access_token: accessToken } = v.token; + return typeof accessToken === 'string' && accessToken.length > 0; +} + +/** `codeam link antigravity`: the signed-in agy's OAuth file, else GEMINI_API_KEY. */ +export async function extractLocalAntigravityToken(): Promise { + const file = antigravityOauthTokenPath(); + if (fs.existsSync(file)) { + const credential = fs.readFileSync(file, 'utf8').trim(); + if (credential.length > 0) return { method: 'oauth', credential, source: 'flat-file' }; + } + const key = process.env.GEMINI_API_KEY?.trim(); + if (key) return { method: 'api_key', credential: key, source: 'flat-file' }; + return null; +} + +/** + * Refuse uploading a dead OAuth snapshot. The access token is short-lived but + * agy refreshes it from `refresh_token`, so a blob with a refresh token is + * usable regardless of `expiry`; only a refresh-less blob past its expiry is + * `expired`. API keys have no clock → `valid` when present. + */ +export function validateLocalAntigravityToken(token: LocalAgentToken): LocalAgentTokenValidation { + if (token.method !== 'oauth') { + return { status: token.credential.trim().length > 0 ? 'valid' : 'unknown' }; + } + let parsed: unknown; + try { + parsed = JSON.parse(token.credential); + } catch { + return { status: 'unknown' }; + } + if (!isAntigravityOauthBlob(parsed)) return { status: 'unknown' }; + if (parsed.token.refresh_token) return { status: 'valid' }; + const expiry = parsed.token.expiry ? Date.parse(parsed.token.expiry) : NaN; + if (Number.isFinite(expiry) && expiry <= Date.now()) { + return { status: 'expired', reason: 'Antigravity CLI session expired — run `agy` and sign in again.' }; + } + return { status: 'valid' }; +} diff --git a/apps/cli/src/agents/antigravity/runtime.ts b/apps/cli/src/agents/antigravity/runtime.ts new file mode 100644 index 00000000..5d5fefa0 --- /dev/null +++ b/apps/cli/src/agents/antigravity/runtime.ts @@ -0,0 +1,186 @@ +/** + * AntigravityRuntimeStrategy — RuntimeStrategy for Google's Antigravity CLI + * (`agy`), the consumer-account successor to Gemini CLI. + * + * Antigravity is driven EXCLUSIVELY via the ACP runtime (`agy acp`, official + * since agy 1.5.0) — see `apps/cli/src/agents/acp/adapters.ts`. + * `requiresAcp('antigravity')` always routes it through ACP; the PTY-spawn path + * is never reached. This strategy exists so the non-spawn code paths that touch + * every registered agent still resolve: + * + * - `codeam link antigravity` → `credentialLocator()` / `loginLauncher()`. + * - AI Insights / Summary + Preview detection → `generateOneShot()`. + * - The agent-contract suite iterates every runtime builder. + * + * Baton (Take Control) is not wired for v1 (`resolveHistoryFile` omitted → + * `runtimeSupportsBaton` is false), although agy's ACP does support + * `session/load`; wiring it needs agy's transcript layout first. + */ + +import { + getAgent, + type AgentId, + type AgentMetadata, + type AgentModel, + type NormalizedMessage, + type SelectPrompt, +} from '@codeam/shared'; +import { spawn, type ChildProcess } from 'node:child_process'; +import { spawnAndCapture } from '../../services/spawn-and-capture'; +import { createOsStrategy } from '../../os'; +import type { OsStrategy } from '../../os'; +import type { + AgentCredentialLocator, + AgentLoginLauncher, + ChangeModelInstruction, + RuntimeStrategy, +} from '../strategy'; +import { + antigravityCredentialsPaths, + extractLocalAntigravityToken, + validateLocalAntigravityToken, +} from './local-token'; +import { ANTIGRAVITY_INSTALL_URL, ensureAntigravityInstalled } from './installer'; + +/** One-shot budget when the caller passes none (preview detection is the slow one). */ +const DEFAULT_ONE_SHOT_TIMEOUT_MS = 120_000; +/** Kill grace past agy's own `--print-timeout`, so agy normally exits on its own. */ +const ONE_SHOT_KILL_GRACE_MS = 5_000; + +export class AntigravityRuntimeStrategy implements RuntimeStrategy { + readonly id: AgentId = 'antigravity'; + readonly meta: AgentMetadata = getAgent('antigravity'); + readonly mode = 'interactive' as const; + readonly os: OsStrategy; + + constructor(os: OsStrategy) { + this.os = os; + } + + async prepareLaunch(): Promise<{ + cmd: string; + args: string[]; + env?: Record; + sessionId?: string; + }> { + const binary = this.os.findInPath('agy'); + if (!binary) { + throw new Error( + 'Antigravity CLI (agy) is not on PATH. Install it with:\n' + + ` curl -fsSL ${ANTIGRAVITY_INSTALL_URL} | bash\n` + + ' Then run `codeam pair` again.', + ); + } + // Production never spawns agy over a PTY (ACP path); this satisfies the + // contract so `createInteractiveAgentStrategy` resolves cleanly. + const launch = this.os.buildLaunch(binary, []); + return { cmd: launch.cmd, args: launch.args }; + } + + resumeLaunchArgs(sessionId: string, _opts?: { auto?: boolean }): string[] { + return ['--conversation', sessionId]; + } + + resolveHistoryDir(_cwd: string): string | null { + return null; + } + + parseHistoryFile(_filePath: string): NormalizedMessage[] { + return []; + } + + getCurrentUsage( + _historyDir: string, + ): { used: number; total: number; percent: number; model?: string } | null { + return null; + } + + async fetchWeeklyUsage(): Promise<{ percent: number; resetAt?: string } | null> { + return null; + } + + async listModels(): Promise { + // agy picks its model from the signed-in account / settings.json; the ACP + // session reports the live list. A sentinel keeps the ≥1-model contract + // without claiming we route the model (Gemini-class 1M context window). + return [{ id: 'default', label: 'Account default', contextWindow: 1_000_000 }]; + } + + changeModelInstruction(modelId: string): ChangeModelInstruction { + return { type: 'pty', ptyInput: `/model ${modelId}\r` }; + } + + summarizeInstruction(_mode: 'normal' | 'auto'): { ptyInput: string } { + return { ptyInput: '/compact\r' }; + } + + filterTuiOutput(lines: string[]): string[] { + return lines; + } + + detectInteractivePrompt(_lines: string[]): SelectPrompt | null { + return null; + } + + /** + * Headless single prompt — powers AI summaries/insights AND Preview + * detection. `agy -p ""` prints the reply to stdout and exits. agy + * has no workspace-trust gate (no flag needed), but with no credentials it + * waits for a pasted sign-in code, and `--print-timeout` does NOT bound that + * wait (verified on agy 1.5.2: `--print-timeout 5s` signed out hung until + * killed). So `--print-timeout` bounds the turn and OUR kill timer + * (`spawnAndCapture`'s timeout) bounds the sign-in wait. `null` on + * failure/timeout/empty output so callers skip silently. + */ + async generateOneShot( + prompt: string, + opts?: { + cwd?: string; + timeoutMs?: number; + onStderr?: (chunk: string) => void; + onFailedOutput?: (stdout: string) => void; + }, + ): Promise { + const binary = this.os.findInPath('agy'); + if (!binary) return null; + const timeoutMs = opts?.timeoutMs ?? DEFAULT_ONE_SHOT_TIMEOUT_MS; + const printTimeoutSec = Math.max(1, Math.floor(timeoutMs / 1000)); + const launch = this.os.buildLaunch(binary, [ + '-p', + prompt, + '--print-timeout', + `${printTimeoutSec}s`, + ]); + return spawnAndCapture(launch.cmd, launch.args, { + onStderr: opts?.onStderr, + onFailedOutput: opts?.onFailedOutput, + cwd: opts?.cwd, + timeoutMs: timeoutMs + ONE_SHOT_KILL_GRACE_MS, + }); + } + + credentialLocator(): AgentCredentialLocator { + return { + publicId: 'antigravity', + vendor: 'Google', + hint: '~/.gemini/antigravity-cli/antigravity-oauth-token (sign in with `agy`) or GEMINI_API_KEY', + watchPaths: antigravityCredentialsPaths, + extract: extractLocalAntigravityToken, + validate: validateLocalAntigravityToken, + }; + } + + loginLauncher(): AgentLoginLauncher { + return { + async ensureInstalled(): Promise { + if (createOsStrategy().findInPath('agy') !== null) return true; + return ensureAntigravityInstalled(); + }, + launch(): ChildProcess { + // A bare `agy` opens the sign-in flow when signed out; inherited stdio + // lets the user finish it, after which the token file is captured. + return spawn('agy', [], { stdio: 'inherit' }); + }, + }; + } +} diff --git a/apps/cli/src/agents/registry.ts b/apps/cli/src/agents/registry.ts index 8f072bfe..7113e47b 100644 --- a/apps/cli/src/agents/registry.ts +++ b/apps/cli/src/agents/registry.ts @@ -10,6 +10,7 @@ import { AiderRuntimeStrategy } from './aider/runtime'; import { GeminiRuntimeStrategy } from './gemini/runtime'; import { KimiRuntimeStrategy } from './kimi/runtime'; import { OpencodeRuntimeStrategy } from './opencode/runtime'; +import { AntigravityRuntimeStrategy } from './antigravity/runtime'; import type { AgentStrategy, RuntimeStrategy, DeployStrategy } from './strategy'; /** @@ -37,6 +38,7 @@ const runtimeBuilders: Partial AgentStrategy gemini: (os) => new GeminiRuntimeStrategy(os), kimi: (os) => new KimiRuntimeStrategy(os), opencode: (os) => new OpencodeRuntimeStrategy(os), + antigravity: (os) => new AntigravityRuntimeStrategy(os), }; const deployBuilders: Partial DeployStrategy>> = { diff --git a/apps/cli/src/beads/provisioner.ts b/apps/cli/src/beads/provisioner.ts index ff4f73e3..bc7887bb 100644 --- a/apps/cli/src/beads/provisioner.ts +++ b/apps/cli/src/beads/provisioner.ts @@ -81,6 +81,8 @@ const AGENT_SETUP_RECIPE: Record = { coderabbit: null, // bd 1.0.5 has no opencode recipe → skip; beads still works. opencode: null, + // bd has no Antigravity recipe → skip; beads still works. + antigravity: null, }; export interface ProvisionOptions { diff --git a/apps/cli/src/commands/host/agent-provisioning.ts b/apps/cli/src/commands/host/agent-provisioning.ts index 4e97a2a2..69368b5a 100644 --- a/apps/cli/src/commands/host/agent-provisioning.ts +++ b/apps/cli/src/commands/host/agent-provisioning.ts @@ -33,6 +33,11 @@ import * as os from 'node:os'; import * as path from 'node:path'; import type { AgentAuth, AgentId } from '@codeam/shared'; import { restrictToOwner } from '../../lib/restrict-to-owner'; +import { + antigravityOauthTokenPath, + antigravitySettingsPath, + isAntigravityOauthBlob, +} from '../../agents/antigravity/local-token'; /** * Map the public LinkedAgent id the deploy command carries @@ -56,6 +61,7 @@ const PUBLIC_TO_INTERNAL_AGENT: Readonly> = { gemini: 'gemini', kimi: 'kimi', opencode: 'opencode', + antigravity: 'antigravity', }; /** Resolve a public LinkedAgent id to the internal agent id, or null. */ @@ -378,6 +384,59 @@ const kimiProvisioner: AgentProvisioner = { }, }; +/** agy's settings.json as an object; a missing or unparsable file → `{}`. */ +function readAntigravitySettings(file: string): Record { + try { + const parsed: unknown = JSON.parse(fs.readFileSync(file, 'utf8')); + if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) { + return { ...parsed }; + } + } catch { + // missing / not JSON → start from an empty object + } + return {}; +} + +const antigravityProvisioner: AgentProvisioner = { + write(auth, home): Record { + const tokenFile = antigravityOauthTokenPath(home); + const settingsFile = antigravitySettingsPath(home); + // settings.json may hold the user's own agy preferences — merge, never clobber. + const settings = readAntigravitySettings(settingsFile); + + if (auth.kind === 'api_key') { + // A Gemini API key. GEMINI_API_KEY alone does NOTHING (agy still asks to + // sign in): agy only takes the key when settings.json says + // `modelProvider: "gemini"`. Remove the OAuth login so it can't shadow + // the key on a re-provision that changed the auth kind. + rmIfExists(tokenFile); + writeFile0600(settingsFile, JSON.stringify({ ...settings, modelProvider: 'gemini' })); + return { GEMINI_API_KEY: auth.value }; + } + + // oauth_token → the backend-minted login state, written VERBATIM: + // {"token":{"access_token","token_type","refresh_token","expiry"},"auth_method":"consumer"}. + const value = auth.value.trim(); + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch { + throw new Error('antigravity oauth_token: credential blob is not valid JSON'); + } + if (!isAntigravityOauthBlob(parsed)) { + throw new Error('antigravity oauth_token: credential blob is missing token.access_token'); + } + writeFile0600(tokenFile, value); + // Drop a `modelProvider` a previous api_key provision left behind, or agy + // would keep routing to the (now absent) Gemini API key instead of the login. + if ('modelProvider' in settings) { + delete settings.modelProvider; + writeFile0600(settingsFile, JSON.stringify(settings)); + } + return {}; + }, +}; + const coderabbitProvisioner: AgentProvisioner = { write(auth, home): Record { const dir = path.join(home, '.coderabbit'); @@ -440,6 +499,7 @@ const PROVISIONERS: Partial> = { cursor: cursorProvisioner, coderabbit: coderabbitProvisioner, opencode: providerKeyProvisioner, + antigravity: antigravityProvisioner, // ⚠️ Was missing: every self-hosted / fleet-Box aider deploy failed with // "Self-hosted provisioning is not implemented for agent "aider"" (23 in // prod Cloud Run, 2026-09-10..10-08). diff --git a/packages/shared/__tests__/agents-install-snippets.test.ts b/packages/shared/__tests__/agents-install-snippets.test.ts index 2bcdabc4..22022121 100644 --- a/packages/shared/__tests__/agents-install-snippets.test.ts +++ b/packages/shared/__tests__/agents-install-snippets.test.ts @@ -21,11 +21,19 @@ const CREDENTIAL_WORD_RE = /(TOKEN|KEY|SECRET|PASSWORD|CREDENTIAL|API_?KEY|AUTH) describe('INSTALL_SNIPPETS', () => { it('has at least the installable agents the product ships', () => { - for (const id of ['codex', 'gemini', 'kimi', 'cursor', 'opencode'] as AgentId[]) { + for (const id of ['codex', 'gemini', 'kimi', 'cursor', 'opencode', 'antigravity'] as AgentId[]) { expect(INSTALL_SNIPPETS[id], `missing install snippet for ${id}`).toBeTruthy(); } }); + it('installs Antigravity CLI with the official no-sudo installer into ~/.local/bin', () => { + const snippet = INSTALL_SNIPPETS.antigravity ?? ''; + expect(snippet).toContain('command -v agy'); + expect(snippet).toContain('https://antigravity.google/cli/install.sh'); + expect(snippet).toContain('export PATH="$HOME/.local/bin:$PATH"'); + expect(snippet).not.toMatch(/\bsudo\b/); + }); + it('omits claude (its binary ships with the Agent SDK, not a separate installer)', () => { expect(INSTALL_SNIPPETS.claude).toBeUndefined(); }); @@ -96,6 +104,6 @@ describe('isNoopInstallSnippet / installableAgentIds', () => { it('installableAgentIds excludes coderabbit but includes the real installers', () => { const ids = installableAgentIds(); expect(ids).not.toContain('coderabbit'); - expect(ids).toEqual(expect.arrayContaining(['codex', 'gemini', 'kimi', 'cursor', 'opencode'])); + expect(ids).toEqual(expect.arrayContaining(['codex', 'gemini', 'kimi', 'cursor', 'opencode', 'antigravity'])); }); }); diff --git a/packages/shared/__tests__/agents.test.ts b/packages/shared/__tests__/agents.test.ts index 07f2bb74..60cb5614 100644 --- a/packages/shared/__tests__/agents.test.ts +++ b/packages/shared/__tests__/agents.test.ts @@ -49,6 +49,7 @@ describe('AGENT_REGISTRY', () => { const enabled = getEnabledAgents(); expect(enabled.map(a => a.id).sort()).toEqual([ 'aider', + 'antigravity', 'claude', 'coderabbit', 'codex', @@ -89,12 +90,9 @@ describe('AGENT_REGISTRY', () => { * after 2026-09-01 and the newest that same day — three months of sending * people through a bricked door. * - * ⚠️ Antigravity CLI is NOT a drop-in replacement for us: it has no official - * ACP mode (google-antigravity/antigravity-cli#31, still open, 192 comments, - * zero `acp` hits in the repo as of 2026-09-15) and every third-party adapter - * warns that driving `agy` that way matches what Google's FAQ calls a ToS - * violation, risking the USER's account. Our whole Gemini path is ACP. Revisit - * only when that issue ships. + * Consumer-Google users go to Antigravity CLI instead — its own agent since + * official ACP (`agy acp`) shipped in agy 1.5.0 (2026-11-04); see the + * `antigravity` suite below. */ describe('gemini: the retired OAuth tier is not offered', () => { it('accepts an API key and nothing else', () => { @@ -113,3 +111,23 @@ describe('gemini: the retired OAuth tier is not offered', () => { expect(AGENT_REGISTRY.gemini.enabled).toBe(true); }); }); + +describe('antigravity: Antigravity CLI over native ACP', () => { + const meta = AGENT_REGISTRY.antigravity; + + it('is registered under the owner-decided identity', () => { + expect(meta.id).toBe('antigravity'); + expect(meta.displayName).toBe('Antigravity CLI'); + expect(meta.binaryName).toBe('agy'); + expect(meta.enabled).toBe(true); + }); + + it('prefers the backend-minted OAuth blob and accepts a Gemini API key', () => { + expect(meta.supportedAuthKinds).toEqual(['oauth_token', 'api_key']); + expect(meta.preferredAuthKind).toBe('oauth_token'); + }); + + it('runs over ACP (agy acp)', () => { + expect(meta.acp).toBe(true); + }); +}); diff --git a/packages/shared/src/agents/identity.ts b/packages/shared/src/agents/identity.ts index ad39142d..15f54339 100644 --- a/packages/shared/src/agents/identity.ts +++ b/packages/shared/src/agents/identity.ts @@ -91,6 +91,7 @@ export type LinkedAgentId = | 'kimi' | 'openrouter' | 'opencode' + | 'antigravity' | typeof HOUSE_AGENT_ID | ManagedProviderId; @@ -104,6 +105,7 @@ export const LINKED_AGENT_IDS: readonly LinkedAgentId[] = [ 'kimi', 'openrouter', 'opencode', + 'antigravity', HOUSE_AGENT_ID, ...MANAGED_PROVIDER_IDS, ]; @@ -144,6 +146,9 @@ export const PUBLIC_TO_INTERNAL: Readonly< // opencode is a real, distinct agent (its own binary + ACP server) — it does // NOT map onto another runtime. Public id === internal id. opencode: 'opencode', + // Antigravity CLI (`agy`) is its own binary + native ACP server — public id + // === internal id, like opencode. + antigravity: 'antigravity', // OpenRouter runs Claude Code under the hood, pointed at OpenRouter's hosted // "Anthropic Skin" (https://openrouter.ai/api — native Anthropic Messages // protocol) with the user's vaulted `sk-or-…` key as ANTHROPIC_AUTH_TOKEN, @@ -177,6 +182,7 @@ export const INTERNAL_TO_PUBLIC: Readonly gemini: 'gemini', kimi: 'kimi', opencode: 'opencode', + antigravity: 'antigravity', }; function isPublicToInternalKey(v: string): v is LinkedAgentId | 'claude' | 'copilot' { diff --git a/packages/shared/src/agents/install-snippets.ts b/packages/shared/src/agents/install-snippets.ts index a03e1765..31a0340f 100644 --- a/packages/shared/src/agents/install-snippets.ts +++ b/packages/shared/src/agents/install-snippets.ts @@ -125,6 +125,21 @@ if ! command -v opencode >/dev/null 2>&1; then export PATH="$HOME/.opencode/bin:$PATH" fi`; +/** + * Antigravity CLI (`agy`). Official no-sudo installer (glibc + musl builds); + * drops the binary in ~/.local/bin, which is not on every default PATH — so + * extend PATH for the session AND persist it to ~/.bashrc, like cursor. Baked + * in the box image under /opt/agents/.local/bin → `command -v` short-circuits. + */ +const ANTIGRAVITY = ` +if ! command -v agy >/dev/null 2>&1; then + curl -fsSL https://antigravity.google/cli/install.sh | bash \\ + || { echo '[codeam:step] antigravity_install_retry' >&2; sleep 2; curl -fsSL https://antigravity.google/cli/install.sh | bash; } \\ + || { echo '[ERROR] Antigravity CLI install failed (antigravity.google/cli/install.sh)' >&2; exit 1; } + echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc + export PATH="$HOME/.local/bin:$PATH" +fi`; + /** * Baked in the image → `command -v` short-circuits. The guard only installs on * a bare box that somehow lacks it. Retry once (unguarded network pipe). @@ -160,6 +175,7 @@ export const INSTALL_SNIPPETS: Readonly>> = Obje kimi: KIMI, cursor: CURSOR, opencode: OPENCODE, + antigravity: ANTIGRAVITY, aider: AIDER, coderabbit: CODERABBIT, }); diff --git a/packages/shared/src/agents/registry.ts b/packages/shared/src/agents/registry.ts index 3f175a1d..17c4be18 100644 --- a/packages/shared/src/agents/registry.ts +++ b/packages/shared/src/agents/registry.ts @@ -108,13 +108,11 @@ export const AGENT_REGISTRY: Record = { // newest that same day. Three months of sending people through a bricked // door — each one landing on `isGeminiIneligibleTier` at first use. // - // ⚠️ Antigravity CLI is NOT a drop-in replacement for us: no official ACP - // mode (google-antigravity/antigravity-cli#31 open, 192 comments, zero - // `acp` hits in the repo as of 2026-09-15), and every third-party ACP - // adapter warns that driving `agy` that way matches what Google's FAQ calls - // a ToS violation — risking the USER's Google account. Our whole Gemini - // path is ACP, so adopting it would mean shipping that risk. Revisit only - // when #31 ships an official mode. + // Antigravity CLI is its own agent now (`antigravity` below): official ACP + // (`agy acp`) shipped in agy 1.5.0 (2026-11-04), closing + // google-antigravity/antigravity-cli#31 — the reason we held off (only + // third-party adapters existed, which Google's FAQ called a ToS risk) is + // gone. Consumer-Google users belong there; Gemini CLI stays api_key only. // // Existing OAuth rows are NOT migrated: `supportedAuthMethods` gates the // LINK call only, so the 46 stay readable and simply keep failing at the @@ -153,6 +151,21 @@ export const AGENT_REGISTRY: Record = { // Native ACP server: `opencode acp` (stdio JSON-RPC). acp: true, }, + antigravity: { + id: 'antigravity', + displayName: 'Antigravity CLI', + binaryName: 'agy', + enabled: true, + // Google's successor to Gemini CLI for consumer accounts (AI Pro / Ultra / + // free). `oauth_token` is the backend-minted blob (Antigravity's own OAuth + // client, redirect flow like Gemini's) written to + // ~/.gemini/antigravity-cli/antigravity-oauth-token; `api_key` is a Gemini + // API key (GEMINI_API_KEY + settings.json `modelProvider: "gemini"`). + supportedAuthKinds: ['oauth_token', 'api_key'], + preferredAuthKind: 'oauth_token', + // Native ACP server: `agy acp` (official since agy 1.5.0, 2026-11-04). + acp: true, + }, }; export function getEnabledAgents(): AgentMetadata[] { diff --git a/packages/shared/src/agents/types.ts b/packages/shared/src/agents/types.ts index 001ce4f5..204e3a1a 100644 --- a/packages/shared/src/agents/types.ts +++ b/packages/shared/src/agents/types.ts @@ -7,7 +7,8 @@ export type AgentId = | 'aider' | 'gemini' | 'kimi' - | 'opencode'; + | 'opencode' + | 'antigravity'; export type AgentAuthKind = 'oauth_token' | 'api_key' | 'setup_token';