feat(installer): GitHub Copilot targets — VS Code, Copilot CLI, JetBrains

Adds three new installer targets so `codegraph install` can wire the
MCP server into GitHub Copilot surfaces:

- copilot-vscode: .vscode/mcp.json (local) or the VS Code User-dir
  mcp.json (global), JSONC-surgical edits, `--path` pinned via
  ${workspaceFolder} for global installs
- copilot-cli: ~/.copilot/mcp-config.json
- copilot-jetbrains: github-copilot config dir (XDG / %LOCALAPPDATA%)

Detection, install, uninstall, and --print-config are covered for all
three in installer-targets.test.ts, including platform-specific path
resolution.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Colby McHenry
2026-07-23 17:29:38 -05:00
co-authored by Claude Fable 5
parent 572d22bfbe
commit 490791c07a
10 changed files with 1124 additions and 11 deletions
+3
View File
@@ -9,6 +9,9 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased] ## [Unreleased]
### New Features
- 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.
## [1.5.0] - 2026-07-21 ## [1.5.0] - 2026-07-21
+9 -5
View File
@@ -6,7 +6,7 @@ Already installed? Run `codegraph upgrade`
Follow [@getcodegraph](https://x.com/getcodegraph) on X for updates. 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** **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) [![Gemini](https://img.shields.io/badge/Gemini-supported-blueviolet.svg)](#supported-agents)
[![Antigravity](https://img.shields.io/badge/Antigravity-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) [![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> <br>
@@ -104,7 +105,7 @@ In a **new terminal**, run the installer to connect CodeGraph to the agents you
codegraph install 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 ### 3. Initialize each project
@@ -371,7 +372,7 @@ 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**, **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) - 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, 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`. - 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`.
@@ -385,7 +386,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 --yes # auto-detect agents, install global
codegraph install --target=cursor,claude --yes # explicit target list codegraph install --target=cursor,claude --yes # explicit target list
codegraph install --target=auto --location=local # detected agents, project-local 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 codex # print snippet, no file writes
codegraph install --print-config copilot-vscode # same, for Copilot in VS Code
``` ```
| Flag | Values | Default | | Flag | Values | Default |
@@ -398,7 +401,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 / 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 ### 3. Initialize Projects
@@ -756,6 +759,7 @@ is written):
- **Gemini CLI** - **Gemini CLI**
- **Antigravity IDE** - **Antigravity IDE**
- **Kiro** - **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 ## Supported Languages
@@ -854,7 +858,7 @@ MIT
<div align="center"> <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) [Report Bug](https://github.com/colbymchenry/codegraph/issues) · [Request Feature](https://github.com/colbymchenry/codegraph/issues)
+501
View File
@@ -18,6 +18,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import * as os from 'os'; import * as os from 'os';
import { parse as parseJsonc } from 'jsonc-parser';
import { ALL_TARGETS, getTarget, resolveTargetFlag } from '../src/installer/targets/registry'; import { ALL_TARGETS, getTarget, resolveTargetFlag } from '../src/installer/targets/registry';
import { uninstallTargets, refreshTargets } from '../src/installer'; import { uninstallTargets, refreshTargets } from '../src/installer';
import { upsertTomlTable, removeTomlTable, buildTomlTable } from '../src/installer/targets/toml'; import { upsertTomlTable, removeTomlTable, buildTomlTable } from '../src/installer/targets/toml';
@@ -38,12 +39,14 @@ function setHome(dir: string): { restore: () => void } {
APPDATA: process.env.APPDATA, APPDATA: process.env.APPDATA,
XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME, XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME,
HERMES_HOME: process.env.HERMES_HOME, HERMES_HOME: process.env.HERMES_HOME,
COPILOT_HOME: process.env.COPILOT_HOME,
}; };
process.env.HOME = dir; process.env.HOME = dir;
process.env.USERPROFILE = dir; process.env.USERPROFILE = dir;
process.env.APPDATA = path.join(dir, '.config'); process.env.APPDATA = path.join(dir, '.config');
process.env.XDG_CONFIG_HOME = path.join(dir, '.config'); process.env.XDG_CONFIG_HOME = path.join(dir, '.config');
delete process.env.HERMES_HOME; delete process.env.HERMES_HOME;
delete process.env.COPILOT_HOME;
return { return {
restore() { restore() {
if (prev.HOME === undefined) delete process.env.HOME; else process.env.HOME = prev.HOME; 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.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.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.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; delete seed.mcpServers;
seed.mcp = { other: { type: 'local', command: ['x'], enabled: true } }; 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'); fs.writeFileSync(jsonPath, JSON.stringify(seed, null, 2) + '\n');
target.install(location, { autoAllow: true }); target.install(location, { autoAllow: true });
@@ -144,6 +154,9 @@ describe('Installer targets — contract', () => {
if (target.id === 'opencode') { if (target.id === 'opencode') {
expect(after.mcp.other).toBeDefined(); expect(after.mcp.other).toBeDefined();
expect(after.mcp.codegraph).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 { } else {
expect(after.mcpServers.other).toBeDefined(); expect(after.mcpServers.other).toBeDefined();
expect(after.mcpServers.codegraph).toBeDefined(); expect(after.mcpServers.codegraph).toBeDefined();
@@ -1229,6 +1242,9 @@ describe('Installer targets — registry', () => {
expect(getTarget('gemini')?.id).toBe('gemini'); expect(getTarget('gemini')?.id).toBe('gemini');
expect(getTarget('antigravity')?.id).toBe('antigravity'); expect(getTarget('antigravity')?.id).toBe('antigravity');
expect(getTarget('kiro')?.id).toBe('kiro'); 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(); expect(getTarget('not-a-real-target')).toBeUndefined();
}); });
@@ -1239,6 +1255,18 @@ describe('Installer targets — registry', () => {
expect(csv.map((t) => t.id)).toEqual(['claude', 'cursor']); 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', () => { it('resolveTargetFlag throws on unknown id', () => {
expect(() => resolveTargetFlag('claude,bogus', 'global')).toThrow(/Unknown --target/); expect(() => resolveTargetFlag('claude,bogus', 'global')).toThrow(/Unknown --target/);
}); });
@@ -1858,3 +1886,476 @@ describe('Installer targets — opencode XDG config path (#535)', () => {
expect(opencode.detect('global').alreadyConfigured).toBe(false); 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 pins --path to ${workspaceFolder}', () => {
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', '--path', '${workspaceFolder}']);
});
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 drops an emptied mcpServers wrapper', () => {
const t = getTarget('copilot-cli')!;
t.install('global', { autoAllow: true });
t.uninstall('global');
const file = path.join(tmpHome, '.copilot', 'mcp-config.json');
const after = JSON.parse(fs.readFileSync(file, 'utf-8'));
expect(after.mcpServers).toBeUndefined();
});
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 the ~/.copilot dir alone', () => {
const t = getTarget('copilot-cli')!;
// The tmp PATH may or may not carry a real `copilot` binary; only
// assert the positive signal we control.
fs.mkdirSync(path.join(tmpHome, '.copilot'), { recursive: true });
expect(t.detect('global').installed).toBe(true);
expect(t.detect('global').alreadyConfigured).toBe(false);
});
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
@@ -2232,7 +2232,7 @@ program
*/ */
program program
.command('install') .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('-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('-l, --location <where>', 'Install location: "global" or "local". Default: prompt')
.option('-y, --yes', 'Non-interactive: defaults to --location=global --target=auto, auto-allow on') .option('-y, --yes', 'Non-interactive: defaults to --location=global --target=auto, auto-allow on')
@@ -2332,7 +2332,7 @@ program
*/ */
program program
.command('uninstall') .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('-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('-l, --location <where>', 'Uninstall location: "global" or "local". Default: prompt')
.option('-y, --yes', 'Non-interactive: defaults to --location=global --target=all') .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 * 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, 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 * 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.
* *
@@ -467,8 +468,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, ~/.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, ./opencode.jsonc, ./.gemini, ./.kiro' }, { value: 'local' as const, label: 'Just this project (local)', hint: './.claude, ./.cursor, ./.vscode, ./opencode.jsonc, ./.gemini, ./.kiro' },
], ],
initialValue: 'global' as const, initialValue: 'global' as const,
}); });
+166
View File
@@ -0,0 +1,166 @@
/**
* 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');
}
/**
* 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 = fs.existsSync(configDir()) || 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;
}
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();
+202
View File
@@ -0,0 +1,202 @@
/**
* 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 we inject `--path` (mirrors Cursor)
*
* VS Code's docs don't specify the working directory stdio MCP servers
* are launched with, and (like Cursor) we can't rely on it being the
* workspace root. Rather than depend on undocumented cwd behavior we
* pin the project explicitly:
*
* - `local` install: absolute path (known at install time).
* - `global` install: `${workspaceFolder}` VS Code expands its
* standard variables inside mcp.json, giving per-workspace behavior
* from a single user-level config.
*
* ## 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.
* Shared `{type, command, args}` shape plus the `--path` pin see
* file header for why we don't trust VS Code's launch cwd.
*/
function buildVscodeServerEntry(loc: Location): { type: string; command: string; args: string[] } {
const base = getMcpServerConfig();
const pathArg = loc === 'local' ? process.cwd() : '${workspaceFolder}';
return { ...base, args: [...base.args, '--path', pathArg] };
}
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 { geminiTarget } from './gemini';
import { antigravityTarget } from './antigravity'; import { antigravityTarget } from './antigravity';
import { kiroTarget } from './kiro'; 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([ export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([
claudeTarget, claudeTarget,
@@ -26,6 +29,9 @@ export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([
geminiTarget, geminiTarget,
antigravityTarget, antigravityTarget,
kiroTarget, kiroTarget,
copilotVscodeTarget,
copilotCliTarget,
copilotJetbrainsTarget,
]); ]);
export function getTarget(id: string): AgentTarget | undefined { 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 * 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' | '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)`. * Result of `target.detect(location)`.