`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:
co-authored by
Claude Opus 4.7
parent
7479c5e82b
commit
180ba785ce
@@ -9,6 +9,16 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Installer targets for Gemini CLI and the Antigravity IDE.** `codegraph install` (and the interactive prompt) now detect and configure two more agents out of the box:
|
||||||
|
- **Gemini CLI** (also covers the rebranded Antigravity CLI) — writes `mcpServers.codegraph` to `~/.gemini/settings.json` (global) or `./.gemini/settings.json` (local), and the codegraph usage block into `~/.gemini/GEMINI.md` / `./GEMINI.md`. Existing top-level settings (e.g. `security.auth`) and sibling MCP servers are preserved.
|
||||||
|
- **Antigravity IDE** — writes `mcpServers.codegraph` to Antigravity's unified MCP config at `~/.gemini/config/mcp_config.json` (post-migration, signalled by the `.migrated` marker Antigravity itself drops). Falls back to the legacy `~/.gemini/antigravity/mcp_config.json` for users on a pre-migration Antigravity build. On install, a stale codegraph entry in the legacy path is migrated to the new file automatically. Uninstall sweeps both. Antigravity-managed sibling fields (e.g. the `"disabled": true` flag the IDE adds when a user disables a server through the UI) are preserved across re-installs.
|
||||||
|
- The Antigravity entry omits the `type: "stdio"` field the other targets use — Antigravity rejects entries that carry it.
|
||||||
|
- On macOS, the Antigravity entry resolves `codegraph` to its absolute path at install time (e.g. an nvm-managed `~/.nvm/.../bin/codegraph`). macOS GUI apps launched from Dock/Finder get a stripped PATH that doesn't include nvm, so a bare command name fails to spawn — even when `which codegraph` works in your shell. Linux GUI apps inherit user PATH and Windows uses env `PATH` directly, so both keep the bare command.
|
||||||
|
- The IDE shares `GEMINI.md` with Gemini CLI, so the two targets compose naturally when both are installed; the antigravity target deliberately doesn't touch `GEMINI.md` so uninstalling Antigravity alone leaves CLI instructions intact.
|
||||||
|
|
||||||
|
Both targets are tested on the same parameterized contract as the existing five agents (idempotent install, sibling preservation, install/uninstall round-trip), with extra coverage for migration-marker detection, legacy → unified entry migration, sibling `disabled` field preservation, and the cross-target case where Gemini CLI and Antigravity IDE coexist in the same `~/.gemini/`. Closes #399.
|
||||||
|
|
||||||
## [0.9.5] - 2026-05-25
|
## [0.9.5] - 2026-05-25
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
# CodeGraph
|
# CodeGraph
|
||||||
|
|
||||||
### Supercharge Claude Code, Cursor, Codex, OpenCode, and Hermes Agent with Semantic Code Intelligence
|
### Supercharge Claude Code, Cursor, Codex, OpenCode, Hermes Agent, Gemini CLI, and Antigravity IDE with Semantic Code Intelligence
|
||||||
|
|
||||||
**~35% cheaper · ~70% fewer tool calls · 100% local**
|
**~35% cheaper · ~70% fewer tool calls · 100% local**
|
||||||
|
|
||||||
@@ -21,6 +21,8 @@
|
|||||||
[](#supported-agents)
|
[](#supported-agents)
|
||||||
[](#supported-agents)
|
[](#supported-agents)
|
||||||
[](#supported-agents)
|
[](#supported-agents)
|
||||||
|
[](#supported-agents)
|
||||||
|
[](#supported-agents)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -43,7 +45,7 @@ npx @colbymchenry/codegraph # zero-install, or:
|
|||||||
npm i -g @colbymchenry/codegraph
|
npm i -g @colbymchenry/codegraph
|
||||||
```
|
```
|
||||||
|
|
||||||
<sub>CodeGraph bundles its own runtime — nothing to compile, no native build, works the same everywhere. The interactive installer auto-configures your agent(s) — Claude Code, Cursor, Codex CLI, opencode, Hermes Agent.</sub>
|
<sub>CodeGraph bundles its own runtime — nothing to compile, no native build, works the same everywhere. The interactive installer auto-configures your agent(s) — Claude Code, Cursor, Codex CLI, opencode, Hermes Agent, Gemini CLI, Antigravity IDE.</sub>
|
||||||
|
|
||||||
### Initialize Projects
|
### Initialize Projects
|
||||||
|
|
||||||
@@ -230,10 +232,10 @@ npx @colbymchenry/codegraph
|
|||||||
```
|
```
|
||||||
|
|
||||||
The installer will:
|
The installer will:
|
||||||
- Ask which agent(s) to configure — auto-detects installed ones from: **Claude Code**, **Cursor**, **Codex CLI**, **opencode**, **Hermes Agent**
|
- Ask which agent(s) to configure — auto-detects installed ones from: **Claude Code**, **Cursor**, **Codex CLI**, **opencode**, **Hermes Agent**, **Gemini CLI**, **Antigravity IDE**
|
||||||
- Prompt to install `codegraph` on your PATH (so agents can launch the MCP server)
|
- Prompt to install `codegraph` on your PATH (so agents can launch the MCP server)
|
||||||
- Ask whether configs apply to all your projects or just this one
|
- Ask whether configs apply to all your projects or just this one
|
||||||
- Write each chosen agent's MCP server config + an instructions file (e.g. `CLAUDE.md`, `.cursor/rules/codegraph.mdc`, `~/.codex/AGENTS.md`)
|
- Write each chosen agent's MCP server config + an instructions file (e.g. `CLAUDE.md`, `.cursor/rules/codegraph.mdc`, `~/.codex/AGENTS.md`, `~/.gemini/GEMINI.md`)
|
||||||
- Set up auto-allow permissions when Claude Code is one of the targets
|
- Set up auto-allow permissions when Claude Code is one of the targets
|
||||||
- Initialize your current project (local installs only)
|
- Initialize your current project (local installs only)
|
||||||
|
|
||||||
@@ -256,7 +258,7 @@ codegraph install --print-config codex # print snippet, no file wr
|
|||||||
|
|
||||||
### 2. Restart Your Agent
|
### 2. Restart Your Agent
|
||||||
|
|
||||||
Restart your agent (Claude Code / Cursor / Codex CLI / opencode / Hermes Agent) for the MCP server to load.
|
Restart your agent (Claude Code / Cursor / Codex CLI / opencode / Hermes Agent / Gemini CLI / Antigravity IDE) for the MCP server to load.
|
||||||
|
|
||||||
### 3. Initialize Projects
|
### 3. Initialize Projects
|
||||||
|
|
||||||
|
|||||||
@@ -317,6 +317,277 @@ describe('Installer targets — partial-state idempotency', () => {
|
|||||||
expect(paths.some((p) => p.endsWith('/AGENTS.md'))).toBe(true);
|
expect(paths.some((p) => p.endsWith('/AGENTS.md'))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('gemini: install writes settings.json (mcpServers.codegraph) and GEMINI.md with marker block', () => {
|
||||||
|
const gemini = getTarget('gemini')!;
|
||||||
|
const result = gemini.install('global', { autoAllow: true });
|
||||||
|
const settings = path.join(tmpHome, '.gemini', 'settings.json');
|
||||||
|
const geminiMd = path.join(tmpHome, '.gemini', 'GEMINI.md');
|
||||||
|
expect(result.files.some((f) => f.path === settings)).toBe(true);
|
||||||
|
expect(result.files.some((f) => f.path === geminiMd)).toBe(true);
|
||||||
|
|
||||||
|
const cfg = JSON.parse(fs.readFileSync(settings, 'utf-8'));
|
||||||
|
expect(cfg.mcpServers.codegraph).toEqual({ type: 'stdio', command: 'codegraph', args: ['serve', '--mcp'] });
|
||||||
|
|
||||||
|
const md = fs.readFileSync(geminiMd, 'utf-8');
|
||||||
|
expect(md).toContain('<!-- CODEGRAPH_START -->');
|
||||||
|
expect(md).toContain('<!-- CODEGRAPH_END -->');
|
||||||
|
expect(md).toContain('codegraph_callers');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gemini: install preserves pre-existing settings (security.auth survives)', () => {
|
||||||
|
const gemini = getTarget('gemini')!;
|
||||||
|
const settings = path.join(tmpHome, '.gemini', 'settings.json');
|
||||||
|
fs.mkdirSync(path.dirname(settings), { recursive: true });
|
||||||
|
fs.writeFileSync(settings, JSON.stringify({
|
||||||
|
security: { auth: { selectedType: 'oauth-personal' } },
|
||||||
|
}, null, 2) + '\n');
|
||||||
|
|
||||||
|
gemini.install('global', { autoAllow: true });
|
||||||
|
|
||||||
|
const after = JSON.parse(fs.readFileSync(settings, 'utf-8'));
|
||||||
|
expect(after.security?.auth?.selectedType).toBe('oauth-personal');
|
||||||
|
expect(after.mcpServers?.codegraph).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gemini: uninstall strips codegraph but leaves pre-existing settings (security.auth) intact', () => {
|
||||||
|
const gemini = getTarget('gemini')!;
|
||||||
|
const settings = path.join(tmpHome, '.gemini', 'settings.json');
|
||||||
|
fs.mkdirSync(path.dirname(settings), { recursive: true });
|
||||||
|
fs.writeFileSync(settings, JSON.stringify({
|
||||||
|
security: { auth: { selectedType: 'oauth-personal' } },
|
||||||
|
}, null, 2) + '\n');
|
||||||
|
|
||||||
|
gemini.install('global', { autoAllow: true });
|
||||||
|
gemini.uninstall('global');
|
||||||
|
|
||||||
|
const after = JSON.parse(fs.readFileSync(settings, 'utf-8'));
|
||||||
|
expect(after.security?.auth?.selectedType).toBe('oauth-personal');
|
||||||
|
expect(after.mcpServers).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gemini: local install writes ./.gemini/settings.json and ./GEMINI.md (project root)', () => {
|
||||||
|
const gemini = getTarget('gemini')!;
|
||||||
|
const result = gemini.install('local', { autoAllow: true });
|
||||||
|
const paths = result.files.map((f) => f.path.replace(/\\/g, '/'));
|
||||||
|
expect(paths.some((p) => p.endsWith('/.gemini/settings.json'))).toBe(true);
|
||||||
|
// Local GEMINI.md sits at the project root, NOT under .gemini/.
|
||||||
|
expect(paths.some((p) => p.endsWith('/GEMINI.md') && !p.endsWith('/.gemini/GEMINI.md'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gemini: GEMINI.md uninstall preserves user content outside the codegraph markers', () => {
|
||||||
|
const gemini = getTarget('gemini')!;
|
||||||
|
const geminiMd = path.join(tmpHome, '.gemini', 'GEMINI.md');
|
||||||
|
fs.mkdirSync(path.dirname(geminiMd), { recursive: true });
|
||||||
|
fs.writeFileSync(geminiMd, '# My personal Gemini context\n\nAlways respond concisely.\n');
|
||||||
|
|
||||||
|
gemini.install('global', { autoAllow: true });
|
||||||
|
gemini.uninstall('global');
|
||||||
|
|
||||||
|
const body = fs.readFileSync(geminiMd, 'utf-8');
|
||||||
|
expect(body).toContain('# My personal Gemini context');
|
||||||
|
expect(body).toContain('Always respond concisely.');
|
||||||
|
expect(body).not.toContain('CODEGRAPH_START');
|
||||||
|
expect(body).not.toContain('codegraph_callers');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('antigravity: install writes to LEGACY ~/.gemini/antigravity/mcp_config.json when no migration marker', () => {
|
||||||
|
const antigravity = getTarget('antigravity')!;
|
||||||
|
antigravity.install('global', { autoAllow: true });
|
||||||
|
|
||||||
|
const legacyFile = path.join(tmpHome, '.gemini', 'antigravity', 'mcp_config.json');
|
||||||
|
expect(fs.existsSync(legacyFile)).toBe(true);
|
||||||
|
const cfg = JSON.parse(fs.readFileSync(legacyFile, 'utf-8'));
|
||||||
|
expect(cfg.mcpServers.codegraph).toBeDefined();
|
||||||
|
// Crucially: does NOT touch the Gemini CLI's settings.json.
|
||||||
|
expect(fs.existsSync(path.join(tmpHome, '.gemini', 'settings.json'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('antigravity: install writes to UNIFIED ~/.gemini/config/mcp_config.json when .migrated marker present', () => {
|
||||||
|
const antigravity = getTarget('antigravity')!;
|
||||||
|
// Plant the migration marker — same signal Antigravity itself drops
|
||||||
|
// when it migrates a user's config.
|
||||||
|
const unifiedDir = path.join(tmpHome, '.gemini', 'config');
|
||||||
|
fs.mkdirSync(unifiedDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(unifiedDir, '.migrated'), '');
|
||||||
|
|
||||||
|
antigravity.install('global', { autoAllow: true });
|
||||||
|
|
||||||
|
const unifiedFile = path.join(unifiedDir, 'mcp_config.json');
|
||||||
|
expect(fs.existsSync(unifiedFile)).toBe(true);
|
||||||
|
const cfg = JSON.parse(fs.readFileSync(unifiedFile, 'utf-8'));
|
||||||
|
expect(cfg.mcpServers.codegraph).toBeDefined();
|
||||||
|
// Legacy path is NOT touched when the marker tells us migration happened.
|
||||||
|
expect(fs.existsSync(path.join(tmpHome, '.gemini', 'antigravity', 'mcp_config.json'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('antigravity: install writes to UNIFIED path when ~/.gemini/config/mcp_config.json already exists (even without marker)', () => {
|
||||||
|
const antigravity = getTarget('antigravity')!;
|
||||||
|
// Antigravity creates this file on first launch post-migration — its
|
||||||
|
// presence is the second signal we accept, in case the .migrated
|
||||||
|
// marker semantics change across Antigravity versions.
|
||||||
|
const unifiedFile = path.join(tmpHome, '.gemini', 'config', 'mcp_config.json');
|
||||||
|
fs.mkdirSync(path.dirname(unifiedFile), { recursive: true });
|
||||||
|
fs.writeFileSync(unifiedFile, JSON.stringify({ mcpServers: {} }, null, 2) + '\n');
|
||||||
|
|
||||||
|
antigravity.install('global', { autoAllow: true });
|
||||||
|
|
||||||
|
const cfg = JSON.parse(fs.readFileSync(unifiedFile, 'utf-8'));
|
||||||
|
expect(cfg.mcpServers.codegraph).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('antigravity: entry has NO `type` field (Antigravity rejects entries with it)', () => {
|
||||||
|
const antigravity = getTarget('antigravity')!;
|
||||||
|
// Marker → unified path; doesn't matter which path, just inspect the entry shape.
|
||||||
|
fs.mkdirSync(path.join(tmpHome, '.gemini', 'config'), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(tmpHome, '.gemini', 'config', '.migrated'), '');
|
||||||
|
|
||||||
|
antigravity.install('global', { autoAllow: true });
|
||||||
|
|
||||||
|
const cfg = JSON.parse(fs.readFileSync(
|
||||||
|
path.join(tmpHome, '.gemini', 'config', 'mcp_config.json'), 'utf-8'
|
||||||
|
));
|
||||||
|
expect(cfg.mcpServers.codegraph.type).toBeUndefined();
|
||||||
|
expect(cfg.mcpServers.codegraph.command).toBeDefined();
|
||||||
|
expect(cfg.mcpServers.codegraph.args).toEqual(['serve', '--mcp']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('antigravity: install migrates a legacy codegraph entry to the unified path when marker appears', () => {
|
||||||
|
const antigravity = getTarget('antigravity')!;
|
||||||
|
// Simulate: user installed on the legacy path, then Antigravity
|
||||||
|
// migrated their config (dropped the `.migrated` marker + created
|
||||||
|
// the unified file). Re-running codegraph install should land
|
||||||
|
// codegraph in the new file AND strip the stale legacy entry.
|
||||||
|
const legacyFile = path.join(tmpHome, '.gemini', 'antigravity', 'mcp_config.json');
|
||||||
|
fs.mkdirSync(path.dirname(legacyFile), { recursive: true });
|
||||||
|
fs.writeFileSync(legacyFile, JSON.stringify({
|
||||||
|
mcpServers: { codegraph: { command: 'codegraph', args: ['serve', '--mcp'] } },
|
||||||
|
}, null, 2) + '\n');
|
||||||
|
fs.mkdirSync(path.join(tmpHome, '.gemini', 'config'), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(tmpHome, '.gemini', 'config', '.migrated'), '');
|
||||||
|
|
||||||
|
antigravity.install('global', { autoAllow: true });
|
||||||
|
|
||||||
|
const unified = JSON.parse(fs.readFileSync(
|
||||||
|
path.join(tmpHome, '.gemini', 'config', 'mcp_config.json'), 'utf-8'
|
||||||
|
));
|
||||||
|
expect(unified.mcpServers.codegraph).toBeDefined();
|
||||||
|
// Legacy file's codegraph entry got stripped.
|
||||||
|
const legacy = JSON.parse(fs.readFileSync(legacyFile, 'utf-8'));
|
||||||
|
expect(legacy.mcpServers).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('antigravity: install preserves a sibling MCP server in mcp_config.json (legacy path)', () => {
|
||||||
|
const antigravity = getTarget('antigravity')!;
|
||||||
|
const mcpFile = path.join(tmpHome, '.gemini', 'antigravity', 'mcp_config.json');
|
||||||
|
fs.mkdirSync(path.dirname(mcpFile), { recursive: true });
|
||||||
|
fs.writeFileSync(mcpFile, JSON.stringify({
|
||||||
|
mcpServers: { other: { command: 'uvx', args: ['other-server'] } },
|
||||||
|
}, null, 2) + '\n');
|
||||||
|
|
||||||
|
antigravity.install('global', { autoAllow: true });
|
||||||
|
|
||||||
|
const after = JSON.parse(fs.readFileSync(mcpFile, 'utf-8'));
|
||||||
|
expect(after.mcpServers.other).toBeDefined();
|
||||||
|
expect(after.mcpServers.codegraph).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('antigravity: install preserves Antigravity-managed fields on sibling servers (e.g. disabled flag)', () => {
|
||||||
|
const antigravity = getTarget('antigravity')!;
|
||||||
|
// Antigravity adds `"disabled": true` to entries the user disables via
|
||||||
|
// the IDE. Install must not clobber that on sibling entries.
|
||||||
|
fs.mkdirSync(path.join(tmpHome, '.gemini', 'config'), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(tmpHome, '.gemini', 'config', '.migrated'), '');
|
||||||
|
const unified = path.join(tmpHome, '.gemini', 'config', 'mcp_config.json');
|
||||||
|
fs.writeFileSync(unified, JSON.stringify({
|
||||||
|
mcpServers: {
|
||||||
|
'code-review-graph': {
|
||||||
|
command: 'uvx', args: ['code-review-graph', 'serve'], disabled: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, null, 2) + '\n');
|
||||||
|
|
||||||
|
antigravity.install('global', { autoAllow: true });
|
||||||
|
|
||||||
|
const after = JSON.parse(fs.readFileSync(unified, 'utf-8'));
|
||||||
|
expect(after.mcpServers['code-review-graph'].disabled).toBe(true);
|
||||||
|
expect(after.mcpServers.codegraph).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('antigravity: uninstall removes only codegraph, sibling MCP server survives', () => {
|
||||||
|
const antigravity = getTarget('antigravity')!;
|
||||||
|
const mcpFile = path.join(tmpHome, '.gemini', 'antigravity', 'mcp_config.json');
|
||||||
|
fs.mkdirSync(path.dirname(mcpFile), { recursive: true });
|
||||||
|
fs.writeFileSync(mcpFile, JSON.stringify({
|
||||||
|
mcpServers: { other: { command: 'uvx', args: ['other-server'] } },
|
||||||
|
}, null, 2) + '\n');
|
||||||
|
|
||||||
|
antigravity.install('global', { autoAllow: true });
|
||||||
|
antigravity.uninstall('global');
|
||||||
|
|
||||||
|
const after = JSON.parse(fs.readFileSync(mcpFile, 'utf-8'));
|
||||||
|
expect(after.mcpServers.other).toBeDefined();
|
||||||
|
expect(after.mcpServers.codegraph).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('antigravity: uninstall sweeps BOTH legacy and unified paths (handles migration half-state)', () => {
|
||||||
|
const antigravity = getTarget('antigravity')!;
|
||||||
|
// User had codegraph in BOTH files (e.g. legacy install + post-migration
|
||||||
|
// re-install before our migration cleanup landed). Uninstall must clean
|
||||||
|
// both so a "fresh slate" really is fresh.
|
||||||
|
const legacy = path.join(tmpHome, '.gemini', 'antigravity', 'mcp_config.json');
|
||||||
|
const unified = path.join(tmpHome, '.gemini', 'config', 'mcp_config.json');
|
||||||
|
fs.mkdirSync(path.dirname(legacy), { recursive: true });
|
||||||
|
fs.mkdirSync(path.dirname(unified), { recursive: true });
|
||||||
|
fs.writeFileSync(legacy, JSON.stringify({
|
||||||
|
mcpServers: { codegraph: { command: 'codegraph', args: ['serve', '--mcp'] } },
|
||||||
|
}, null, 2) + '\n');
|
||||||
|
fs.writeFileSync(unified, JSON.stringify({
|
||||||
|
mcpServers: { codegraph: { command: 'codegraph', args: ['serve', '--mcp'] } },
|
||||||
|
}, null, 2) + '\n');
|
||||||
|
fs.writeFileSync(path.join(path.dirname(unified), '.migrated'), '');
|
||||||
|
|
||||||
|
antigravity.uninstall('global');
|
||||||
|
|
||||||
|
const legacyAfter = JSON.parse(fs.readFileSync(legacy, 'utf-8'));
|
||||||
|
const unifiedAfter = JSON.parse(fs.readFileSync(unified, 'utf-8'));
|
||||||
|
expect(legacyAfter.mcpServers).toBeUndefined();
|
||||||
|
expect(unifiedAfter.mcpServers).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('antigravity: rejects --location=local with a clear note (global-only IDE)', () => {
|
||||||
|
const antigravity = getTarget('antigravity')!;
|
||||||
|
expect(antigravity.supportsLocation('local')).toBe(false);
|
||||||
|
const result = antigravity.install('local', { autoAllow: true });
|
||||||
|
expect(result.files).toEqual([]);
|
||||||
|
expect(result.notes?.join(' ')).toMatch(/no project-local config/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('antigravity: does not write GEMINI.md (only gemini target owns instructions)', () => {
|
||||||
|
const antigravity = getTarget('antigravity')!;
|
||||||
|
antigravity.install('global', { autoAllow: true });
|
||||||
|
const geminiMd = path.join(tmpHome, '.gemini', 'GEMINI.md');
|
||||||
|
expect(fs.existsSync(geminiMd)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gemini + antigravity: both installed coexist (separate MCP files, shared GEMINI.md)', () => {
|
||||||
|
const gemini = getTarget('gemini')!;
|
||||||
|
const antigravity = getTarget('antigravity')!;
|
||||||
|
gemini.install('global', { autoAllow: true });
|
||||||
|
antigravity.install('global', { autoAllow: true });
|
||||||
|
|
||||||
|
const cliCfg = JSON.parse(fs.readFileSync(path.join(tmpHome, '.gemini', 'settings.json'), 'utf-8'));
|
||||||
|
// Antigravity lands on the LEGACY path here since no .migrated marker
|
||||||
|
// was planted — same end-to-end check either way.
|
||||||
|
const ideCfg = JSON.parse(fs.readFileSync(path.join(tmpHome, '.gemini', 'antigravity', 'mcp_config.json'), 'utf-8'));
|
||||||
|
expect(cliCfg.mcpServers.codegraph).toBeDefined();
|
||||||
|
expect(ideCfg.mcpServers.codegraph).toBeDefined();
|
||||||
|
|
||||||
|
// Uninstall one — the other's MCP entry must survive.
|
||||||
|
antigravity.uninstall('global');
|
||||||
|
const cliAfter = JSON.parse(fs.readFileSync(path.join(tmpHome, '.gemini', 'settings.json'), 'utf-8'));
|
||||||
|
expect(cliAfter.mcpServers.codegraph).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
it('hermes: install adds codegraph MCP server and cli toolset, preserving existing yaml', () => {
|
it('hermes: install adds codegraph MCP server and cli toolset, preserving existing yaml', () => {
|
||||||
const hermes = getTarget('hermes')!;
|
const hermes = getTarget('hermes')!;
|
||||||
const config = path.join(tmpHome, '.hermes', 'config.yaml');
|
const config = path.join(tmpHome, '.hermes', 'config.yaml');
|
||||||
@@ -617,6 +888,8 @@ describe('Installer targets — registry', () => {
|
|||||||
expect(getTarget('codex')?.id).toBe('codex');
|
expect(getTarget('codex')?.id).toBe('codex');
|
||||||
expect(getTarget('opencode')?.id).toBe('opencode');
|
expect(getTarget('opencode')?.id).toBe('opencode');
|
||||||
expect(getTarget('hermes')?.id).toBe('hermes');
|
expect(getTarget('hermes')?.id).toBe('hermes');
|
||||||
|
expect(getTarget('gemini')?.id).toBe('gemini');
|
||||||
|
expect(getTarget('antigravity')?.id).toBe('antigravity');
|
||||||
expect(getTarget('not-a-real-target')).toBeUndefined();
|
expect(getTarget('not-a-real-target')).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
*
|
*
|
||||||
* Multi-target: writes MCP server config + instructions for the
|
* Multi-target: writes MCP server config + instructions for the
|
||||||
* agents the user picks (Claude Code, Cursor, Codex CLI, opencode,
|
* 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
|
* Defaults to the Claude-only behavior for backwards compatibility
|
||||||
* when no targets are explicitly chosen and nothing else is detected.
|
* 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({
|
const sel = await clack.select({
|
||||||
message: 'Remove CodeGraph from all your projects, or just this one?',
|
message: 'Remove CodeGraph from all your projects, or just this one?',
|
||||||
options: [
|
options: [
|
||||||
{ value: 'global' as const, label: 'All projects (global)', hint: '~/.claude, ~/.cursor, ~/.codex, ~/.config/opencode, ~/.hermes' },
|
{ 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' },
|
{ value: 'local' as const, label: 'Just this project (local)', hint: './.claude, ./.cursor, ./opencode.jsonc, ./.gemini' },
|
||||||
],
|
],
|
||||||
initialValue: 'global' as const,
|
initialValue: 'global' as const,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -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();
|
||||||
@@ -13,6 +13,8 @@ import { cursorTarget } from './cursor';
|
|||||||
import { codexTarget } from './codex';
|
import { codexTarget } from './codex';
|
||||||
import { opencodeTarget } from './opencode';
|
import { opencodeTarget } from './opencode';
|
||||||
import { hermesTarget } from './hermes';
|
import { hermesTarget } from './hermes';
|
||||||
|
import { geminiTarget } from './gemini';
|
||||||
|
import { antigravityTarget } from './antigravity';
|
||||||
|
|
||||||
export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([
|
export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([
|
||||||
claudeTarget,
|
claudeTarget,
|
||||||
@@ -20,6 +22,8 @@ export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([
|
|||||||
codexTarget,
|
codexTarget,
|
||||||
opencodeTarget,
|
opencodeTarget,
|
||||||
hermesTarget,
|
hermesTarget,
|
||||||
|
geminiTarget,
|
||||||
|
antigravityTarget,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export function getTarget(id: string): AgentTarget | undefined {
|
export function getTarget(id: string): AgentTarget | undefined {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export type Location = 'global' | 'local';
|
|||||||
* lookup. New targets add a value here when they're added to the
|
* lookup. New targets add a value here when they're added to the
|
||||||
* registry. Keep these short and lowercase.
|
* 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)`.
|
* Result of `target.detect(location)`.
|
||||||
|
|||||||
Reference in New Issue
Block a user