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:
co-authored by
Claude Fable 5
parent
572d22bfbe
commit
490791c07a
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
|||||||
[](#supported-agents)
|
[](#supported-agents)
|
||||||
[](#supported-agents)
|
[](#supported-agents)
|
||||||
[](#supported-agents)
|
[](#supported-agents)
|
||||||
|
[](#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)
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -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();
|
||||||
@@ -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();
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)`.
|
||||||
|
|||||||
Reference in New Issue
Block a user