Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion __tests__/e2e/__snapshots__/happy-path.e2e.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
3 changes: 2 additions & 1 deletion __tests__/e2e/__snapshots__/stale-auth.e2e.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion __tests__/e2e/happy-path.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion __tests__/e2e/helpers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ export async function navigateToPlugins(session: TerminalSession): Promise<void>
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<void> {
Expand Down
28 changes: 28 additions & 0 deletions __tests__/e2e/helpers/mock-antigravity.ts
Original file line number Diff line number Diff line change
@@ -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);
}
`;
2 changes: 2 additions & 0 deletions __tests__/e2e/helpers/mock-binaries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;
Expand Down
24 changes: 16 additions & 8 deletions __tests__/e2e/helpers/mock-streaming.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
12 changes: 12 additions & 0 deletions __tests__/e2e/onboarding-invocation.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
4 changes: 2 additions & 2 deletions __tests__/e2e/skip-plugins.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion __tests__/e2e/stale-auth.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});

Expand Down
4 changes: 2 additions & 2 deletions __tests__/e2e/stale-mcp-auth.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
23 changes: 23 additions & 0 deletions __tests__/ui/screens/ConnectToolsScreen.auth.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
Expand All @@ -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<string, unknown> = { 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}"`)
Expand Down
18 changes: 18 additions & 0 deletions __tests__/ui/screens/InstallPluginsScreen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
9 changes: 9 additions & 0 deletions scripts/clean-dev-env.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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 ---
Expand Down
13 changes: 13 additions & 0 deletions src/integrations/antigravity/chat.ts
Original file line number Diff line number Diff line change
@@ -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,
});
}
19 changes: 19 additions & 0 deletions src/integrations/antigravity/index.ts
Original file line number Diff line number Diff line change
@@ -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,
};
83 changes: 83 additions & 0 deletions src/integrations/antigravity/mcp.ts
Original file line number Diff line number Diff line change
@@ -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<Record<McpServerName, McpServerStatus>> {
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<void> {
const headers: Record<string, string> = { ...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<string, unknown> = {};
if (existsSync(configPath)) {
try {
config = JSON.parse(readFileSync(configPath, 'utf-8')) as Record<string, unknown>;
} catch {
// overwrite if corrupt
}
} else {
mkdirSync(join(configPath, '..'), { recursive: true });
}

const mcpServers = (config.mcpServers ?? {}) as Record<string, unknown>;
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<string, unknown> = {};
if (existsSync(path)) {
try {
settings = JSON.parse(readFileSync(path, 'utf-8')) as Record<string, unknown>;
} catch {
// overwrite if corrupt
}
} else {
mkdirSync(join(path, '..'), { recursive: true });
}

const permissions = (settings.permissions ?? {}) as Record<string, unknown>;
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');
}
Loading