Merge pull request #1440 from colbymchenry/feat/copilot-installer-targets

feat(installer): GitHub Copilot targets — VS Code, Copilot CLI, JetBrains
This commit is contained in:
Colby Mchenry
2026-08-07 13:50:33 -05:00
committed by GitHub
10 changed files with 1206 additions and 11 deletions
+2
View File
@@ -17,6 +17,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- When an agent connects over MCP, CodeGraph now states up front that it indexes 30+ languages — TypeScript/JavaScript, Python, Go, Rust, Java, C#, C/C++, PHP, Ruby, Swift, Kotlin, and more — so agents no longer assume a language isn't supported and skip the graph. (#671)
- GitHub Copilot is now a supported agent: `codegraph install` can configure Copilot Chat in VS Code (`copilot-vscode`), the GitHub Copilot CLI (`copilot-cli`), and the Copilot plugin in JetBrains IDEs (`copilot-jetbrains`). Installed Copilot surfaces are auto-detected like every other agent, existing MCP server entries in their config files are preserved, and `codegraph uninstall` reverses the setup cleanly. Restart VS Code or your JetBrains IDE after installing so Copilot picks up the server.
### Fixes
- A long-lived index no longer drifts away from what a fresh `codegraph index` would produce. When a file gained or lost a symbol, references to that name in files the sync never touched kept pointing at the definition that was correct before the change, and — because nothing distinguished two same-named definitions — the winner could come down to the order files happened to be written, which differs between a full index and a sync. On this project's own repository, replaying 80 commits through `sync` left 5.7% of connections wrong; it is now 1.3%, and the wrong-answers-still-being-asserted half drops by 99.7%. Since call edges are what flow questions follow and what `codegraph_explore` ranks files by, this quietly degraded answers as an index aged, with nothing to indicate it. Syncing is unchanged in speed, and an edit that only changes a function's body does no extra work at all. Set `CODEGRAPH_NO_REBIND=1` to opt out.
+9 -5
View File
@@ -6,7 +6,7 @@ Already installed? Run `codegraph upgrade`
Follow [@getcodegraph](https://x.com/getcodegraph) on X for updates.
### Supercharge Claude Code, Cursor, Codex, OpenCode, Hermes Agent, Gemini, Antigravity, and Kiro with Semantic Code Intelligence
### Supercharge Claude Code, Cursor, Codex, OpenCode, Hermes Agent, Gemini, Antigravity, Kiro, and GitHub Copilot with Semantic Code Intelligence
**The fastest complete code graph · surgical context · built for how agents actually work · 100% local**
@@ -35,6 +35,7 @@ Follow [@getcodegraph](https://x.com/getcodegraph) on X for updates.
[![Gemini](https://img.shields.io/badge/Gemini-supported-blueviolet.svg)](#supported-agents)
[![Antigravity](https://img.shields.io/badge/Antigravity-supported-blueviolet.svg)](#supported-agents)
[![Kiro](https://img.shields.io/badge/Kiro-supported-blueviolet.svg)](#supported-agents)
[![GitHub Copilot](https://img.shields.io/badge/GitHub_Copilot-supported-blueviolet.svg)](#supported-agents)
<br>
@@ -104,7 +105,7 @@ In a **new terminal**, run the installer to connect CodeGraph to the agents you
codegraph install
```
<sub>Detects and auto-configures Claude Code, Cursor, Codex CLI, opencode, Hermes Agent, Gemini CLI, Antigravity IDE, and Kiro — wiring the CodeGraph MCP server into each. **This is the step that connects CodeGraph to your agent;** installing the CLI in step 1 does not do it on its own. It only wires up your agent — it does **not** index any code; building each project's graph is the separate `codegraph init` in step 3. (Shortcut: `npx @colbymchenry/codegraph` downloads and runs this in one go.)</sub>
<sub>Detects and auto-configures Claude Code, Cursor, Codex CLI, opencode, Hermes Agent, Gemini CLI, Antigravity IDE, Kiro, and GitHub Copilot (VS Code, Copilot CLI, JetBrains IDEs) — wiring the CodeGraph MCP server into each. **This is the step that connects CodeGraph to your agent;** installing the CLI in step 1 does not do it on its own. It only wires up your agent — it does **not** index any code; building each project's graph is the separate `codegraph init` in step 3. (Shortcut: `npx @colbymchenry/codegraph` downloads and runs this in one go.)</sub>
### 3. Initialize each project
@@ -375,7 +376,7 @@ npx @colbymchenry/codegraph
```
The installer will:
- Ask which agent(s) to configure — auto-detects installed ones from: **Claude Code**, **Cursor**, **Codex CLI**, **opencode**, **Hermes Agent**, **Gemini CLI**, **Antigravity IDE**, **Kiro**
- Ask which agent(s) to configure — auto-detects installed ones from: **Claude Code**, **Cursor**, **Codex CLI**, **opencode**, **Hermes Agent**, **Gemini CLI**, **Antigravity IDE**, **Kiro**, **GitHub Copilot** (VS Code, Copilot CLI, JetBrains IDEs)
- 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
- Write each chosen agent's MCP server config, plus a small marker-fenced CodeGraph section in the agent's instructions file (`CLAUDE.md` / `AGENTS.md` / `GEMINI.md`) — that's how subagents and non-MCP agents learn the `codegraph explore` command, since the MCP server's own guidance only reaches the main agent. Removed cleanly by `codegraph uninstall`.
@@ -389,7 +390,9 @@ The installer **wires up your agents only — it does not index your code.** Aft
codegraph install --yes # auto-detect agents, install global
codegraph install --target=cursor,claude --yes # explicit target list
codegraph install --target=auto --location=local # detected agents, project-local
codegraph install --target=copilot-vscode,copilot-cli,copilot-jetbrains --yes # GitHub Copilot everywhere
codegraph install --print-config codex # print snippet, no file writes
codegraph install --print-config copilot-vscode # same, for Copilot in VS Code
```
| Flag | Values | Default |
@@ -402,7 +405,7 @@ codegraph install --print-config codex # print snippet, no file wr
### 2. Restart Your Agent
Restart your agent (Claude Code / Cursor / Codex CLI / opencode / Hermes Agent / Gemini CLI / Antigravity IDE / Kiro) for the MCP server to load.
Restart your agent (Claude Code / Cursor / Codex CLI / opencode / Hermes Agent / Gemini CLI / Antigravity IDE / Kiro / VS Code, the Copilot CLI, or your JetBrains IDE for GitHub Copilot) for the MCP server to load.
### 3. Initialize Projects
@@ -760,6 +763,7 @@ is written):
- **Gemini CLI**
- **Antigravity IDE**
- **Kiro**
- **GitHub Copilot** — Copilot Chat in VS Code (`copilot-vscode`), the Copilot CLI (`copilot-cli`), and the Copilot plugin in JetBrains IDEs (`copilot-jetbrains`)
## Supported Languages
@@ -858,7 +862,7 @@ MIT
<div align="center">
**Made for AI coding agents — Claude Code, Cursor, Codex CLI, opencode, Hermes Agent, Gemini CLI, Antigravity IDE, and Kiro**
**Made for AI coding agents — Claude Code, Cursor, Codex CLI, opencode, Hermes Agent, Gemini CLI, Antigravity IDE, Kiro, and GitHub Copilot**
[Report Bug](https://github.com/colbymchenry/codegraph/issues) · [Request Feature](https://github.com/colbymchenry/codegraph/issues)
+548
View File
@@ -18,6 +18,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { parse as parseJsonc } from 'jsonc-parser';
import { ALL_TARGETS, getTarget, resolveTargetFlag } from '../src/installer/targets/registry';
import { uninstallTargets, refreshTargets } from '../src/installer';
import { upsertTomlTable, removeTomlTable, buildTomlTable } from '../src/installer/targets/toml';
@@ -38,12 +39,14 @@ function setHome(dir: string): { restore: () => void } {
APPDATA: process.env.APPDATA,
XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME,
HERMES_HOME: process.env.HERMES_HOME,
COPILOT_HOME: process.env.COPILOT_HOME,
};
process.env.HOME = dir;
process.env.USERPROFILE = dir;
process.env.APPDATA = path.join(dir, '.config');
process.env.XDG_CONFIG_HOME = path.join(dir, '.config');
delete process.env.HERMES_HOME;
delete process.env.COPILOT_HOME;
return {
restore() {
if (prev.HOME === undefined) delete process.env.HOME; else process.env.HOME = prev.HOME;
@@ -51,6 +54,7 @@ function setHome(dir: string): { restore: () => void } {
if (prev.APPDATA === undefined) delete process.env.APPDATA; else process.env.APPDATA = prev.APPDATA;
if (prev.XDG_CONFIG_HOME === undefined) delete process.env.XDG_CONFIG_HOME; else process.env.XDG_CONFIG_HOME = prev.XDG_CONFIG_HOME;
if (prev.HERMES_HOME === undefined) delete process.env.HERMES_HOME; else process.env.HERMES_HOME = prev.HERMES_HOME;
if (prev.COPILOT_HOME === undefined) delete process.env.COPILOT_HOME; else process.env.COPILOT_HOME = prev.COPILOT_HOME;
},
};
}
@@ -136,6 +140,12 @@ describe('Installer targets — contract', () => {
delete seed.mcpServers;
seed.mcp = { other: { type: 'local', command: ['x'], enabled: true } };
}
// VS Code's mcp.json uses `servers`; the JetBrains Copilot
// plugin's mcp.json is schema-compatible with it.
if (target.id === 'copilot-vscode' || target.id === 'copilot-jetbrains') {
delete seed.mcpServers;
seed.servers = { other: { command: 'x' } };
}
fs.writeFileSync(jsonPath, JSON.stringify(seed, null, 2) + '\n');
target.install(location, { autoAllow: true });
@@ -144,6 +154,9 @@ describe('Installer targets — contract', () => {
if (target.id === 'opencode') {
expect(after.mcp.other).toBeDefined();
expect(after.mcp.codegraph).toBeDefined();
} else if (target.id === 'copilot-vscode' || target.id === 'copilot-jetbrains') {
expect(after.servers.other).toBeDefined();
expect(after.servers.codegraph).toBeDefined();
} else {
expect(after.mcpServers.other).toBeDefined();
expect(after.mcpServers.codegraph).toBeDefined();
@@ -1268,6 +1281,9 @@ describe('Installer targets — registry', () => {
expect(getTarget('gemini')?.id).toBe('gemini');
expect(getTarget('antigravity')?.id).toBe('antigravity');
expect(getTarget('kiro')?.id).toBe('kiro');
expect(getTarget('copilot-vscode')?.id).toBe('copilot-vscode');
expect(getTarget('copilot-cli')?.id).toBe('copilot-cli');
expect(getTarget('copilot-jetbrains')?.id).toBe('copilot-jetbrains');
expect(getTarget('not-a-real-target')).toBeUndefined();
});
@@ -1278,6 +1294,18 @@ describe('Installer targets — registry', () => {
expect(csv.map((t) => t.id)).toEqual(['claude', 'cursor']);
});
it("resolveTargetFlag('all') includes every Copilot target", () => {
const ids = resolveTargetFlag('all', 'global').map((t) => t.id);
expect(ids).toContain('copilot-vscode');
expect(ids).toContain('copilot-cli');
expect(ids).toContain('copilot-jetbrains');
});
it('resolveTargetFlag resolves the Copilot ids from a csv list', () => {
const csv = resolveTargetFlag('copilot-vscode,copilot-cli,copilot-jetbrains', 'global');
expect(csv.map((t) => t.id)).toEqual(['copilot-vscode', 'copilot-cli', 'copilot-jetbrains']);
});
it('resolveTargetFlag throws on unknown id', () => {
expect(() => resolveTargetFlag('claude,bogus', 'global')).toThrow(/Unknown --target/);
});
@@ -1897,3 +1925,523 @@ describe('Installer targets — opencode XDG config path (#535)', () => {
expect(opencode.detect('global').alreadyConfigured).toBe(false);
});
});
// ---------------------------------------------------------------------------
// Copilot family — copilot-vscode / copilot-cli / copilot-jetbrains (CG-5)
//
// The registry-driven contract suite above covers the shared surface
// (install/idempotency/sibling/uninstall/printConfig). These pin the
// target-specific behavior: OS-specific global paths, `--path` injection
// (copilot-vscode mirrors Cursor), global-only skip semantics (cli +
// jetbrains, Codex pattern), COPILOT_HOME resolution, JSONC comment
// preservation, empty-`servers`-wrapper cleanup, and printConfig parity
// with what install writes.
// ---------------------------------------------------------------------------
describe('Installer targets — Copilot family', () => {
let tmpHome: string;
let tmpCwd: string;
let origCwd: string;
let homeRestore: { restore: () => void };
beforeEach(() => {
tmpHome = mkTmpDir('cop-home');
tmpCwd = mkTmpDir('cop-cwd');
origCwd = process.cwd();
process.chdir(tmpCwd);
homeRestore = setHome(tmpHome);
});
afterEach(() => {
homeRestore.restore();
process.chdir(origCwd);
fs.rmSync(tmpHome, { recursive: true, force: true });
fs.rmSync(tmpCwd, { recursive: true, force: true });
});
// printConfig embeds the paste-able snippet after a `# Add to <path>`
// header — extract and parse just the JSON body.
function snippetJson(out: string): any {
const start = out.indexOf('{');
expect(start).toBeGreaterThanOrEqual(0);
return JSON.parse(out.slice(start));
}
// ---- copilot-vscode ----
it('copilot-vscode: local install writes ./.vscode/mcp.json with servers.codegraph and an absolute --path pin', () => {
const t = getTarget('copilot-vscode')!;
const result = t.install('local', { autoAllow: true });
const file = path.join(process.cwd(), '.vscode', 'mcp.json');
expect(result.files[0].path).toBe(file);
expect(result.files[0].action).toBe('created');
const cfg = JSON.parse(fs.readFileSync(file, 'utf-8'));
expect(cfg.servers.codegraph.type).toBe('stdio');
expect(cfg.servers.codegraph.command).toBe('codegraph');
// Cursor-mirror: local installs pin the project with an absolute path.
expect(cfg.servers.codegraph.args).toEqual(['serve', '--mcp', '--path', process.cwd()]);
// No mcpServers wrapper — VS Code's mcp.json uses `servers`.
expect(cfg.mcpServers).toBeUndefined();
});
it('copilot-vscode: global install writes a variable-free entry — no --path, no ${workspaceFolder}', () => {
// VS Code refuses to start a user-level server whose entry uses
// ${workspaceFolder} in any window with no folder open, toasting
// "Variable workspaceFolder can not be resolved" (hit live). VS Code
// documents cwd = workspace folder for stdio servers, and the
// codegraph server resolves the project from roots/cwd — so the
// global entry must carry no --path and no variables at all.
const t = getTarget('copilot-vscode')!;
const result = t.install('global', { autoAllow: true });
const cfg = JSON.parse(fs.readFileSync(result.files[0].path, 'utf-8'));
expect(cfg.servers.codegraph.args).toEqual(['serve', '--mcp']);
expect(JSON.stringify(cfg)).not.toContain('${');
});
it.runIf(process.platform === 'darwin')('copilot-vscode: global path is ~/Library/Application Support/Code/User/mcp.json on macOS', () => {
const t = getTarget('copilot-vscode')!;
const expected = path.join(tmpHome, 'Library', 'Application Support', 'Code', 'User', 'mcp.json');
expect(t.describePaths('global')).toEqual([expected]);
const result = t.install('global', { autoAllow: true });
expect(result.files[0].path).toBe(expected);
expect(fs.existsSync(expected)).toBe(true);
});
it.runIf(process.platform === 'linux')('copilot-vscode: global path honors XDG_CONFIG_HOME on Linux', () => {
const t = getTarget('copilot-vscode')!;
// setHome() points XDG_CONFIG_HOME at <home>/.config.
const expected = path.join(tmpHome, '.config', 'Code', 'User', 'mcp.json');
expect(t.describePaths('global')).toEqual([expected]);
const result = t.install('global', { autoAllow: true });
expect(result.files[0].path).toBe(expected);
});
it.runIf(process.platform === 'win32')('copilot-vscode: global path is %APPDATA%\\Code\\User\\mcp.json on Windows', () => {
const t = getTarget('copilot-vscode')!;
// setHome() points APPDATA at <home>/.config.
const expected = path.join(process.env.APPDATA!, 'Code', 'User', 'mcp.json');
expect(t.describePaths('global')).toEqual([expected]);
const result = t.install('global', { autoAllow: true });
expect(result.files[0].path).toBe(expected);
});
it('copilot-vscode: supports both global and local locations', () => {
const t = getTarget('copilot-vscode')!;
expect(t.supportsLocation('global')).toBe(true);
expect(t.supportsLocation('local')).toBe(true);
});
it('copilot-vscode: preserves comments and sibling servers through install + idempotent re-run (JSONC)', () => {
const t = getTarget('copilot-vscode')!;
const dir = path.join(tmpCwd, '.vscode');
fs.mkdirSync(dir, { recursive: true });
const file = path.join(dir, 'mcp.json');
fs.writeFileSync(file, [
'{',
' // my MCP servers',
' "servers": {',
' "other": { "type": "stdio", "command": "other-server" } // keep',
' }',
'}',
'',
].join('\n'));
t.install('local', { autoAllow: true });
const afterInstall = fs.readFileSync(file, 'utf-8');
expect(afterInstall).toContain('// my MCP servers');
expect(afterInstall).toContain('// keep');
expect(afterInstall).toContain('"other-server"');
expect(afterInstall).toContain('"codegraph"');
const second = t.install('local', { autoAllow: true });
expect(second.files[0].action).toBe('unchanged');
expect(fs.readFileSync(file, 'utf-8')).toBe(afterInstall);
});
it('copilot-vscode: uninstall drops an emptied servers wrapper but keeps the file and its siblings (e.g. inputs)', () => {
const t = getTarget('copilot-vscode')!;
const dir = path.join(tmpCwd, '.vscode');
fs.mkdirSync(dir, { recursive: true });
const file = path.join(dir, 'mcp.json');
fs.writeFileSync(file, [
'{',
' // prompt-time inputs',
' "inputs": [{ "id": "api-key", "type": "promptString" }]',
'}',
'',
].join('\n'));
t.install('local', { autoAllow: true });
const result = t.uninstall('local');
expect(result.files[0].action).toBe('removed');
// File survives; our entry and the now-empty `servers` wrapper are gone.
expect(fs.existsSync(file)).toBe(true);
const text = fs.readFileSync(file, 'utf-8');
expect(text).toContain('// prompt-time inputs');
const cfg = parseJsonc(text);
expect(cfg.inputs).toBeDefined();
expect(cfg.servers).toBeUndefined();
expect(text).not.toContain('codegraph');
});
it('copilot-vscode: uninstall keeps a non-empty servers wrapper (sibling server survives)', () => {
const t = getTarget('copilot-vscode')!;
const file = path.join(tmpCwd, '.vscode', 'mcp.json');
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, JSON.stringify({
servers: { other: { type: 'stdio', command: 'other-server' } },
}, null, 2) + '\n');
t.install('local', { autoAllow: true });
t.uninstall('local');
const cfg = JSON.parse(fs.readFileSync(file, 'utf-8'));
expect(cfg.servers.other).toBeDefined();
expect(cfg.servers.codegraph).toBeUndefined();
});
it('copilot-vscode: uninstall when never installed reports not-found for both locations, no throw', () => {
const t = getTarget('copilot-vscode')!;
for (const loc of ['global', 'local'] as const) {
const result = t.uninstall(loc);
expect(result.files).toHaveLength(1);
expect(result.files[0].action).toBe('not-found');
}
});
it('copilot-vscode: detect() local reports installed only when a .vscode dir exists', () => {
const t = getTarget('copilot-vscode')!;
expect(t.detect('local').installed).toBe(false);
fs.mkdirSync(path.join(tmpCwd, '.vscode'), { recursive: true });
expect(t.detect('local').installed).toBe(true);
expect(t.detect('local').alreadyConfigured).toBe(false);
});
it('copilot-vscode: detect() global falls back to ~/.vscode (extensions dir) as the installed heuristic', () => {
const t = getTarget('copilot-vscode')!;
expect(t.detect('global').installed).toBe(false);
fs.mkdirSync(path.join(tmpHome, '.vscode'), { recursive: true });
expect(t.detect('global').installed).toBe(true);
});
it('copilot-vscode: printConfig matches what install writes, at both locations', () => {
const t = getTarget('copilot-vscode')!;
for (const loc of ['global', 'local'] as const) {
const printed = snippetJson(t.printConfig(loc));
const result = t.install(loc, { autoAllow: true });
const onDisk = JSON.parse(fs.readFileSync(result.files[0].path, 'utf-8'));
expect(printed.servers.codegraph).toEqual(onDisk.servers.codegraph);
}
});
it('copilot-vscode: install note tells the user to restart VS Code', () => {
const t = getTarget('copilot-vscode')!;
const result = t.install('local', { autoAllow: true });
expect(result.notes?.join(' ')).toMatch(/[Rr]estart VS Code/);
});
// ---- copilot-cli ----
it('copilot-cli: global install writes ~/.copilot/mcp-config.json with the documented entry shape (tools: ["*"])', () => {
const t = getTarget('copilot-cli')!;
const result = t.install('global', { autoAllow: true });
const file = path.join(tmpHome, '.copilot', 'mcp-config.json');
expect(result.files[0].path).toBe(file);
expect(result.files[0].action).toBe('created');
const cfg = JSON.parse(fs.readFileSync(file, 'utf-8'));
expect(cfg.mcpServers.codegraph).toEqual({
type: 'stdio',
command: 'codegraph',
args: ['serve', '--mcp'],
tools: ['*'],
});
});
it('copilot-cli: is global-only — local install skips with a clear note, uninstall is a no-op', () => {
const t = getTarget('copilot-cli')!;
expect(t.supportsLocation('local')).toBe(false);
expect(t.supportsLocation('global')).toBe(true);
const install = t.install('local', { autoAllow: true });
expect(install.files).toEqual([]);
expect(install.notes?.join(' ')).toMatch(/no project-local config/);
expect(t.uninstall('local').files).toEqual([]);
expect(t.describePaths('local')).toEqual([]);
expect(t.detect('local').installed).toBe(false);
});
it('copilot-cli: honors the COPILOT_HOME override for install, detect, and uninstall', () => {
const t = getTarget('copilot-cli')!;
const custom = path.join(tmpHome, 'copilot-custom');
process.env.COPILOT_HOME = custom;
const result = t.install('global', { autoAllow: true });
const expected = path.join(custom, 'mcp-config.json');
expect(result.files[0].path).toBe(expected);
expect(fs.existsSync(expected)).toBe(true);
expect(t.detect('global').alreadyConfigured).toBe(true);
// The default location was never touched.
expect(fs.existsSync(path.join(tmpHome, '.copilot'))).toBe(false);
t.uninstall('global');
expect(t.detect('global').alreadyConfigured).toBe(false);
});
it('copilot-cli: uninstall removes only codegraph — sibling server and unrelated keys survive', () => {
const t = getTarget('copilot-cli')!;
const file = path.join(tmpHome, '.copilot', 'mcp-config.json');
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, JSON.stringify({
mcpServers: { other: { type: 'stdio', command: 'other-server' } },
banner: 'never',
}, null, 2) + '\n');
t.install('global', { autoAllow: true });
t.uninstall('global');
const after = JSON.parse(fs.readFileSync(file, 'utf-8'));
expect(after.mcpServers.other).toBeDefined();
expect(after.mcpServers.codegraph).toBeUndefined();
expect(after.banner).toBe('never');
});
it('copilot-cli: uninstall of a from-scratch install deletes the file — no `{}` husk to fool detect()', () => {
const t = getTarget('copilot-cli')!;
t.install('global', { autoAllow: true });
t.uninstall('global');
const file = path.join(tmpHome, '.copilot', 'mcp-config.json');
// A leftover empty mcp-config.json would count as a CLI footprint
// and keep the target showing as detected after uninstall.
expect(fs.existsSync(file)).toBe(false);
});
it('copilot-cli: uninstall keeps the file when unrelated top-level keys remain', () => {
const t = getTarget('copilot-cli')!;
const file = path.join(tmpHome, '.copilot', 'mcp-config.json');
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, JSON.stringify({ banner: 'never' }, null, 2) + '\n');
t.install('global', { autoAllow: true });
t.uninstall('global');
const after = JSON.parse(fs.readFileSync(file, 'utf-8'));
expect(after.mcpServers).toBeUndefined();
expect(after.banner).toBe('never');
});
it('copilot-cli: uninstall when never installed reports not-found, no throw', () => {
const t = getTarget('copilot-cli')!;
const result = t.uninstall('global');
expect(result.files).toHaveLength(1);
expect(result.files[0].action).toBe('not-found');
// Same when the file exists but holds no codegraph entry.
const file = path.join(tmpHome, '.copilot', 'mcp-config.json');
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, JSON.stringify({ mcpServers: { other: { command: 'x' } } }) + '\n');
expect(t.uninstall('global').files[0].action).toBe('not-found');
});
it('copilot-cli: detect() reports installed from CLI artifacts in ~/.copilot', () => {
const t = getTarget('copilot-cli')!;
// The tmp PATH may or may not carry a real `copilot` binary; only
// assert the positive signal we control. The CLI writes config.json
// on first run — that's the footprint.
fs.mkdirSync(path.join(tmpHome, '.copilot'), { recursive: true });
fs.writeFileSync(path.join(tmpHome, '.copilot', 'config.json'), '{}');
expect(t.detect('global').installed).toBe(true);
expect(t.detect('global').alreadyConfigured).toBe(false);
});
it('copilot-cli: detect() is NOT fooled by the VS Code extension\'s ~/.copilot/ide/ locks', () => {
// The VS Code Copilot Chat extension writes MCP socket-handoff lock
// files into ~/.copilot/ide/ on every launch — a machine with only
// the extension has ~/.copilot with a lone `ide` entry and no CLI.
const t = getTarget('copilot-cli')!;
const ideDir = path.join(tmpHome, '.copilot', 'ide');
fs.mkdirSync(ideDir, { recursive: true });
fs.writeFileSync(path.join(ideDir, 'some-uuid.lock'), '{"socketPath":"/tmp/mcp.sock"}');
// Pin PATH to an empty dir so a real `copilot` binary on the host
// can't turn this negative assertion into a false failure.
const prevPath = process.env.PATH;
process.env.PATH = ideDir;
try {
expect(t.detect('global').installed).toBe(false);
// An empty ~/.copilot (no CLI footprint at all) is also not enough.
fs.rmSync(ideDir, { recursive: true });
expect(t.detect('global').installed).toBe(false);
} finally {
process.env.PATH = prevPath;
}
});
it('copilot-cli: printConfig matches what install writes; local variant points at --location=global', () => {
const t = getTarget('copilot-cli')!;
const printed = snippetJson(t.printConfig('global'));
const result = t.install('global', { autoAllow: true });
const onDisk = JSON.parse(fs.readFileSync(result.files[0].path, 'utf-8'));
expect(printed.mcpServers.codegraph).toEqual(onDisk.mcpServers.codegraph);
expect(t.printConfig('local')).toMatch(/--location=global/);
});
// ---- copilot-jetbrains ----
it('copilot-jetbrains: global install writes github-copilot/intellij/mcp.json with the VS Code-compatible servers shape', () => {
const t = getTarget('copilot-jetbrains')!;
const result = t.install('global', { autoAllow: true });
// setHome() sets XDG_CONFIG_HOME, honored on every platform.
const file = path.join(tmpHome, '.config', 'github-copilot', 'intellij', 'mcp.json');
expect(result.files[0].path).toBe(file);
expect(result.files[0].action).toBe('created');
const cfg = JSON.parse(fs.readFileSync(file, 'utf-8'));
// Plain entry — no --path injection for this user-global config.
expect(cfg.servers.codegraph).toEqual({ type: 'stdio', command: 'codegraph', args: ['serve', '--mcp'] });
expect(cfg.mcpServers).toBeUndefined();
});
it.runIf(process.platform !== 'win32')('copilot-jetbrains: falls back to ~/.config/github-copilot when XDG_CONFIG_HOME is unset', () => {
delete process.env.XDG_CONFIG_HOME;
const t = getTarget('copilot-jetbrains')!;
const expected = path.join(tmpHome, '.config', 'github-copilot', 'intellij', 'mcp.json');
expect(t.describePaths('global')).toEqual([expected]);
const result = t.install('global', { autoAllow: true });
expect(result.files[0].path).toBe(expected);
});
it.runIf(process.platform === 'win32')('copilot-jetbrains: falls back to %LOCALAPPDATA%\\github-copilot on Windows when XDG_CONFIG_HOME is unset', () => {
const prevLocal = process.env.LOCALAPPDATA;
delete process.env.XDG_CONFIG_HOME;
process.env.LOCALAPPDATA = path.join(tmpHome, 'AppData', 'Local');
try {
const t = getTarget('copilot-jetbrains')!;
const expected = path.join(tmpHome, 'AppData', 'Local', 'github-copilot', 'intellij', 'mcp.json');
expect(t.describePaths('global')).toEqual([expected]);
const result = t.install('global', { autoAllow: true });
expect(result.files[0].path).toBe(expected);
} finally {
if (prevLocal === undefined) delete process.env.LOCALAPPDATA;
else process.env.LOCALAPPDATA = prevLocal;
}
});
it('copilot-jetbrains: is global-only — local install skips with a clear note, uninstall is a no-op', () => {
const t = getTarget('copilot-jetbrains')!;
expect(t.supportsLocation('local')).toBe(false);
expect(t.supportsLocation('global')).toBe(true);
const install = t.install('local', { autoAllow: true });
expect(install.files).toEqual([]);
expect(install.notes?.join(' ')).toMatch(/no project-local MCP config/);
expect(t.uninstall('local').files).toEqual([]);
expect(t.describePaths('local')).toEqual([]);
expect(t.detect('local').installed).toBe(false);
});
it('copilot-jetbrains: preserves comments and sibling servers through install + idempotent re-run (JSONC)', () => {
const t = getTarget('copilot-jetbrains')!;
const file = path.join(tmpHome, '.config', 'github-copilot', 'intellij', 'mcp.json');
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, [
'{',
' // hand-edited via Settings → Tools → GitHub Copilot',
' "servers": {',
' "other": { "type": "stdio", "command": "other-server" }',
' }',
'}',
'',
].join('\n'));
t.install('global', { autoAllow: true });
const afterInstall = fs.readFileSync(file, 'utf-8');
expect(afterInstall).toContain('// hand-edited via Settings');
expect(afterInstall).toContain('"other-server"');
expect(afterInstall).toContain('"codegraph"');
const second = t.install('global', { autoAllow: true });
expect(second.files[0].action).toBe('unchanged');
expect(fs.readFileSync(file, 'utf-8')).toBe(afterInstall);
});
it('copilot-jetbrains: uninstall removes only codegraph and drops an emptied servers wrapper, keeping the file', () => {
const t = getTarget('copilot-jetbrains')!;
t.install('global', { autoAllow: true });
const file = path.join(tmpHome, '.config', 'github-copilot', 'intellij', 'mcp.json');
const result = t.uninstall('global');
expect(result.files[0].action).toBe('removed');
expect(fs.existsSync(file)).toBe(true);
const cfg = parseJsonc(fs.readFileSync(file, 'utf-8'));
expect(cfg.servers).toBeUndefined();
});
it('copilot-jetbrains: uninstall keeps a sibling server (wrapper not dropped when non-empty)', () => {
const t = getTarget('copilot-jetbrains')!;
const file = path.join(tmpHome, '.config', 'github-copilot', 'intellij', 'mcp.json');
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, JSON.stringify({
servers: { other: { type: 'stdio', command: 'other-server' } },
}, null, 2) + '\n');
t.install('global', { autoAllow: true });
t.uninstall('global');
const cfg = JSON.parse(fs.readFileSync(file, 'utf-8'));
expect(cfg.servers.other).toBeDefined();
expect(cfg.servers.codegraph).toBeUndefined();
});
it('copilot-jetbrains: uninstall when never installed reports not-found, no throw', () => {
const t = getTarget('copilot-jetbrains')!;
const result = t.uninstall('global');
expect(result.files).toHaveLength(1);
expect(result.files[0].action).toBe('not-found');
});
it('copilot-jetbrains: detect() reports installed from the intellij config dir', () => {
const t = getTarget('copilot-jetbrains')!;
expect(t.detect('global').installed).toBe(false);
fs.mkdirSync(path.join(tmpHome, '.config', 'github-copilot', 'intellij'), { recursive: true });
expect(t.detect('global').installed).toBe(true);
expect(t.detect('global').alreadyConfigured).toBe(false);
});
it('copilot-jetbrains: printConfig matches what install writes and names the IDE settings path', () => {
const t = getTarget('copilot-jetbrains')!;
const out = t.printConfig('global');
expect(out).toContain('Settings → Tools → GitHub Copilot');
const printed = snippetJson(out);
const result = t.install('global', { autoAllow: true });
const onDisk = JSON.parse(fs.readFileSync(result.files[0].path, 'utf-8'));
expect(printed.servers.codegraph).toEqual(onDisk.servers.codegraph);
expect(t.printConfig('local')).toMatch(/--location=global/);
});
it('copilot-jetbrains: install note tells the user to restart the IDE', () => {
const t = getTarget('copilot-jetbrains')!;
const result = t.install('global', { autoAllow: true });
expect(result.notes?.join(' ')).toMatch(/[Rr]estart your JetBrains IDE/);
});
it('copilot family: all three coexist — uninstalling one leaves the others configured', () => {
const vscode = getTarget('copilot-vscode')!;
const cli = getTarget('copilot-cli')!;
const jetbrains = getTarget('copilot-jetbrains')!;
vscode.install('global', { autoAllow: true });
cli.install('global', { autoAllow: true });
jetbrains.install('global', { autoAllow: true });
cli.uninstall('global');
expect(cli.detect('global').alreadyConfigured).toBe(false);
expect(vscode.detect('global').alreadyConfigured).toBe(true);
expect(jetbrains.detect('global').alreadyConfigured).toBe(true);
});
});
+2 -2
View File
@@ -2246,7 +2246,7 @@ program
*/
program
.command('install')
.description('Install codegraph MCP server into one or more agents (Claude Code, Cursor, Codex CLI, opencode, Hermes Agent)')
.description('Install codegraph MCP server into one or more agents (Claude Code, Cursor, Codex CLI, opencode, Hermes Agent, Gemini CLI, Antigravity IDE, Kiro, GitHub Copilot)')
.option('-t, --target <ids>', 'Target agent(s): comma-separated ids, or "auto"|"all"|"none". Default: prompt')
.option('-l, --location <where>', 'Install location: "global" or "local". Default: prompt')
.option('-y, --yes', 'Non-interactive: defaults to --location=global --target=auto, auto-allow on')
@@ -2346,7 +2346,7 @@ program
*/
program
.command('uninstall')
.description('Remove codegraph from your agents (Claude Code, Cursor, Codex CLI, opencode, Hermes Agent)')
.description('Remove codegraph from your agents (Claude Code, Cursor, Codex CLI, opencode, Hermes Agent, Gemini CLI, Antigravity IDE, Kiro, GitHub Copilot)')
.option('-t, --target <ids>', 'Target agent(s): comma-separated ids, or "all". Default: all')
.option('-l, --location <where>', 'Uninstall location: "global" or "local". Default: prompt')
.option('-y, --yes', 'Non-interactive: defaults to --location=global --target=all')
+4 -3
View File
@@ -3,7 +3,8 @@
*
* Multi-target: writes MCP server config + instructions for the
* agents the user picks (Claude Code, Cursor, Codex CLI, opencode,
* Hermes Agent, Gemini CLI, Antigravity IDE).
* Hermes Agent, Gemini CLI, Antigravity IDE, Kiro, and GitHub
* Copilot in VS Code / the Copilot CLI / JetBrains IDEs).
* Defaults to the Claude-only behavior for backwards compatibility
* when no targets are explicitly chosen and nothing else is detected.
*
@@ -467,8 +468,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, ~/.gemini, ~/.kiro' },
{ value: 'local' as const, label: 'Just this project (local)', hint: './.claude, ./.cursor, ./opencode.jsonc, ./.gemini, ./.kiro' },
{ value: 'global' as const, label: 'All projects (global)', hint: '~/.claude, ~/.cursor, ~/.codex, ~/.config/opencode, ~/.hermes, ~/.gemini, ~/.kiro, ~/.copilot, ~/.config/github-copilot' },
{ value: 'local' as const, label: 'Just this project (local)', hint: './.claude, ./.cursor, ./.vscode, ./opencode.jsonc, ./.gemini, ./.kiro' },
],
initialValue: 'global' as const,
});
+192
View File
@@ -0,0 +1,192 @@
/**
* GitHub Copilot CLI target.
*
* - MCP server entry to `~/.copilot/mcp-config.json` under the
* `mcpServers` key (same wrapper as Claude/Cursor). Entry shape per
* the GitHub docs: `{ "type": "stdio", "command", "args", "tools" }`
* `type` accepts `"local"` or `"stdio"`; we write `"stdio"` (the
* standard MCP name, recommended by the docs for cross-client
* compatibility). `"tools": ["*"]` mirrors the docs' example and is
* the documented default.
* - The config dir is `~/.copilot` unless the user moved it via
* `COPILOT_HOME` (documented override) we honor it so install and
* detect follow the CLI's own resolution.
*
* Copilot CLI as of 2026-07 has no project-local MCP config per-repo
* config (`.github/mcp.json`) is an open feature request
* (github/copilot-cli#2528). `supportsLocation('local')` returns false;
* the orchestrator skips this target for local installs with a clear
* message (same pattern as Codex).
*
* The file is machine-written by the CLI's own `/mcp add` flow, so it's
* plain JSON no JSONC handling needed; surgical edits go through the
* shared read/mutate/write helpers (Cursor pattern), preserving sibling
* servers.
*
* No instructions file (MCP `initialize` instructions are the single
* source of truth, #529) and no permissions concept `autoAllow` is
* silently ignored.
*/
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,
writeJsonFile,
} from './shared';
function configDir(): string {
const override = process.env.COPILOT_HOME;
if (override && override.trim().length > 0) return override;
return path.join(os.homedir(), '.copilot');
}
function mcpConfigPath(): string {
return path.join(configDir(), 'mcp-config.json');
}
/**
* `~/.copilot` existing is NOT proof the CLI is installed: the VS Code
* Copilot Chat extension drops MCP socket-handoff lock files into
* `~/.copilot/ide/` on launch, so a machine with only the VS Code
* extension still has the dir (with a lone `ide` entry). Count the dir
* as a CLI footprint only when it holds anything besides `ide` the
* CLI writes `config.json` (and later `mcp-config.json`, history state)
* on first run.
*/
function cliConfigDirPresent(): boolean {
let entries: string[];
try {
entries = fs.readdirSync(configDir());
} catch {
return false;
}
return entries.some((e) => e !== 'ide');
}
/**
* Best-effort check that the `copilot` binary is reachable on PATH.
* A plain fs scan (no shell-out) cheap enough to run inside
* `detectAll()` for the multiselect prompt.
*/
function copilotOnPath(): boolean {
const pathVar = process.env.PATH || '';
const exts = process.platform === 'win32'
? ['.exe', '.cmd', '.bat', '.ps1']
: [''];
for (const dir of pathVar.split(path.delimiter)) {
if (!dir) continue;
for (const ext of exts) {
try {
if (fs.existsSync(path.join(dir, 'copilot' + ext))) return true;
} catch { /* ignore unreadable PATH entries */ }
}
}
return false;
}
function buildCopilotMcpConfig(): { type: string; command: string; args: string[]; tools: string[] } {
const base = getMcpServerConfig();
return { ...base, tools: ['*'] };
}
class CopilotCliTarget implements AgentTarget {
readonly id = 'copilot-cli' as const;
readonly displayName = 'GitHub Copilot CLI';
readonly docsUrl = 'https://docs.github.com/en/copilot/how-tos/copilot-cli/customize-copilot/add-mcp-servers';
supportsLocation(loc: Location): boolean {
return loc === 'global';
}
detect(loc: Location): DetectionResult {
if (loc !== 'global') {
return { installed: false, alreadyConfigured: false };
}
const file = mcpConfigPath();
const config = readJsonFile(file);
const alreadyConfigured = !!config.mcpServers?.codegraph;
const installed = cliConfigDirPresent() || copilotOnPath();
return { installed, alreadyConfigured, configPath: file };
}
install(loc: Location, _opts: InstallOptions): WriteResult {
if (loc !== 'global') {
return {
files: [],
notes: ['Copilot CLI has no project-local config — re-run with --location=global to install.'],
};
}
return {
files: [writeMcpEntry()],
notes: ['Restart any running Copilot CLI session to pick up the MCP server.'],
};
}
uninstall(loc: Location): WriteResult {
if (loc !== 'global') return { files: [] };
const file = mcpConfigPath();
if (!fs.existsSync(file)) {
return { files: [{ path: file, action: 'not-found' }] };
}
const config = readJsonFile(file);
if (!config.mcpServers?.codegraph) {
return { files: [{ path: file, action: 'not-found' }] };
}
delete config.mcpServers.codegraph;
if (Object.keys(config.mcpServers).length === 0) {
delete config.mcpServers;
}
if (Object.keys(config).length === 0) {
// Nothing left but the `{}` we'd write back — delete the file so
// uninstall fully reverses a from-scratch install. A leftover
// empty file would keep detect() reporting the CLI as installed.
fs.unlinkSync(file);
} else {
writeJsonFile(file, config);
}
return { files: [{ path: file, action: 'removed' }] };
}
printConfig(loc: Location): string {
if (loc !== 'global') {
return '# Copilot CLI has no project-local config — use --location=global.\n';
}
const snippet = JSON.stringify({ mcpServers: { codegraph: buildCopilotMcpConfig() } }, null, 2);
return `# Add to ${mcpConfigPath()}\n\n${snippet}\n`;
}
describePaths(loc: Location): string[] {
if (loc !== 'global') return [];
return [mcpConfigPath()];
}
}
function writeMcpEntry(): WriteResult['files'][number] {
const file = mcpConfigPath();
const existing = readJsonFile(file);
const before = existing.mcpServers?.codegraph;
const after = buildCopilotMcpConfig();
if (jsonDeepEqual(before, after)) {
return { path: file, action: 'unchanged' };
}
const existed = fs.existsSync(file);
if (!existing.mcpServers) existing.mcpServers = {};
existing.mcpServers.codegraph = after;
writeJsonFile(file, existing);
return { path: file, action: existed ? 'updated' : 'created' };
}
export const copilotCliTarget: AgentTarget = new CopilotCliTarget();
+230
View File
@@ -0,0 +1,230 @@
/**
* JetBrains IDEs (GitHub Copilot plugin) target.
*
* - MCP server entry to the plugin's user-level `mcp.json`, which
* lives under the shared `github-copilot` config dir (the same dir
* the Copilot ecosystem uses for `hosts.json`):
*
* macOS/Linux: $XDG_CONFIG_HOME|~/.config/github-copilot/intellij/mcp.json
* Windows: %LOCALAPPDATA%\github-copilot\intellij\mcp.json
*
* `$XDG_CONFIG_HOME` is honored on every platform when set
* matching the plugin family's own resolution (copilot.vim /
* copilot-language-server check it before the OS default).
* - Shape is VS Code-compatible: `{ "servers": { "<name>": { "type":
* "stdio", "command", "args" } } }` — the plugin documents mcp.json
* parity with `.vscode/mcp.json`.
* - **Global-only.** The plugin reads exactly one user-level file; a
* project-level mcp.json is an open feature request
* (microsoft/copilot-intellij-feedback#701, still open 2026-07).
* `supportsLocation('local')` returns false so the orchestrator
* skips local installs with a clear message (Codex pattern).
* - No `--path` injection: the config is user-global and the plugin
* documents no `${workspaceFolder}`-style variable expansion for
* this file, so we ship the plain entry and let the MCP server
* resolve the project from the client's roots/cwd as with other
* global installs.
* - No instructions file (MCP `initialize` instructions are the
* single source of truth, #529) and no permissions concept
* `autoAllow` is silently ignored.
*
* The IDE opens this file in a JSON editor for hand-editing (Settings
* Tools GitHub Copilot MCP Configure), so reads + writes go
* through `jsonc-parser` surgical edits that preserve sibling
* servers, user comments, and formatting (same approach as the
* copilot-vscode target).
*
* The plugin only re-reads mcp.json on IDE restart
* (microsoft/copilot-intellij-feedback#1139) hence the restart note.
*/
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { parse as parseJsonc, modify, applyEdits } from 'jsonc-parser';
import {
AgentTarget,
DetectionResult,
InstallOptions,
Location,
WriteResult,
} from './types';
import {
atomicWriteFileSync,
getMcpServerConfig,
jsonDeepEqual,
} from './shared';
/**
* The `github-copilot` config root, resolved the way the Copilot
* plugin family resolves it: `$XDG_CONFIG_HOME` first on every
* platform, then `%LOCALAPPDATA%` on Windows, then `~/.config`.
*/
function copilotConfigRoot(): string {
const xdg = process.env.XDG_CONFIG_HOME;
if (xdg && xdg.trim().length > 0) {
return path.join(xdg, 'github-copilot');
}
if (process.platform === 'win32') {
const localAppData = process.env.LOCALAPPDATA && process.env.LOCALAPPDATA.trim().length > 0
? process.env.LOCALAPPDATA
: path.join(os.homedir(), 'AppData', 'Local');
return path.join(localAppData, 'github-copilot');
}
return path.join(os.homedir(), '.config', 'github-copilot');
}
function intellijDir(): string {
return path.join(copilotConfigRoot(), 'intellij');
}
function mcpJsonPath(): string {
return path.join(intellijDir(), 'mcp.json');
}
/**
* Best-effort "a JetBrains IDE exists here" heuristic for the
* multiselect default the per-OS dir every JetBrains IDE creates on
* first launch. False positives (IDE without the Copilot plugin) are
* acceptable per the `DetectionResult` contract.
*/
function jetbrainsConfigDirExists(): boolean {
const home = os.homedir();
if (process.platform === 'darwin') {
return fs.existsSync(path.join(home, 'Library', 'Application Support', 'JetBrains'));
}
if (process.platform === 'win32') {
const appData = process.env.APPDATA && process.env.APPDATA.trim().length > 0
? process.env.APPDATA
: path.join(home, 'AppData', 'Roaming');
return fs.existsSync(path.join(appData, 'JetBrains'));
}
const xdg = process.env.XDG_CONFIG_HOME && process.env.XDG_CONFIG_HOME.trim().length > 0
? process.env.XDG_CONFIG_HOME
: path.join(home, '.config');
return fs.existsSync(path.join(xdg, 'JetBrains'));
}
function readConfigText(file: string): string {
if (!fs.existsSync(file)) return '';
return fs.readFileSync(file, 'utf-8');
}
function parseConfig(text: string): Record<string, any> {
if (!text.trim()) return {};
const errors: any[] = [];
const result = parseJsonc(text, errors, { allowTrailingComma: true });
if (result == null || typeof result !== 'object' || Array.isArray(result)) {
return {};
}
return result as Record<string, any>;
}
const FORMATTING = { tabSize: 2, insertSpaces: true, eol: '\n' };
class CopilotJetbrainsTarget implements AgentTarget {
readonly id = 'copilot-jetbrains' as const;
readonly displayName = 'JetBrains IDEs (Copilot plugin)';
readonly docsUrl = 'https://docs.github.com/en/copilot/how-tos/provide-context/use-mcp/extend-copilot-chat-with-mcp';
supportsLocation(loc: Location): boolean {
return loc === 'global';
}
detect(loc: Location): DetectionResult {
if (loc !== 'global') {
return { installed: false, alreadyConfigured: false };
}
const file = mcpJsonPath();
const config = parseConfig(readConfigText(file));
const alreadyConfigured = !!config.servers?.codegraph;
// The `intellij/` subdir is created by the Copilot plugin itself;
// fall back to "some JetBrains IDE is installed" for first-time
// plugin users.
const installed = fs.existsSync(intellijDir()) || jetbrainsConfigDirExists();
return { installed, alreadyConfigured, configPath: file };
}
install(loc: Location, _opts: InstallOptions): WriteResult {
if (loc !== 'global') {
return {
files: [],
notes: ['The JetBrains Copilot plugin has no project-local MCP config — re-run with --location=global to install.'],
};
}
return {
files: [writeMcpEntry()],
notes: ['Restart your JetBrains IDE — the Copilot plugin only reads mcp.json on startup.'],
};
}
uninstall(loc: Location): WriteResult {
if (loc !== 'global') return { files: [] };
return { files: [removeMcpEntry()] };
}
printConfig(loc: Location): string {
if (loc !== 'global') {
return '# The JetBrains Copilot plugin has no project-local MCP config — use --location=global.\n';
}
const snippet = JSON.stringify({ servers: { codegraph: getMcpServerConfig() } }, null, 2);
return `# Add to ${mcpJsonPath()}\n# (Settings → Tools → GitHub Copilot → Model Context Protocol → Configure)\n\n${snippet}\n`;
}
describePaths(loc: Location): string[] {
if (loc !== 'global') return [];
return [mcpJsonPath()];
}
}
function writeMcpEntry(): WriteResult['files'][number] {
const file = mcpJsonPath();
const existed = fs.existsSync(file);
let text = readConfigText(file);
if (!text.trim()) text = '{}\n';
const config = parseConfig(text);
const before = config.servers?.codegraph;
const after = getMcpServerConfig();
if (jsonDeepEqual(before, after)) {
return { path: file, action: 'unchanged' };
}
// Surgical edit — preserves comments, formatting, and sibling
// servers ("servers" is created when missing).
const edits = modify(text, ['servers', 'codegraph'], after, {
formattingOptions: FORMATTING,
});
const updated = applyEdits(text, edits);
atomicWriteFileSync(file, updated);
return { path: file, action: existed ? 'updated' : 'created' };
}
function removeMcpEntry(): WriteResult['files'][number] {
const file = mcpJsonPath();
if (!fs.existsSync(file)) return { path: file, action: 'not-found' };
const text = readConfigText(file);
const config = parseConfig(text);
if (!config.servers?.codegraph) return { path: file, action: 'not-found' };
let edits = modify(text, ['servers', 'codegraph'], undefined, {
formattingOptions: FORMATTING,
});
let updated = applyEdits(text, edits);
// Drop an emptied `servers` wrapper; the file itself is left in
// place — the plugin owns it and siblings may remain.
const afterParsed = parseConfig(updated);
if (afterParsed.servers && typeof afterParsed.servers === 'object' &&
Object.keys(afterParsed.servers).length === 0) {
edits = modify(updated, ['servers'], undefined, { formattingOptions: FORMATTING });
updated = applyEdits(updated, edits);
}
atomicWriteFileSync(file, updated);
return { path: file, action: 'removed' };
}
export const copilotJetbrainsTarget: AgentTarget = new CopilotJetbrainsTarget();
+212
View File
@@ -0,0 +1,212 @@
/**
* VS Code (GitHub Copilot Chat) target.
*
* - MCP server entry to `.vscode/mcp.json` (local, workspace-scoped)
* or the user-level `mcp.json` in the VS Code User dir (global):
*
* macOS: ~/Library/Application Support/Code/User/mcp.json
* Windows: %APPDATA%\Code\User\mcp.json
* Linux: $XDG_CONFIG_HOME|~/.config/Code/User/mcp.json
*
* VS Code moved MCP config out of settings.json into this dedicated
* `mcp.json` (v1.102, "MCP: Open User Configuration"). Shape is
* `{ "servers": { "<name>": { "type": "stdio", "command", "args" } } }`
* note `servers`, not the `mcpServers` wrapper Claude/Cursor use.
* - No instructions file: Copilot Chat consumes the MCP `initialize`
* instructions, the single source of truth (#529).
* - No permissions concept `autoAllow` is silently ignored.
*
* ## Why `--path` only for local installs (NOT the Cursor pattern)
*
* Unlike Cursor, VS Code DOCUMENTS the launch cwd for stdio MCP
* servers: "Working directory for the server command. Defaults to the
* workspace folder when run in a workspace" (mcp-configuration
* reference). The codegraph server resolves its project via the MCP
* roots/list dance with a cwd fallback, so cwd alone is sufficient:
*
* - `local` install: absolute `--path` (known at install time)
* deterministic, and free of variables.
* - `global` install: NO `--path`. Do not be tempted to pin it with
* `${workspaceFolder}`: VS Code refuses to start a user-level
* server whose entry uses that variable whenever a window has no
* folder open (loose files, welcome tab), surfacing an error toast
* "Variable workspaceFolder can not be resolved" in every such
* window exactly the error-noise that teaches users to disable
* the server. With no `--path`, a folderless window still starts
* the server fine and it serves the "no project" guidance.
*
* ## JSONC
*
* VS Code parses its config files as JSONC (comments + trailing commas
* allowed), so reads + writes go through `jsonc-parser` surgical
* edits that preserve sibling servers, user comments, and formatting
* across install / re-install / uninstall (same approach as opencode).
*/
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { parse as parseJsonc, modify, applyEdits } from 'jsonc-parser';
import {
AgentTarget,
DetectionResult,
InstallOptions,
Location,
WriteResult,
} from './types';
import {
atomicWriteFileSync,
getMcpServerConfig,
jsonDeepEqual,
} from './shared';
function vscodeUserDir(): string {
const home = os.homedir();
if (process.platform === 'win32') {
const appData = process.env.APPDATA && process.env.APPDATA.trim().length > 0
? process.env.APPDATA
: path.join(home, 'AppData', 'Roaming');
return path.join(appData, 'Code', 'User');
}
if (process.platform === 'darwin') {
return path.join(home, 'Library', 'Application Support', 'Code', 'User');
}
const xdg = process.env.XDG_CONFIG_HOME && process.env.XDG_CONFIG_HOME.trim().length > 0
? process.env.XDG_CONFIG_HOME
: path.join(home, '.config');
return path.join(xdg, 'Code', 'User');
}
function mcpJsonPath(loc: Location): string {
return loc === 'global'
? path.join(vscodeUserDir(), 'mcp.json')
: path.join(process.cwd(), '.vscode', 'mcp.json');
}
/**
* Build the codegraph server entry for VS Code at the given location.
* Local installs pin `--path`; global installs rely on VS Code's
* documented workspace-folder cwd see file header for why the global
* entry must stay variable-free.
*/
function buildVscodeServerEntry(loc: Location): { type: string; command: string; args: string[] } {
const base = getMcpServerConfig();
if (loc === 'local') {
return { ...base, args: [...base.args, '--path', process.cwd()] };
}
return { ...base, args: [...base.args] };
}
function readConfigText(file: string): string {
if (!fs.existsSync(file)) return '';
return fs.readFileSync(file, 'utf-8');
}
function parseConfig(text: string): Record<string, any> {
if (!text.trim()) return {};
const errors: any[] = [];
const result = parseJsonc(text, errors, { allowTrailingComma: true });
if (result == null || typeof result !== 'object' || Array.isArray(result)) {
return {};
}
return result as Record<string, any>;
}
const FORMATTING = { tabSize: 2, insertSpaces: true, eol: '\n' };
class CopilotVscodeTarget implements AgentTarget {
readonly id = 'copilot-vscode' as const;
readonly displayName = 'VS Code (Copilot Chat)';
readonly docsUrl = 'https://code.visualstudio.com/docs/copilot/customization/mcp-servers';
supportsLocation(_loc: Location): boolean {
return true;
}
detect(loc: Location): DetectionResult {
const file = mcpJsonPath(loc);
const config = parseConfig(readConfigText(file));
const alreadyConfigured = !!config.servers?.codegraph;
// "Installed" heuristic: the VS Code User dir (created on first
// launch) or ~/.vscode (extensions dir) for global; an existing
// .vscode/ dir in the project for local.
const installed = loc === 'global'
? fs.existsSync(vscodeUserDir()) || fs.existsSync(path.join(os.homedir(), '.vscode'))
: fs.existsSync(path.join(process.cwd(), '.vscode'));
return { installed, alreadyConfigured, configPath: file };
}
install(loc: Location, _opts: InstallOptions): WriteResult {
return {
files: [writeMcpEntry(loc)],
notes: ['Restart VS Code for MCP changes to take effect.'],
};
}
uninstall(loc: Location): WriteResult {
return { files: [removeMcpEntry(loc)] };
}
printConfig(loc: Location): string {
const target = mcpJsonPath(loc);
const snippet = JSON.stringify({ servers: { codegraph: buildVscodeServerEntry(loc) } }, null, 2);
return `# Add to ${target}\n\n${snippet}\n`;
}
describePaths(loc: Location): string[] {
return [mcpJsonPath(loc)];
}
}
function writeMcpEntry(loc: Location): WriteResult['files'][number] {
const file = mcpJsonPath(loc);
const existed = fs.existsSync(file);
let text = readConfigText(file);
if (!text.trim()) text = '{}\n';
const config = parseConfig(text);
const before = config.servers?.codegraph;
const after = buildVscodeServerEntry(loc);
if (jsonDeepEqual(before, after)) {
return { path: file, action: 'unchanged' };
}
// Surgical edit — preserves comments, formatting, and sibling
// servers ("servers" is created when missing).
const edits = modify(text, ['servers', 'codegraph'], after, {
formattingOptions: FORMATTING,
});
const updated = applyEdits(text, edits);
atomicWriteFileSync(file, updated);
return { path: file, action: existed ? 'updated' : 'created' };
}
function removeMcpEntry(loc: Location): WriteResult['files'][number] {
const file = mcpJsonPath(loc);
if (!fs.existsSync(file)) return { path: file, action: 'not-found' };
const text = readConfigText(file);
const config = parseConfig(text);
if (!config.servers?.codegraph) return { path: file, action: 'not-found' };
let edits = modify(text, ['servers', 'codegraph'], undefined, {
formattingOptions: FORMATTING,
});
let updated = applyEdits(text, edits);
// Drop an emptied `servers` wrapper; the file itself is left in
// place — VS Code recreates/reads it and siblings like `inputs`
// may remain.
const afterParsed = parseConfig(updated);
if (afterParsed.servers && typeof afterParsed.servers === 'object' &&
Object.keys(afterParsed.servers).length === 0) {
edits = modify(updated, ['servers'], undefined, { formattingOptions: FORMATTING });
updated = applyEdits(updated, edits);
}
atomicWriteFileSync(file, updated);
return { path: file, action: 'removed' };
}
export const copilotVscodeTarget: AgentTarget = new CopilotVscodeTarget();
+6
View File
@@ -16,6 +16,9 @@ import { hermesTarget } from './hermes';
import { geminiTarget } from './gemini';
import { antigravityTarget } from './antigravity';
import { kiroTarget } from './kiro';
import { copilotVscodeTarget } from './copilot-vscode';
import { copilotCliTarget } from './copilot-cli';
import { copilotJetbrainsTarget } from './copilot-jetbrains';
export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([
claudeTarget,
@@ -26,6 +29,9 @@ export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([
geminiTarget,
antigravityTarget,
kiroTarget,
copilotVscodeTarget,
copilotCliTarget,
copilotJetbrainsTarget,
]);
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' | 'gemini' | 'antigravity' | 'kiro';
export type TargetId = 'claude' | 'cursor' | 'codex' | 'opencode' | 'hermes' | 'gemini' | 'antigravity' | 'kiro' | 'copilot-vscode' | 'copilot-cli' | 'copilot-jetbrains';
/**
* Result of `target.detect(location)`.