Jelajahi Sumber

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

feat(installer): GitHub Copilot targets — VS Code, Copilot CLI, JetBrains
Colby Mchenry 1 bulan lalu
induk
melakukan
222f82b9a5

+ 2 - 0
CHANGELOG.md

@@ -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
README.md

@@ -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 - 0
__tests__/installer-targets.test.ts

@@ -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
src/bin/codegraph.ts

@@ -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
src/installer/index.ts

@@ -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 - 0
src/installer/targets/copilot-cli.ts

@@ -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 - 0
src/installer/targets/copilot-jetbrains.ts

@@ -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 - 0
src/installer/targets/copilot-vscode.ts

@@ -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 - 0
src/installer/targets/registry.ts

@@ -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
src/installer/targets/types.ts

@@ -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)`.