feat(installer): add Gemini CLI + Antigravity IDE targets (#399) (#458)

`codegraph install` now detects and configures two more agents:

- Gemini CLI / Antigravity CLI — `~/.gemini/settings.json` (or
  `./.gemini/settings.json`) + `~/.gemini/GEMINI.md` (or project-root
  `./GEMINI.md`). Preserves pre-existing top-level settings like
  `security.auth` and sibling MCP servers.

- Antigravity IDE — writes to Antigravity's unified MCP config at
  `~/.gemini/config/mcp_config.json` (post-migration, detected via
  the `.migrated` marker Antigravity drops). Falls back to the
  legacy `~/.gemini/antigravity/mcp_config.json` on pre-migration
  builds; install migrates a stale legacy entry, uninstall sweeps
  both. Antigravity-managed sibling fields (e.g. the `disabled` flag
  added when users disable a server through the UI) survive re-install.

  Two Antigravity-specific quirks the target handles:
  1. Entries with `type: "stdio"` are silently rejected by
     Antigravity's MCP scanner; we omit the field for this target.
  2. macOS GUI apps launched from Dock/Finder get a stripped PATH
     that excludes nvm — a bare `codegraph` command name fails to
     spawn even when `which codegraph` works in the user's shell.
     The target resolves `codegraph` to its absolute path at install
     time on macOS. Linux + Windows are unaffected.

End-to-end validated:
- macOS: real Gemini CLI v0.43 via tmux — `/mcp` shows codegraph with
  all 10 tools, `codegraph_status` executes and returns real index
  state. Real Antigravity IDE shows codegraph under Customizations
  after restart.
- Linux (Docker node:22-bookworm) + Windows (Parallels Win11): 116
  installer tests pass; CLI install + uninstall round-trip verified.

Test coverage: the new targets inherit the existing parameterized
contract (idempotent install, sibling preservation, install/uninstall
round-trip). Plus 14 target-specific tests covering migration-marker
detection, legacy→unified entry migration, `disabled` flag
preservation, the `type` field omission, gemini+antigravity
coexistence in the same `~/.gemini/`, and macOS-only path resolution.
Full suite: 972 passing.

Closes #399.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-05-26 11:29:18 -05:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 7479c5e82b
commit 180ba785ce
8 changed files with 753 additions and 9 deletions
+3 -3
View File
@@ -3,7 +3,7 @@
*
* Multi-target: writes MCP server config + instructions for the
* agents the user picks (Claude Code, Cursor, Codex CLI, opencode,
* Hermes Agent).
* Hermes Agent, Gemini CLI, Antigravity IDE).
* Defaults to the Claude-only behavior for backwards compatibility
* when no targets are explicitly chosen and nothing else is detected.
*
@@ -317,8 +317,8 @@ export async function runUninstaller(opts: RunUninstallerOptions): Promise<void>
const sel = await clack.select({
message: 'Remove CodeGraph from all your projects, or just this one?',
options: [
{ value: 'global' as const, label: 'All projects (global)', hint: '~/.claude, ~/.cursor, ~/.codex, ~/.config/opencode, ~/.hermes' },
{ value: 'local' as const, label: 'Just this project (local)', hint: './.claude, ./.cursor, ./opencode.jsonc' },
{ value: 'global' as const, label: 'All projects (global)', hint: '~/.claude, ~/.cursor, ~/.codex, ~/.config/opencode, ~/.hermes, ~/.gemini' },
{ value: 'local' as const, label: 'Just this project (local)', hint: './.claude, ./.cursor, ./opencode.jsonc, ./.gemini' },
],
initialValue: 'global' as const,
});
+288
View File
@@ -0,0 +1,288 @@
/**
* Google Antigravity IDE target. Antigravity is Google's VS Code-derived
* multi-agent IDE; the Gemini CLI is in the process of consolidating with
* it under a single agent platform. Antigravity reads MCP server
* definitions from a separate config file from the CLI.
*
* ## Config path: unified vs legacy
*
* Antigravity recently migrated to a **unified** MCP config path shared
* across all Antigravity tools:
*
* - **Unified** (post-migration, current): `~/.gemini/config/mcp_config.json`
* — signalled by the `~/.gemini/config/.migrated` marker file.
* - **Legacy** (pre-migration): `~/.gemini/antigravity/mcp_config.json`
* — what the github-mcp-server install guide still documents.
*
* We detect the marker at install time and write to the right path. On
* uninstall we sweep BOTH — so a user who installed on the legacy path,
* was then auto-migrated by Antigravity, and re-ran `codegraph install`
* doesn't end up with stale codegraph entries in two files.
*
* ## Entry shape: no `type: stdio` field
*
* Antigravity rejects MCP entries that carry the `type: "stdio"` field
* the rest of our targets use — the working entries it manages itself
* (e.g. `code-review-graph`) omit it, and dropping it was load-bearing
* to get codegraph to appear in the Customizations UI. We build the
* entry locally instead of routing through `getMcpServerConfig()`.
*
* ## macOS GUI app PATH resolution
*
* Antigravity is a GUI Electron app. macOS gives Dock/Finder-launched
* apps a stripped PATH (`/usr/bin:/bin:/usr/sbin:/sbin`) — nvm-managed
* tools live outside that, so a bare `codegraph` command fails to spawn
* even when `which codegraph` resolves in the user's shell. We resolve
* `codegraph` to its absolute path on macOS at install time. (Linux GUI
* apps inherit user PATH; Windows uses `PATH` env directly — both are
* fine with the bare command.)
*
* ## Shared instructions (no GEMINI.md from here)
*
* The IDE shares `~/.gemini/GEMINI.md` with Gemini CLI for instructions
* — written by the `./gemini.ts` target. We deliberately don't touch it
* here so uninstalling Antigravity without uninstalling Gemini CLI
* leaves CLI instructions intact. Users who install only Antigravity
* still get a working MCP integration; the prefer-codegraph-over-grep
* guidance just won't be present unless they also install the gemini
* target.
*
* ## Location
*
* `supportsLocation('local')` returns false — Antigravity has no
* project-scoped config concept as of 2026-05.
*/
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { execSync } from 'child_process';
import {
AgentTarget,
DetectionResult,
InstallOptions,
Location,
WriteResult,
} from './types';
import {
jsonDeepEqual,
readJsonFile,
writeJsonFile,
} from './shared';
function unifiedConfigDir(): string {
return path.join(os.homedir(), '.gemini', 'config');
}
function unifiedMcpConfigPath(): string {
return path.join(unifiedConfigDir(), 'mcp_config.json');
}
function legacyConfigDir(): string {
return path.join(os.homedir(), '.gemini', 'antigravity');
}
function legacyMcpConfigPath(): string {
return path.join(legacyConfigDir(), 'mcp_config.json');
}
function migratedMarkerPath(): string {
return path.join(unifiedConfigDir(), '.migrated');
}
/**
* Pick the right MCP config path to write to.
*
* Prefers the unified `~/.gemini/config/mcp_config.json` when Antigravity
* has signalled it's migrated (`.migrated` marker present, OR the
* unified file already exists — Antigravity creates it on first
* launch post-migration). Falls back to the legacy
* `~/.gemini/antigravity/mcp_config.json` for users on a pre-migration
* Antigravity build.
*/
function preferredMcpConfigPath(): string {
if (fs.existsSync(migratedMarkerPath())) return unifiedMcpConfigPath();
if (fs.existsSync(unifiedMcpConfigPath())) return unifiedMcpConfigPath();
return legacyMcpConfigPath();
}
/**
* Resolve the on-disk path of the `codegraph` binary so a Mac GUI app
* launched from Dock/Finder (with a stripped PATH) can find it. Falls
* back to the bare `codegraph` name when:
*
* - we're not on macOS (Linux GUI apps inherit user PATH; Windows
* uses env PATH directly), OR
* - the lookup fails for any reason (preserving install in restricted
* environments where `which`/`command -v` aren't available).
*
* Resolution prefers `command -v` (built-in, no PATH manipulation),
* with `which` as a fallback. Both are read via the user's interactive
* shell PATH at install time — that's the right PATH for finding
* nvm-managed tools like ours.
*/
function resolveCodegraphCommand(): string {
if (process.platform !== 'darwin') return 'codegraph';
try {
const resolved = execSync('command -v codegraph || which codegraph', {
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'ignore'],
shell: '/bin/bash',
}).trim();
if (resolved && fs.existsSync(resolved)) return resolved;
} catch {
/* fall through to bare name */
}
return 'codegraph';
}
/**
* Build the codegraph MCP-server entry for Antigravity. Distinct from
* `getMcpServerConfig()` because Antigravity (a) rejects the `type`
* field and (b) needs an absolute command path on macOS — see file
* header.
*/
function buildAntigravityEntry(): { command: string; args: string[] } {
return {
command: resolveCodegraphCommand(),
args: ['serve', '--mcp'],
};
}
class AntigravityTarget implements AgentTarget {
readonly id = 'antigravity' as const;
readonly displayName = 'Antigravity IDE';
readonly docsUrl = 'https://antigravity.google';
supportsLocation(loc: Location): boolean {
return loc === 'global';
}
detect(loc: Location): DetectionResult {
if (loc !== 'global') {
return { installed: false, alreadyConfigured: false };
}
const file = preferredMcpConfigPath();
const config = readJsonFile(file);
const alreadyConfigured = !!config.mcpServers?.codegraph;
// "Installed" heuristic: either the unified config dir, the legacy
// config dir, or one of the config files exists. Antigravity creates
// ~/.gemini/ on first launch even before MCP configs.
const installed =
fs.existsSync(unifiedConfigDir()) ||
fs.existsSync(legacyConfigDir()) ||
fs.existsSync(file);
return { installed, alreadyConfigured, configPath: file };
}
install(loc: Location, _opts: InstallOptions): WriteResult {
if (loc !== 'global') {
return {
files: [],
notes: ['Antigravity IDE has no project-local config — re-run with --location=global.'],
};
}
const files: WriteResult['files'] = [];
files.push(writeMcpEntry());
// If the user originally installed on the legacy path and Antigravity
// has since migrated, strip the stale legacy entry so they don't
// wind up with two competing codegraph configs.
const legacyCleanup = cleanupLegacyEntry();
if (legacyCleanup) files.push(legacyCleanup);
return {
files,
notes: ['Restart Antigravity for MCP changes to take effect.'],
};
}
uninstall(loc: Location): WriteResult {
if (loc !== 'global') return { files: [] };
const files: WriteResult['files'] = [];
// Remove from the preferred path.
const preferred = preferredMcpConfigPath();
files.push(removeCodegraphFromFile(preferred));
// Also sweep the OTHER path (legacy when preferred is unified, and
// vice versa) — handles the migration-half-state case where codegraph
// got written to one file but Antigravity now reads from the other.
const other = preferred === unifiedMcpConfigPath()
? legacyMcpConfigPath()
: unifiedMcpConfigPath();
if (preferred !== other) {
const otherResult = removeCodegraphFromFile(other);
// Only surface the secondary file if we actually touched it —
// a `not-found` on a file the user never had is noise.
if (otherResult.action === 'removed') files.push(otherResult);
}
return { files };
}
printConfig(loc: Location): string {
if (loc !== 'global') {
return '# Antigravity IDE has no project-local config — use --location=global.\n';
}
const file = preferredMcpConfigPath();
const snippet = JSON.stringify({ mcpServers: { codegraph: buildAntigravityEntry() } }, null, 2);
return `# Add to ${file}\n\n${snippet}\n`;
}
describePaths(loc: Location): string[] {
if (loc !== 'global') return [];
return [preferredMcpConfigPath()];
}
}
function writeMcpEntry(): WriteResult['files'][number] {
const file = preferredMcpConfigPath();
const dir = path.dirname(file);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
const existing = readJsonFile(file);
const before = existing.mcpServers?.codegraph;
const after = buildAntigravityEntry();
if (jsonDeepEqual(before, after)) {
return { path: file, action: 'unchanged' };
}
const action: 'created' | 'updated' =
before ? 'updated' : (fs.existsSync(file) ? 'updated' : 'created');
if (!existing.mcpServers) existing.mcpServers = {};
existing.mcpServers.codegraph = after;
writeJsonFile(file, existing);
return { path: file, action };
}
/**
* Strip the codegraph entry from the legacy `~/.gemini/antigravity/mcp_config.json`
* if it's present AND we're writing to the unified path. Used by install
* to migrate users who had codegraph configured on the legacy path
* before Antigravity migrated their config. Returns the file action for
* reporting, or `null` when there's nothing to clean up.
*/
function cleanupLegacyEntry(): WriteResult['files'][number] | null {
if (preferredMcpConfigPath() !== unifiedMcpConfigPath()) return null;
const legacy = legacyMcpConfigPath();
if (!fs.existsSync(legacy)) return null;
const config = readJsonFile(legacy);
if (!config.mcpServers?.codegraph) return null;
delete config.mcpServers.codegraph;
if (Object.keys(config.mcpServers).length === 0) {
delete config.mcpServers;
}
writeJsonFile(legacy, config);
return { path: legacy, action: 'removed' };
}
function removeCodegraphFromFile(file: string): WriteResult['files'][number] {
if (!fs.existsSync(file)) return { path: file, action: 'not-found' };
const config = readJsonFile(file);
if (!config.mcpServers?.codegraph) return { path: file, action: 'not-found' };
delete config.mcpServers.codegraph;
if (Object.keys(config.mcpServers).length === 0) {
delete config.mcpServers;
}
// Leave a now-empty `{}` in place — Antigravity manages this file and
// a stray empty file is less surprising than a deletion.
writeJsonFile(file, config);
return { path: file, action: 'removed' };
}
export const antigravityTarget: AgentTarget = new AntigravityTarget();
+167
View File
@@ -0,0 +1,167 @@
/**
* Gemini CLI target (also covers the rebranded "Antigravity CLI" —
* Google is in the middle of unifying its CLI tools under
* Antigravity, and the new CLI continues to read `~/.gemini/settings.json`
* + project-local `.gemini/settings.json`). Writes:
*
* - MCP server entry to `~/.gemini/settings.json` (global) or
* `./.gemini/settings.json` (local) under the standard
* `mcpServers.codegraph` key. Same shape as Claude / Cursor.
* - Instructions to `~/.gemini/GEMINI.md` (global) or `./GEMINI.md`
* (local — Gemini reads the project root file directly, not
* under `.gemini/`).
*
* No permissions concept — Gemini CLI gates tool invocations through
* the `trust` field per server, not an external allowlist. We leave
* `trust` unset so the user controls confirmation prompts.
*
* The Antigravity IDE shares `~/.gemini/GEMINI.md` for instructions
* but uses a separate MCP config file (`~/.gemini/antigravity/mcp_config.json`)
* — see `./antigravity.ts`. Both targets writing to GEMINI.md is
* safe: the marker-based section replacement makes the second write
* a byte-identical no-op.
*/
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import {
AgentTarget,
DetectionResult,
InstallOptions,
Location,
WriteResult,
} from './types';
import {
getMcpServerConfig,
jsonDeepEqual,
readJsonFile,
removeMarkedSection,
replaceOrAppendMarkedSection,
writeJsonFile,
} from './shared';
import {
CODEGRAPH_SECTION_END,
CODEGRAPH_SECTION_START,
INSTRUCTIONS_TEMPLATE,
} from '../instructions-template';
function configDir(loc: Location): string {
return loc === 'global'
? path.join(os.homedir(), '.gemini')
: path.join(process.cwd(), '.gemini');
}
function settingsJsonPath(loc: Location): string {
return path.join(configDir(loc), 'settings.json');
}
function instructionsPath(loc: Location): string {
// Global GEMINI.md lives under ~/.gemini/; project-local GEMINI.md
// lives at the project root (NOT under .gemini/), matching how
// Gemini CLI's hierarchical context loader searches.
return loc === 'global'
? path.join(configDir('global'), 'GEMINI.md')
: path.join(process.cwd(), 'GEMINI.md');
}
class GeminiTarget implements AgentTarget {
readonly id = 'gemini' as const;
readonly displayName = 'Gemini CLI';
readonly docsUrl = 'https://geminicli.com/docs/tools/mcp-server/';
supportsLocation(_loc: Location): boolean {
return true;
}
detect(loc: Location): DetectionResult {
const file = settingsJsonPath(loc);
const config = readJsonFile(file);
const alreadyConfigured = !!config.mcpServers?.codegraph;
const installed = loc === 'global'
? fs.existsSync(configDir('global')) || fs.existsSync(file)
: fs.existsSync(file) || fs.existsSync(configDir('local'));
return { installed, alreadyConfigured, configPath: file };
}
install(loc: Location, _opts: InstallOptions): WriteResult {
const files: WriteResult['files'] = [];
files.push(writeMcpEntry(loc));
files.push(writeInstructionsEntry(loc));
return { files };
}
uninstall(loc: Location): WriteResult {
const files: WriteResult['files'] = [];
const file = settingsJsonPath(loc);
const config = readJsonFile(file);
if (config.mcpServers?.codegraph) {
delete config.mcpServers.codegraph;
if (Object.keys(config.mcpServers).length === 0) {
delete config.mcpServers;
}
// If the file is now an empty `{}` we still leave it — other
// (top-level) Gemini settings the user might add later can
// share the file; deleting it would be surprising.
writeJsonFile(file, config);
files.push({ path: file, action: 'removed' });
} else {
files.push({ path: file, action: 'not-found' });
}
const instr = instructionsPath(loc);
const action = removeMarkedSection(instr, CODEGRAPH_SECTION_START, CODEGRAPH_SECTION_END);
files.push({ path: instr, action });
return { files };
}
printConfig(loc: Location): string {
const target = settingsJsonPath(loc);
const snippet = JSON.stringify({ mcpServers: { codegraph: getMcpServerConfig() } }, null, 2);
return `# Add to ${target}\n\n${snippet}\n`;
}
describePaths(loc: Location): string[] {
return [settingsJsonPath(loc), instructionsPath(loc)];
}
}
function writeMcpEntry(loc: Location): WriteResult['files'][number] {
const file = settingsJsonPath(loc);
const dir = path.dirname(file);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
const existing = readJsonFile(file);
const before = existing.mcpServers?.codegraph;
const after = getMcpServerConfig();
if (jsonDeepEqual(before, after)) {
return { path: file, action: 'unchanged' };
}
const action: 'created' | 'updated' =
before ? 'updated' : (fs.existsSync(file) ? 'updated' : 'created');
if (!existing.mcpServers) existing.mcpServers = {};
existing.mcpServers.codegraph = after;
writeJsonFile(file, existing);
return { path: file, action };
}
function writeInstructionsEntry(loc: Location): WriteResult['files'][number] {
const file = instructionsPath(loc);
const dir = path.dirname(file);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
const action = replaceOrAppendMarkedSection(
file,
INSTRUCTIONS_TEMPLATE,
CODEGRAPH_SECTION_START,
CODEGRAPH_SECTION_END,
);
const mapped: 'created' | 'updated' | 'unchanged' =
action === 'created' ? 'created'
: action === 'unchanged' ? 'unchanged'
: 'updated';
return { path: file, action: mapped };
}
export const geminiTarget: AgentTarget = new GeminiTarget();
+4
View File
@@ -13,6 +13,8 @@ import { cursorTarget } from './cursor';
import { codexTarget } from './codex';
import { opencodeTarget } from './opencode';
import { hermesTarget } from './hermes';
import { geminiTarget } from './gemini';
import { antigravityTarget } from './antigravity';
export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([
claudeTarget,
@@ -20,6 +22,8 @@ export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([
codexTarget,
opencodeTarget,
hermesTarget,
geminiTarget,
antigravityTarget,
]);
export function getTarget(id: string): AgentTarget | undefined {
+1 -1
View File
@@ -19,7 +19,7 @@ export type Location = 'global' | 'local';
* lookup. New targets add a value here when they're added to the
* registry. Keep these short and lowercase.
*/
export type TargetId = 'claude' | 'cursor' | 'codex' | 'opencode' | 'hermes';
export type TargetId = 'claude' | 'cursor' | 'codex' | 'opencode' | 'hermes' | 'gemini' | 'antigravity';
/**
* Result of `target.detect(location)`.