diff --git a/__tests__/e2e/__snapshots__/happy-path.e2e.ts.snap b/__tests__/e2e/__snapshots__/happy-path.e2e.ts.snap index 91fa0d9..ed4ba45 100644 --- a/__tests__/e2e/__snapshots__/happy-path.e2e.ts.snap +++ b/__tests__/e2e/__snapshots__/happy-path.e2e.ts.snap @@ -79,11 +79,12 @@ exports[`happy-path flow > navigates Welcome → SystemCheck → Authenticate ────────────────────────────────────────────────────────────────────────────────────────────────── - Which agent tool are you using? + Which CLI agent would you like to use? ❯ Claude Code Cursor Codex + Antigravity Skip (install manually later) ↑↓ navigate enter confirm vX.Y.Z" diff --git a/__tests__/e2e/__snapshots__/stale-auth.e2e.ts.snap b/__tests__/e2e/__snapshots__/stale-auth.e2e.ts.snap index b84379c..c212f3b 100644 --- a/__tests__/e2e/__snapshots__/stale-auth.e2e.ts.snap +++ b/__tests__/e2e/__snapshots__/stale-auth.e2e.ts.snap @@ -13,11 +13,12 @@ exports[`when auth token is stale > allows re-authentication after expired token ────────────────────────────────────────────────────────────────────────────────────────────────── - Which agent tool are you using? + Which CLI agent would you like to use? ❯ Claude Code Cursor Codex + Antigravity Skip (install manually later) ↑↓ navigate enter confirm vX.Y.Z" diff --git a/__tests__/e2e/happy-path.e2e.ts b/__tests__/e2e/happy-path.e2e.ts index ed46b47..01880bf 100644 --- a/__tests__/e2e/happy-path.e2e.ts +++ b/__tests__/e2e/happy-path.e2e.ts @@ -29,7 +29,7 @@ describe('happy-path flow', () => { // InstallPlugins await session.waitForText('Teach your AI'); - await session.waitForText('Which agent tool are you using?'); + await session.waitForText('Which CLI agent would you like to use?'); expect(session.snapshot()).toMatchSnapshot('install-plugins'); session.checkpoint(); await session.sendKey(ENTER); diff --git a/__tests__/e2e/helpers/index.ts b/__tests__/e2e/helpers/index.ts index 5169830..524a3c4 100644 --- a/__tests__/e2e/helpers/index.ts +++ b/__tests__/e2e/helpers/index.ts @@ -96,7 +96,7 @@ export async function navigateToPlugins(session: TerminalSession): Promise await navigatePastWelcome(session); await navigatePastAuth(session); session.checkpoint(); - await session.waitForText('Which agent tool are you using?'); + await session.waitForText('Which CLI agent would you like to use?'); } export async function navigateToConnectTools(session: TerminalSession): Promise { diff --git a/__tests__/e2e/helpers/mock-antigravity.ts b/__tests__/e2e/helpers/mock-antigravity.ts new file mode 100644 index 0000000..ef22bd7 --- /dev/null +++ b/__tests__/e2e/helpers/mock-antigravity.ts @@ -0,0 +1,28 @@ +import { streamEventsSnippet } from './mock-streaming.js'; + +export const ANTIGRAVITY_SCRIPT = `#!/usr/bin/env node +const fs = require('fs'); +const path = require('path'); +const args = process.argv.slice(2); + +if (args[0] === '--version') { + process.stdout.write('1.0.0\\n'); + process.exit(0); +} + +if (args[0] === '-p') { + const prompt = args[1] || ''; + fs.writeFileSync( + path.join(process.cwd(), '.e2e-onboarding-invocation'), + JSON.stringify({ command: 'agy', args, prompt }), + 'utf-8' + ); +${streamEventsSnippet('antigravity')} +} else if (args[0] === '--prompt-interactive') { + const prompt = args[1] || ''; + fs.writeFileSync(path.join(process.cwd(), '.e2e-chat-prompt'), prompt, 'utf-8'); + process.exit(0); +} else { + process.exit(0); +} +`; diff --git a/__tests__/e2e/helpers/mock-binaries.ts b/__tests__/e2e/helpers/mock-binaries.ts index 8ad92ec..53260c8 100644 --- a/__tests__/e2e/helpers/mock-binaries.ts +++ b/__tests__/e2e/helpers/mock-binaries.ts @@ -3,6 +3,7 @@ import { join } from 'node:path'; import { CLAUDE_SCRIPT } from './mock-claude.js'; import { CURSOR_SCRIPT } from './mock-cursor.js'; import { CODEX_SCRIPT } from './mock-codex.js'; +import { ANTIGRAVITY_SCRIPT } from './mock-antigravity.js'; export const CHAT_PROMPT_FILE = '.e2e-chat-prompt'; export const ONBOARDING_INVOCATION_FILE = '.e2e-onboarding-invocation'; @@ -20,6 +21,7 @@ export function createMockBinDir(dir: string): string { writeMockBinary(binDir, 'claude', CLAUDE_SCRIPT); writeMockBinary(binDir, 'cursor', CURSOR_SCRIPT); writeMockBinary(binDir, 'codex', CODEX_SCRIPT); + writeMockBinary(binDir, 'agy', ANTIGRAVITY_SCRIPT); writeMockBinary(binDir, 'open', '#!/bin/sh\nexit 0\n'); return binDir; diff --git a/__tests__/e2e/helpers/mock-streaming.ts b/__tests__/e2e/helpers/mock-streaming.ts index bc65339..1fc6512 100644 --- a/__tests__/e2e/helpers/mock-streaming.ts +++ b/__tests__/e2e/helpers/mock-streaming.ts @@ -8,22 +8,30 @@ const STATUS_MESSAGES = [ 'STATUS: Generating CONFIDENCE_QUICKSTART.md report...', ]; -type EventFormat = 'claude' | 'codex'; +type EventFormat = 'claude' | 'codex' | 'antigravity'; function buildEvent(format: EventFormat, message: string, isLast: boolean): string { + if (format === 'antigravity') { + return JSON.stringify({ + type: 'step_update', + step_type: 'agent_response', + text_delta: message + '\n', + }); + } + if (format === 'codex') { return JSON.stringify({ type: 'item.completed', item: { type: 'agent_message', text: message }, }); } - if (isLast) { - return JSON.stringify({ type: 'result', result: message }); - } - return JSON.stringify({ - type: 'assistant', - message: { content: [{ type: 'text', text: message }] }, - }); + + return isLast + ? JSON.stringify({ type: 'result', result: message }) + : JSON.stringify({ + type: 'assistant', + message: { content: [{ type: 'text', text: message }] }, + }); } export function streamEventsSnippet(format: EventFormat): string { diff --git a/__tests__/e2e/onboarding-invocation.e2e.ts b/__tests__/e2e/onboarding-invocation.e2e.ts index f5ea059..5ad146d 100644 --- a/__tests__/e2e/onboarding-invocation.e2e.ts +++ b/__tests__/e2e/onboarding-invocation.e2e.ts @@ -43,6 +43,18 @@ const IDE_CASES = [ 'confidence-flags:', ], }, + { + name: 'Antigravity', + downPresses: 3, + command: 'agy', + expectedArgs: ['-p', '--output-format', 'stream-json'], + expectedPromptSnippets: [ + 'Confidence SDK', + '.agents/skills/analyze-project/SKILL.md', + 'existing codebase', + 'confidence-flags_', + ], + }, ] as const; describe.each(IDE_CASES)('$name onboarding invocation', (ide) => { diff --git a/__tests__/e2e/skip-plugins.e2e.ts b/__tests__/e2e/skip-plugins.e2e.ts index ad26041..b82170d 100644 --- a/__tests__/e2e/skip-plugins.e2e.ts +++ b/__tests__/e2e/skip-plugins.e2e.ts @@ -7,7 +7,7 @@ describe('when the user skips installing AI plugin', () => { await navigateToPlugins(session); // Select "Skip (install manually later)" — 4th option - await session.sendKeyRepeat(ARROW_DOWN, 3); + await session.sendKeyRepeat(ARROW_DOWN, 4); await session.sendKey(ENTER); // ConnectTools @@ -37,7 +37,7 @@ describe('when the user skips installing AI plugin', () => { await navigateToPlugins(session); // Skip plugins - await session.sendKeyRepeat(ARROW_DOWN, 3); + await session.sendKeyRepeat(ARROW_DOWN, 4); await session.sendKey(ENTER); // Connect + onboard diff --git a/__tests__/e2e/stale-auth.e2e.ts b/__tests__/e2e/stale-auth.e2e.ts index 11c9811..1e4a2c7 100644 --- a/__tests__/e2e/stale-auth.e2e.ts +++ b/__tests__/e2e/stale-auth.e2e.ts @@ -44,7 +44,7 @@ describe('when auth token is stale', () => { await session.waitForText('Authenticated'); // Continues to InstallPlugins - await session.waitForText('Which agent tool are you using?'); + await session.waitForText('Which CLI agent would you like to use?'); expect(session.snapshot()).toMatchSnapshot('auth-re-authenticated'); }); diff --git a/__tests__/e2e/stale-mcp-auth.e2e.ts b/__tests__/e2e/stale-mcp-auth.e2e.ts index 67c3714..c90b744 100644 --- a/__tests__/e2e/stale-mcp-auth.e2e.ts +++ b/__tests__/e2e/stale-mcp-auth.e2e.ts @@ -48,7 +48,7 @@ describe('when MCP config has expired auth tokens', () => { await navigatePastAuth(session); // InstallPlugins - await session.waitForText('Which agent tool are you using?'); + await session.waitForText('Which CLI agent would you like to use?'); await session.sendKey(ENTER); // ConnectTools — should detect expired auth @@ -71,7 +71,7 @@ describe('when MCP config has expired auth tokens', () => { await navigatePastAuth(session); // InstallPlugins - await session.waitForText('Which agent tool are you using?'); + await session.waitForText('Which CLI agent would you like to use?'); await session.sendKey(ENTER); // ConnectTools — skip instead of reconnecting diff --git a/__tests__/ui/screens/ConnectToolsScreen.auth.test.tsx b/__tests__/ui/screens/ConnectToolsScreen.auth.test.tsx index 31a3271..48f9ea4 100644 --- a/__tests__/ui/screens/ConnectToolsScreen.auth.test.tsx +++ b/__tests__/ui/screens/ConnectToolsScreen.auth.test.tsx @@ -213,6 +213,10 @@ function writeMcpConfig(projectDir: string, ide: ChosenIde, opts?: McpConfigOpts case 'codex': return writeCodexMcpConfig(projectDir); + case 'antigravity': + mkdirSync(join(projectDir, '.agents'), { recursive: true }); + return writeAntigravityMcpConfig(join(projectDir, '.agents', 'mcp_config.json'), opts); + default: { const _exhaustive: never = ide satisfies never; throw new Error(`Unhandled IDE: ${_exhaustive}`); @@ -239,6 +243,25 @@ function writeJsonMcpConfig(configPath: string, opts?: McpConfigOpts): void { ); } +function writeAntigravityMcpConfig(configPath: string, opts?: McpConfigOpts): void { + writeFileSync( + configPath, + JSON.stringify({ + mcpServers: Object.fromEntries( + Object.entries(MCP_SERVERS).map(([name, { url }]) => { + const server: Record = { serverUrl: url }; + + if (opts?.token) { + server.headers = { Authorization: `Bearer ${opts.token}` }; + } + + return [name, server]; + }), + ), + }), + ); +} + function writeCodexMcpConfig(projectDir: string): void { const content = (Object.keys(MCP_SERVERS) as McpServerName[]) .map((name) => `[mcp_servers.${name}]\nurl = "${MCP_SERVERS[name].url}"`) diff --git a/__tests__/ui/screens/InstallPluginsScreen.test.tsx b/__tests__/ui/screens/InstallPluginsScreen.test.tsx index c22b4e6..665a692 100644 --- a/__tests__/ui/screens/InstallPluginsScreen.test.tsx +++ b/__tests__/ui/screens/InstallPluginsScreen.test.tsx @@ -110,6 +110,24 @@ describe('InstallPluginsScreen', () => { }); }); + it('shows continue option for each detected IDE when multiple plugins installed', async () => { + const { detectInstalledPlugins } = await import('../../../src/integrations/plugins.js'); + vi.mocked(detectInstalledPlugins).mockReturnValueOnce(['codex', 'antigravity']); + + using sut = renderApp({ screen: ScreenId.InstallPlugins }); + + await waitFor(() => { + expect(sut.lastFrame()).toContain('Continue with Codex'); + expect(sut.lastFrame()).toContain('Continue with Antigravity'); + }); + + sut.stdin.write(ARROW_DOWN + ENTER); + + await waitFor(() => { + expect(sut.lastFrame()).toContain('Sign in to Confidence'); + }); + }); + it('shows error and retry option on install failure', async () => { server.use( http.get( diff --git a/scripts/clean-dev-env.sh b/scripts/clean-dev-env.sh index e3a7f09..1707ced 100755 --- a/scripts/clean-dev-env.sh +++ b/scripts/clean-dev-env.sh @@ -180,6 +180,12 @@ if $clean_mcp; then fi done + # Clean Antigravity MCP config (JSON format) — both project-level and global + agy_configs=("$PROJECT_DIR/.agents/mcp_config.json" "$HOME/.gemini/config/mcp_config.json") + for agy_config in "${agy_configs[@]}"; do + remove_all_mcp_entries "$agy_config" + done + # --- MCP tool permissions from .claude/settings*.json --- remove_mcp_settings() { @@ -200,6 +206,9 @@ if $clean_mcp; then remove_mcp_settings "$PROJECT_DIR/.claude/settings.json" remove_mcp_settings "$PROJECT_DIR/.claude/settings.local.json" + + # Clean Antigravity settings/permissions + remove_mcp_settings "$HOME/.gemini/antigravity-cli/settings.json" fi # --- Summary --- diff --git a/src/integrations/antigravity/chat.ts b/src/integrations/antigravity/chat.ts new file mode 100644 index 0000000..d7310e4 --- /dev/null +++ b/src/integrations/antigravity/chat.ts @@ -0,0 +1,13 @@ +import { spawn } from 'node:child_process'; +import type { ChatOpts } from '../types.js'; + +export function launchChat({ prompt, cwd, token }: ChatOpts): void { + const env = token ? { ...globalThis.process.env, CONFIDENCE_ACCESS_TOKEN: token } : undefined; + + spawn('agy', ['--prompt-interactive', prompt], { + cwd, + stdio: 'inherit', + detached: false, + env, + }); +} diff --git a/src/integrations/antigravity/index.ts b/src/integrations/antigravity/index.ts new file mode 100644 index 0000000..bfc6195 --- /dev/null +++ b/src/integrations/antigravity/index.ts @@ -0,0 +1,19 @@ +import type { IdeIntegration } from '../types.js'; +import { launchChat } from './chat.js'; +import { detectMcpStatuses, connectMcpServer } from './mcp.js'; +import { runOnboarding } from './onboarding.js'; +import { detectPlugins, installPlugins } from './plugins.js'; +import { prepare } from './prepare.js'; + +export const antigravityIntegration: IdeIntegration = { + id: 'antigravity', + name: 'Antigravity', + + launchChat, + runOnboarding, + prepare, + detectPlugins, + installPlugins, + detectMcpStatuses, + connectMcpServer, +}; diff --git a/src/integrations/antigravity/mcp.ts b/src/integrations/antigravity/mcp.ts new file mode 100644 index 0000000..1c5f592 --- /dev/null +++ b/src/integrations/antigravity/mcp.ts @@ -0,0 +1,83 @@ +import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'; +import { join } from 'node:path'; +import type { McpConnectOpts } from '../types.js'; +import { + type McpServerName, + type McpServerStatus, + detectMcpStatuses as detectShared, +} from '../mcp/servers.js'; +import { getRegisteredMcpNames, getStoredAuthToken } from '../mcp/config.js'; +import { globalMcpConfigPath, projectMcpConfigPath, settingsPath } from './paths.js'; + +export function detectMcpStatuses( + projectDir: string, +): Promise> { + return detectShared({ + getRegisteredNames: () => { + const global = getRegisteredMcpNames(globalMcpConfigPath()); + const project = getRegisteredMcpNames(projectMcpConfigPath(projectDir)); + return [...new Set([...global, ...project])]; + }, + getAuthToken: (name) => + getStoredAuthToken(globalMcpConfigPath(), name) ?? + getStoredAuthToken(projectMcpConfigPath(projectDir), name), + }); +} + +export async function connectMcpServer(opts: McpConnectOpts): Promise { + const headers: Record = { ...opts.serverHeaders }; + if (opts.accessToken) { + headers['Authorization'] = `Bearer ${opts.accessToken}`; + } + + const entry = { serverUrl: opts.serverUrl, headers }; + + writeMcpEntry(projectMcpConfigPath(opts.projectDir), opts.serverName, entry); + writeMcpEntry(globalMcpConfigPath(), opts.serverName, entry); + writeMcpPermission(settingsPath(), opts.serverName); +} + +function writeMcpEntry(configPath: string, serverName: string, entry: unknown): void { + let config: Record = {}; + if (existsSync(configPath)) { + try { + config = JSON.parse(readFileSync(configPath, 'utf-8')) as Record; + } catch { + // overwrite if corrupt + } + } else { + mkdirSync(join(configPath, '..'), { recursive: true }); + } + + const mcpServers = (config.mcpServers ?? {}) as Record; + mcpServers[serverName] = entry; + config.mcpServers = mcpServers; + + writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', 'utf-8'); +} + +function writeMcpPermission(path: string, serverName: string): void { + let settings: Record = {}; + if (existsSync(path)) { + try { + settings = JSON.parse(readFileSync(path, 'utf-8')) as Record; + } catch { + // overwrite if corrupt + } + } else { + mkdirSync(join(path, '..'), { recursive: true }); + } + + const permissions = (settings.permissions ?? {}) as Record; + const allow = (permissions.allow ?? []) as string[]; + const rule = `mcp(${serverName}/*)`; + + if (!allow.includes(rule)) { + allow.push(rule); + } + + permissions.allow = allow; + settings.permissions = permissions; + + writeFileSync(path, JSON.stringify(settings, null, 2) + '\n', 'utf-8'); +} diff --git a/src/integrations/antigravity/onboarding.ts b/src/integrations/antigravity/onboarding.ts new file mode 100644 index 0000000..e4cb4ec --- /dev/null +++ b/src/integrations/antigravity/onboarding.ts @@ -0,0 +1,103 @@ +import { type ChildProcess, spawn } from 'node:child_process'; +import { createInterface } from 'node:readline'; +import type { OnboardingOpts, OnboardingCallbacks } from '../types.js'; +import { ONBOARDING_TIMEOUT_MS, STATUS_PREFIX } from '../constants.js'; +import { spawnErrorMessage } from '../utils.js'; + +type AgyStreamEvent = { + type: string; + step_type?: string; + text_delta?: string; +}; + +export function runOnboarding( + opts: OnboardingOpts, + callbacks: OnboardingCallbacks, +): ChildProcess | null { + const env = opts.token + ? { ...globalThis.process.env, CONFIDENCE_ACCESS_TOKEN: opts.token } + : undefined; + + let child: ChildProcess; + try { + child = spawn('agy', ['-p', opts.prompt, '--output-format', 'stream-json'], { + cwd: opts.projectDir, + timeout: ONBOARDING_TIMEOUT_MS, + stdio: ['ignore', 'pipe', 'pipe'], + env, + }); + } catch (err) { + callbacks.onError(spawnErrorMessage('agy', err as NodeJS.ErrnoException)); + return null; + } + + if (!child.stdout) return null; + + const allLines: string[] = []; + let stderrBuf = ''; + let buffer = ''; + + function emitCompletedLines() { + const parts = buffer.split('\n'); + buffer = parts.pop()!; + for (const part of parts) { + const stripped = part.trim(); + if (!stripped) continue; + allLines.push(stripped); + callbacks.onStdout(stripped); + if (stripped.startsWith(STATUS_PREFIX)) { + callbacks.onStatus(stripped.slice(STATUS_PREFIX.length)); + } + } + } + + const rl = createInterface({ input: child.stdout }); + rl.on('line', function parseStreamEvent(line: string) { + const trimmed = line.trim(); + if (!trimmed) return; + + let event: AgyStreamEvent; + try { + event = JSON.parse(trimmed) as AgyStreamEvent; + } catch { + return; + } + + if (event.type === 'step_update' && event.step_type === 'agent_response' && event.text_delta) { + buffer += event.text_delta; + emitCompletedLines(); + } + }); + + child.stderr?.on('data', (chunk: Buffer) => { + stderrBuf += chunk.toString(); + const trimmed = chunk.toString().trim(); + if (trimmed) callbacks.onStderr(trimmed); + }); + + child.on('close', (code: number | null) => { + rl.close(); + + const remaining = buffer.trim(); + if (remaining) { + allLines.push(remaining); + callbacks.onStdout(remaining); + if (remaining.startsWith(STATUS_PREFIX)) { + callbacks.onStatus(remaining.slice(STATUS_PREFIX.length)); + } + } + + if (code !== 0) { + callbacks.onError(stderrBuf.trim() || `Process exited with code ${code}`); + return; + } + callbacks.onComplete(allLines); + }); + + child.on('error', (err: NodeJS.ErrnoException) => { + rl.close(); + callbacks.onError(spawnErrorMessage('agy', err)); + }); + + return child; +} diff --git a/src/integrations/antigravity/paths.ts b/src/integrations/antigravity/paths.ts new file mode 100644 index 0000000..2b0d4d1 --- /dev/null +++ b/src/integrations/antigravity/paths.ts @@ -0,0 +1,18 @@ +import { join } from 'node:path'; +import { homedir } from 'node:os'; + +export function globalMcpConfigPath(): string { + return join(homedir(), '.gemini', 'config', 'mcp_config.json'); +} + +export function projectMcpConfigPath(projectDir: string): string { + return join(projectDir, '.agents', 'mcp_config.json'); +} + +export function settingsPath(): string { + return join(homedir(), '.gemini', 'antigravity-cli', 'settings.json'); +} + +export function skillsDir(projectDir: string): string { + return join(projectDir, '.agents', 'skills'); +} diff --git a/src/integrations/antigravity/plugins.ts b/src/integrations/antigravity/plugins.ts new file mode 100644 index 0000000..546002c --- /dev/null +++ b/src/integrations/antigravity/plugins.ts @@ -0,0 +1,17 @@ +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { PLUGIN_SKILLS, installSkills } from '../shared.js'; +import { skillsDir } from './paths.js'; + +export function detectPlugins(projectDir: string): boolean { + return hasSkillFiles(projectDir); +} + +export async function installPlugins(projectDir: string): Promise { + await installSkills(skillsDir(projectDir)); +} + +function hasSkillFiles(projectDir: string): boolean { + const dir = skillsDir(projectDir); + return PLUGIN_SKILLS.some((name) => existsSync(join(dir, name, 'SKILL.md'))); +} diff --git a/src/integrations/antigravity/prepare.ts b/src/integrations/antigravity/prepare.ts new file mode 100644 index 0000000..add6900 --- /dev/null +++ b/src/integrations/antigravity/prepare.ts @@ -0,0 +1,22 @@ +import { execFile as execFileCb } from 'node:child_process'; +import { promisify } from 'node:util'; + +const execFile = promisify(execFileCb); + +export async function prepare(): Promise { + try { + await execFile('agy', ['--version']); + } catch { + throw new Error( + 'Antigravity CLI not found. Install it from: https://antigravity.google/docs/cli/install', + ); + } + + try { + await execFile('agy', ['models']); + } catch { + throw new Error( + 'Not logged in to Antigravity. Please, run `agy` once interactively to authenticate.', + ); + } +} diff --git a/src/integrations/claude/onboarding.ts b/src/integrations/claude/onboarding.ts index d15fd24..39b8951 100644 --- a/src/integrations/claude/onboarding.ts +++ b/src/integrations/claude/onboarding.ts @@ -1,7 +1,7 @@ import { type ChildProcess, spawn } from 'node:child_process'; import { createInterface } from 'node:readline'; import type { OnboardingOpts, OnboardingCallbacks } from '../types.js'; -import { STATUS_PREFIX } from '../constants.js'; +import { ONBOARDING_TIMEOUT_MS, STATUS_PREFIX } from '../constants.js'; import { type StreamEvent, extractTextLines } from '../stream-json.js'; import { spawnErrorMessage } from '../utils.js'; @@ -20,7 +20,7 @@ export function runOnboarding( ['--print', '--output-format', 'stream-json', '--verbose', opts.prompt], { cwd: opts.projectDir, - timeout: 600_000, + timeout: ONBOARDING_TIMEOUT_MS, stdio: ['ignore', 'pipe', 'pipe'], env, }, diff --git a/src/integrations/codex/onboarding.ts b/src/integrations/codex/onboarding.ts index f231aca..7e56022 100644 --- a/src/integrations/codex/onboarding.ts +++ b/src/integrations/codex/onboarding.ts @@ -1,7 +1,7 @@ import { type ChildProcess, spawn } from 'node:child_process'; import { createInterface } from 'node:readline'; import type { OnboardingOpts, OnboardingCallbacks } from '../types.js'; -import { STATUS_PREFIX } from '../constants.js'; +import { ONBOARDING_TIMEOUT_MS, STATUS_PREFIX } from '../constants.js'; import { spawnErrorMessage } from '../utils.js'; type CodexEvent = { @@ -24,7 +24,7 @@ export function runOnboarding( try { child = spawn('codex', ['exec', '--json', '--sandbox', 'workspace-write', '-'], { cwd: opts.projectDir, - timeout: 600_000, + timeout: ONBOARDING_TIMEOUT_MS, stdio: ['pipe', 'pipe', 'pipe'], env, }); diff --git a/src/integrations/constants.ts b/src/integrations/constants.ts index 967b2d2..70014b2 100644 --- a/src/integrations/constants.ts +++ b/src/integrations/constants.ts @@ -1 +1,2 @@ export const STATUS_PREFIX = 'STATUS: '; +export const ONBOARDING_TIMEOUT_MS = 600_000; diff --git a/src/integrations/cursor/onboarding.ts b/src/integrations/cursor/onboarding.ts index fdc749f..e53d5a1 100644 --- a/src/integrations/cursor/onboarding.ts +++ b/src/integrations/cursor/onboarding.ts @@ -1,6 +1,7 @@ import { type ChildProcess, spawn } from 'node:child_process'; import { createInterface } from 'node:readline'; import type { OnboardingOpts, OnboardingCallbacks } from '../types.js'; +import { ONBOARDING_TIMEOUT_MS } from '../constants.js'; import { type StreamEvent, extractTextLines } from '../stream-json.js'; import { normalizeStatusLine, spawnErrorMessage } from '../utils.js'; @@ -25,7 +26,7 @@ export function runOnboarding( try { child = spawn('cursor', args, { cwd: opts.projectDir, - timeout: 600_000, + timeout: ONBOARDING_TIMEOUT_MS, stdio: ['ignore', 'pipe', 'pipe'], env, }); diff --git a/src/integrations/registry.ts b/src/integrations/registry.ts index 1be08c7..d1d171c 100644 --- a/src/integrations/registry.ts +++ b/src/integrations/registry.ts @@ -3,8 +3,14 @@ import type { IdeIntegration } from './types.js'; import { claudeIntegration } from './claude/index.js'; import { cursorIntegration } from './cursor/index.js'; import { codexIntegration } from './codex/index.js'; +import { antigravityIntegration } from './antigravity/index.js'; -const INTEGRATIONS: IdeIntegration[] = [claudeIntegration, cursorIntegration, codexIntegration]; +const INTEGRATIONS: IdeIntegration[] = [ + claudeIntegration, + cursorIntegration, + codexIntegration, + antigravityIntegration, +]; export function getIntegrations(): IdeIntegration[] { return INTEGRATIONS; diff --git a/src/integrations/types.ts b/src/integrations/types.ts index 7e4b7f9..b3293a5 100644 --- a/src/integrations/types.ts +++ b/src/integrations/types.ts @@ -1,7 +1,7 @@ import type { ChildProcess } from 'node:child_process'; import type { McpServerName, McpServerStatus } from './mcp/servers.js'; -export type IdeId = 'claude' | 'cursor' | 'codex'; +export type IdeId = 'claude' | 'cursor' | 'codex' | 'antigravity'; export type McpConnectOpts = { serverName: string; diff --git a/src/lib/onboarding-prompt/integrate.ts b/src/lib/onboarding-prompt/integrate.ts index 948ad15..d1f8669 100644 --- a/src/lib/onboarding-prompt/integrate.ts +++ b/src/lib/onboarding-prompt/integrate.ts @@ -6,6 +6,7 @@ const SKILLS_DIR: Record = { claude: '.claude/skills', cursor: '.cursor/skills', codex: '.agents/skills', + antigravity: '.agents/skills', }; export function integrateViaSkill( diff --git a/src/lib/onboarding-prompt/tool-vars.ts b/src/lib/onboarding-prompt/tool-vars.ts index 111fbff..67a7c2a 100644 --- a/src/lib/onboarding-prompt/tool-vars.ts +++ b/src/lib/onboarding-prompt/tool-vars.ts @@ -6,6 +6,7 @@ const TOOL_FORMATTERS: Record = { claude: (server, tool) => `mcp__${server}__${tool}`, codex: (server, tool) => `${server}:${tool}`, cursor: (server, tool) => `mcp__${server}__${tool}`, + antigravity: (server, tool) => `${server}_${tool}`, }; export function buildToolVars(ide: ChosenIde): Record { diff --git a/src/lib/session.ts b/src/lib/session.ts index 0f85bf8..c29d1b9 100644 --- a/src/lib/session.ts +++ b/src/lib/session.ts @@ -1,7 +1,7 @@ import { randomUUID } from 'node:crypto'; import type { DetectedProvider } from '@providers/types.js'; -export type ChosenIde = 'claude' | 'cursor' | 'codex'; +export type ChosenIde = 'claude' | 'cursor' | 'codex' | 'antigravity'; export type OnboardingGoal = 'feature-flags' | 'session-recording' | 'all'; diff --git a/src/ui/tui/lib/format.ts b/src/ui/tui/lib/format.ts new file mode 100644 index 0000000..4520dad --- /dev/null +++ b/src/ui/tui/lib/format.ts @@ -0,0 +1,5 @@ +export function formatList(items: string[]): string { + if (items.length <= 1) return items[0] ?? ''; + if (items.length === 2) return `${items[0]} and ${items[1]}`; + return `${items.slice(0, -1).join(', ')}, and ${items.at(-1)}`; +} diff --git a/src/ui/tui/screens/install-plugins/InstallPluginsScreen.tsx b/src/ui/tui/screens/install-plugins/InstallPluginsScreen.tsx index c9f8092..77de7e1 100644 --- a/src/ui/tui/screens/install-plugins/InstallPluginsScreen.tsx +++ b/src/ui/tui/screens/install-plugins/InstallPluginsScreen.tsx @@ -5,6 +5,7 @@ import { PromptPanel } from '../../components/PromptPanel.js'; import { TwoColumnLayout } from '../../components/TwoColumnLayout.js'; import { TaskList } from '../../components/TaskList.js'; import { buildWizardTasks } from '../../lib/wizard-tasks.js'; +import { formatList } from '../../lib/format.js'; import { type IdeId, getIntegrations } from '@integrations/index.js'; import { PLUGINS_REPO_URL } from '@lib/constants.js'; import { ScreenId } from '@lib/session.js'; @@ -22,9 +23,9 @@ import { } from './log-messages.js'; import * as te from './telemetry-events.js'; -const ALL_INTEGRATIONS = getIntegrations(); +const INTEGRATIONS = getIntegrations(); -const IDE_LABELS = Object.fromEntries(ALL_INTEGRATIONS.map((i) => [i.id, i.name])) as Record< +const IDE_LABELS = Object.fromEntries(INTEGRATIONS.map((i) => [i.id, i.name])) as Record< IdeId, string >; @@ -55,11 +56,11 @@ export function InstallPluginsScreen() { selectIde(value as IdeId); } - const preferredIndex = ALL_INTEGRATIONS.map((i) => i.id).find((id) => detected.includes(id)); + const detectedIdes = new Set(detected); function handleDetectedSelect(value: string) { - if (value === 'continue' && preferredIndex) { - store.setIde(preferredIndex); + if (detectedIdes.has(value)) { + store.setIde(value as IdeId); log(pluginsAlreadyInstalled(detected)); track(te.pluginsAlreadyDetected()); navigate.to('next'); @@ -141,16 +142,18 @@ export function InstallPluginsScreen() { return ( ); + case 'error': return ( ); + case 'already-installed': { - const preferredLabel = preferredIndex ? IDE_LABELS[preferredIndex] : null; - const otherOptions = ALL_INTEGRATIONS.filter((i) => i.id !== preferredIndex).map((i) => ({ + const detected = INTEGRATIONS.filter((i) => detectedIdes.has(i.id)); + const others = INTEGRATIONS.filter((i) => !detectedIdes.has(i.id)); + + const detectedOptions = detected.map((i) => ({ + label: `Continue with ${i.name}`, + value: i.id, + })); + + const otherOptions = others.map((i) => ({ label: i.name, value: i.id, })); + const detectedNames = formatList(detected.map((i) => i.name)); + const statusContext = `Confidence plugin detected for ${detectedNames}.`; + const statusPrompt = + detectedOptions.length === 1 + ? 'Continue with this agent tool?' + : 'Continue with one of these?'; + return ( ); } + case 'detecting': case 'installing': case 'installed': return null; + default: { const _exhaustive: never = phase satisfies never; throw new Error(`Unhandled phase: ${_exhaustive}`); diff --git a/src/ui/tui/screens/onboard-project/OnboardProjectScreen.tsx b/src/ui/tui/screens/onboard-project/OnboardProjectScreen.tsx index d1d9f42..10e6891 100644 --- a/src/ui/tui/screens/onboard-project/OnboardProjectScreen.tsx +++ b/src/ui/tui/screens/onboard-project/OnboardProjectScreen.tsx @@ -6,6 +6,7 @@ import { TwoColumnLayout } from '../../components/TwoColumnLayout.js'; import { TaskList } from '../../components/TaskList.js'; import { StatusFeed } from '../../components/StatusFeed.js'; import { buildWizardTasks } from '../../lib/wizard-tasks.js'; +import { formatList } from '../../lib/format.js'; import { SDK_OPTIONS } from '@lib/sdk-options.js'; import { useTipRotation } from '../../hooks/useTipRotation.js'; import { TipCard } from '../../components/TipCard.js'; @@ -254,7 +255,5 @@ function migrationOptions(providers: DetectedProvider[]) { } function formatNames(providers: DetectedProvider[]): string { - const names = providers.map((c) => c.name); - if (names.length === 1) return names[0]; - return `${names.slice(0, -1).join(', ')} and ${names.at(-1)}`; + return formatList(providers.map((c) => c.name)); }