Merge pull request #1440 from colbymchenry/feat/copilot-installer-targets
feat(installer): GitHub Copilot targets — VS Code, Copilot CLI, JetBrains
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
[](#supported-agents)
|
||||
[](#supported-agents)
|
||||
[](#supported-agents)
|
||||
[](#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)
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
@@ -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,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();
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)`.
|
||||
|
||||
Reference in New Issue
Block a user