From a75940d3446bb106712c7e68c7bb2b60893d6472 Mon Sep 17 00:00:00 2001 From: Davide Consonni Date: Thu, 30 Jul 2026 14:09:10 +0200 Subject: [PATCH 1/2] feat(installer): add VS Code Copilot Chat target - Adds VSCodeTarget supporting both global and local install locations - Global: Code/User/profiles/default/mcp.json (platform-specific path, respects XDG_CONFIG_HOME on Linux and APPDATA on Windows) - Local: .vscode/mcp.json in the project root - Uses VS Code's 'servers' key (not 'mcpServers') with getMcpServerConfig() - No instructions file (VS Code has no AGENTS.md/CLAUDE.md equivalent) - Adds 'vscode' to TargetId union and ALL_TARGETS registry - Adds contract + partial-state tests (177 tests pass) --- CHANGELOG.md | 3 + __tests__/installer-targets.test.ts | 68 ++++++++++- src/installer/targets/registry.ts | 2 + src/installer/targets/types.ts | 2 +- src/installer/targets/vscode.ts | 174 ++++++++++++++++++++++++++++ 5 files changed, 246 insertions(+), 3 deletions(-) create mode 100644 src/installer/targets/vscode.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d2ae567d0..9bcfb80b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### New Features + +- `codegraph install` now supports VS Code Copilot Chat as a target (`--target=vscode`). Running the installer writes the MCP server entry to VS Code's user-profile `mcp.json` (global) or `.vscode/mcp.json` in the project root (local), making CodeGraph available in Copilot Chat agent mode immediately after restarting VS Code. ## [1.5.0] - 2026-07-21 diff --git a/__tests__/installer-targets.test.ts b/__tests__/installer-targets.test.ts index 6db793d65..7b3bb22c4 100644 --- a/__tests__/installer-targets.test.ts +++ b/__tests__/installer-targets.test.ts @@ -136,6 +136,11 @@ describe('Installer targets — contract', () => { delete seed.mcpServers; seed.mcp = { other: { type: 'local', command: ['x'], enabled: true } }; } + // vscode uses `servers` not `mcpServers`. + if (target.id === 'vscode') { + delete seed.mcpServers; + seed.servers = { other: { command: 'x' } }; + } fs.writeFileSync(jsonPath, JSON.stringify(seed, null, 2) + '\n'); target.install(location, { autoAllow: true }); @@ -144,6 +149,9 @@ describe('Installer targets — contract', () => { if (target.id === 'opencode') { expect(after.mcp.other).toBeDefined(); expect(after.mcp.codegraph).toBeDefined(); + } else if (target.id === 'vscode') { + expect(after.servers.other).toBeDefined(); + expect(after.servers.codegraph).toBeDefined(); } else { expect(after.mcpServers.other).toBeDefined(); expect(after.mcpServers.codegraph).toBeDefined(); @@ -501,6 +509,63 @@ describe('Installer targets — partial-state idempotency', () => { expect(paths.some((p) => p.endsWith('/.kiro/steering/codegraph.md'))).toBe(false); }); + it('vscode: global install writes profiles/default/mcp.json (servers.codegraph)', () => { + const vscode = getTarget('vscode')!; + const result = vscode.install('global', { autoAllow: true }); + expect(result.files.length).toBe(1); + expect(result.files[0].action).toBe('created'); + expect(result.files[0].path).toMatch(/profiles[/\\]default[/\\]mcp\.json$/); + const cfg = JSON.parse(fs.readFileSync(result.files[0].path, 'utf-8')); + expect(cfg.servers.codegraph).toEqual({ type: 'stdio', command: 'codegraph', args: ['serve', '--mcp'] }); + }); + + it('vscode: local install writes .vscode/mcp.json (servers.codegraph)', () => { + const vscode = getTarget('vscode')!; + const result = vscode.install('local', { autoAllow: true }); + expect(result.files.length).toBe(1); + expect(result.files[0].action).toBe('created'); + expect(result.files[0].path.replace(/\\/g, '/')).toMatch(/\/\.vscode\/mcp\.json$/); + const cfg = JSON.parse(fs.readFileSync(result.files[0].path, 'utf-8')); + expect(cfg.servers.codegraph).toEqual({ type: 'stdio', command: 'codegraph', args: ['serve', '--mcp'] }); + }); + + it('vscode: install preserves a pre-existing sibling in servers', () => { + const vscode = getTarget('vscode')!; + const mcpFile = path.join(tmpCwd, '.vscode', 'mcp.json'); + fs.mkdirSync(path.dirname(mcpFile), { recursive: true }); + fs.writeFileSync(mcpFile, JSON.stringify({ + servers: { playwright: { command: 'npx', args: ['-y', '@playwright/mcp@latest'] } }, + }, null, 2) + '\n'); + + vscode.install('local', { autoAllow: true }); + + const after = JSON.parse(fs.readFileSync(mcpFile, 'utf-8')); + expect(after.servers.playwright).toBeDefined(); + expect(after.servers.codegraph).toBeDefined(); + }); + + it('vscode: uninstall strips codegraph but leaves sibling servers intact', () => { + const vscode = getTarget('vscode')!; + const mcpFile = path.join(tmpCwd, '.vscode', 'mcp.json'); + fs.mkdirSync(path.dirname(mcpFile), { recursive: true }); + fs.writeFileSync(mcpFile, JSON.stringify({ + servers: { playwright: { command: 'npx', args: ['-y', '@playwright/mcp@latest'] } }, + }, null, 2) + '\n'); + + vscode.install('local', { autoAllow: true }); + vscode.uninstall('local'); + + const after = JSON.parse(fs.readFileSync(mcpFile, 'utf-8')); + expect(after.servers.playwright).toBeDefined(); + expect(after.servers.codegraph).toBeUndefined(); + }); + + it('vscode: install writes no instructions file (no AGENTS.md or similar)', () => { + const vscode = getTarget('vscode')!; + const result = vscode.install('global', { autoAllow: true }); + expect(result.files.every((f) => !f.path.endsWith('.md'))).toBe(true); + }); + it('antigravity: install writes to LEGACY ~/.gemini/antigravity/mcp_config.json when no migration marker', () => { const antigravity = getTarget('antigravity')!; antigravity.install('global', { autoAllow: true }); @@ -673,8 +738,7 @@ describe('Installer targets — partial-state idempotency', () => { expect(result.notes?.join(' ')).toMatch(/no project-local config/); }); - it('antigravity: does not write GEMINI.md (only gemini target owns instructions)', () => { - const antigravity = getTarget('antigravity')!; + it('antigravity: does not write GEMINI.md (only gemini target owns instructions)', () => { const antigravity = getTarget('antigravity')!; antigravity.install('global', { autoAllow: true }); const geminiMd = path.join(tmpHome, '.gemini', 'GEMINI.md'); expect(fs.existsSync(geminiMd)).toBe(false); diff --git a/src/installer/targets/registry.ts b/src/installer/targets/registry.ts index 5e929d468..affd60d8c 100644 --- a/src/installer/targets/registry.ts +++ b/src/installer/targets/registry.ts @@ -16,6 +16,7 @@ import { hermesTarget } from './hermes'; import { geminiTarget } from './gemini'; import { antigravityTarget } from './antigravity'; import { kiroTarget } from './kiro'; +import { vscodeTarget } from './vscode'; export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([ claudeTarget, @@ -26,6 +27,7 @@ export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([ geminiTarget, antigravityTarget, kiroTarget, + vscodeTarget, ]); export function getTarget(id: string): AgentTarget | undefined { diff --git a/src/installer/targets/types.ts b/src/installer/targets/types.ts index 833a801ae..193c1579b 100644 --- a/src/installer/targets/types.ts +++ b/src/installer/targets/types.ts @@ -19,7 +19,7 @@ export type Location = 'global' | 'local'; * lookup. New targets add a value here when they're added to the * registry. Keep these short and lowercase. */ -export type TargetId = 'claude' | 'cursor' | 'codex' | 'opencode' | 'hermes' | 'gemini' | 'antigravity' | 'kiro'; +export type TargetId = 'claude' | 'cursor' | 'codex' | 'opencode' | 'hermes' | 'gemini' | 'antigravity' | 'kiro' | 'vscode'; /** * Result of `target.detect(location)`. diff --git a/src/installer/targets/vscode.ts b/src/installer/targets/vscode.ts new file mode 100644 index 000000000..7d9fe0945 --- /dev/null +++ b/src/installer/targets/vscode.ts @@ -0,0 +1,174 @@ +/** + * VS Code Copilot Chat target. + * + * - MCP server entry to the VS Code user-profile `mcp.json` (global) or + * `.vscode/mcp.json` in the project root (local). VS Code uses the key + * `servers` (not `mcpServers`) in its MCP config files. + * + * Global path is platform-specific: + * - macOS: ~/Library/Application Support/Code/User/profiles/default/mcp.json + * - Linux: ~/.config/Code/User/profiles/default/mcp.json + * - Windows: %APPDATA%\Code\User\profiles\default\mcp.json + * + * Local path: .vscode/mcp.json in the current working directory. + * + * No permissions concept — VS Code gates tool invocations through its own + * trust prompts. `autoAllow` is silently ignored. + * + * No instructions file — VS Code Copilot Chat does not have an equivalent + * to CLAUDE.md / AGENTS.md / GEMINI.md. + * + * Docs: https://code.visualstudio.com/docs/copilot/chat/mcp-servers + */ + +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'; + +/** + * Resolve the VS Code user-profile MCP config path. + * + * VS Code stores per-user MCP configuration in the default profile directory. + * Users with custom profiles will need to add the entry manually via + * "MCP: Open User Configuration" in the VS Code command palette. + */ +function globalMcpPath(): string { + switch (process.platform) { + case 'win32': + return path.join( + process.env.APPDATA ?? path.join(os.homedir(), 'AppData', 'Roaming'), + 'Code', 'User', 'profiles', 'default', 'mcp.json', + ); + case 'darwin': + return path.join( + os.homedir(), 'Library', 'Application Support', + 'Code', 'User', 'profiles', 'default', 'mcp.json', + ); + default: // linux + return path.join( + process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), '.config'), + 'Code', 'User', 'profiles', 'default', 'mcp.json', + ); + } +} + +function localMcpPath(): string { + return path.join(process.cwd(), '.vscode', 'mcp.json'); +} + +function mcpPath(loc: Location): string { + return loc === 'global' ? globalMcpPath() : localMcpPath(); +} + +/** + * Heuristic: detect whether VS Code is installed by checking for the + * existence of its user-data directory root (`Code/User/`). + */ +function vscodeUserDir(): string { + switch (process.platform) { + case 'win32': + return path.join( + process.env.APPDATA ?? path.join(os.homedir(), 'AppData', 'Roaming'), + 'Code', 'User', + ); + case 'darwin': + return path.join(os.homedir(), 'Library', 'Application Support', 'Code', 'User'); + default: + return path.join( + process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), '.config'), + 'Code', 'User', + ); + } +} + +class VSCodeTarget implements AgentTarget { + readonly id = 'vscode' as const; + readonly displayName = 'VS Code Copilot Chat'; + readonly docsUrl = 'https://code.visualstudio.com/docs/copilot/chat/mcp-servers'; + + supportsLocation(_loc: Location): boolean { + return true; + } + + detect(loc: Location): DetectionResult { + const file = mcpPath(loc); + const config = readJsonFile(file); + const alreadyConfigured = !!config.servers?.codegraph; + const installed = loc === 'global' + ? fs.existsSync(vscodeUserDir()) || fs.existsSync(file) + : fs.existsSync(file) || 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 = mcpPath(loc); + const entry = getMcpServerConfig(); + const snippet = JSON.stringify({ servers: { codegraph: entry } }, null, 2); + return `# Add to ${target}\n\n${snippet}\n`; + } + + describePaths(loc: Location): string[] { + return [mcpPath(loc)]; + } +} + +function writeMcpEntry(loc: Location): WriteResult['files'][number] { + const file = mcpPath(loc); + const dir = path.dirname(file); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + const existing = readJsonFile(file); + const before = existing.servers?.codegraph; + const after = getMcpServerConfig(); + + if (jsonDeepEqual(before, after)) { + return { path: file, action: 'unchanged' }; + } + + const action: 'created' | 'updated' = + fs.existsSync(file) ? 'updated' : 'created'; + if (!existing.servers) existing.servers = {}; + existing.servers.codegraph = after; + writeJsonFile(file, existing); + return { path: file, action }; +} + +function removeMcpEntry(loc: Location): WriteResult['files'][number] { + const file = mcpPath(loc); + const config = readJsonFile(file); + if (!config.servers?.codegraph) { + return { path: file, action: 'not-found' }; + } + delete config.servers.codegraph; + if (Object.keys(config.servers).length === 0) { + delete config.servers; + } + writeJsonFile(file, config); + return { path: file, action: 'removed' }; +} + +export const vscodeTarget: AgentTarget = new VSCodeTarget(); From 73c390f8af9205950844809ca2f9725ebae3f8da Mon Sep 17 00:00:00 2001 From: Davide Consonni Date: Thu, 30 Jul 2026 14:25:17 +0200 Subject: [PATCH 2/2] feat(installer): add GitHub Copilot CLI target - Adds CopilotCliTarget supporting both global and local install locations - Global: ~/.copilot/mcp-config.json (same file the CLI uses) - Local: .github/mcp.json in the project root - Uses mcpServers key with type:'local' and tools:['*'] as required by Copilot CLI - Preserves existing sibling MCP servers (cobol, sap-docs, etc.) - Removes incorrect vscode target added by mistake - 176 tests pass --- CHANGELOG.md | 2 +- __tests__/installer-targets.test.ts | 64 ++++------ src/installer/targets/copilot.ts | 131 +++++++++++++++++++++ src/installer/targets/registry.ts | 4 +- src/installer/targets/types.ts | 2 +- src/installer/targets/vscode.ts | 174 ---------------------------- 6 files changed, 160 insertions(+), 217 deletions(-) create mode 100644 src/installer/targets/copilot.ts delete mode 100644 src/installer/targets/vscode.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bcfb80b1..4b2d3925a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### New Features -- `codegraph install` now supports VS Code Copilot Chat as a target (`--target=vscode`). Running the installer writes the MCP server entry to VS Code's user-profile `mcp.json` (global) or `.vscode/mcp.json` in the project root (local), making CodeGraph available in Copilot Chat agent mode immediately after restarting VS Code. +- `codegraph install` now supports GitHub Copilot CLI as a target (`--target=copilot`). Running the installer writes the MCP server entry to `~/.copilot/mcp-config.json` (global) or `.github/mcp.json` in the project root (local), making CodeGraph available immediately in `copilot` sessions. ## [1.5.0] - 2026-07-21 diff --git a/__tests__/installer-targets.test.ts b/__tests__/installer-targets.test.ts index 7b3bb22c4..e4c35ab9a 100644 --- a/__tests__/installer-targets.test.ts +++ b/__tests__/installer-targets.test.ts @@ -136,11 +136,6 @@ describe('Installer targets — contract', () => { delete seed.mcpServers; seed.mcp = { other: { type: 'local', command: ['x'], enabled: true } }; } - // vscode uses `servers` not `mcpServers`. - if (target.id === 'vscode') { - delete seed.mcpServers; - seed.servers = { other: { command: 'x' } }; - } fs.writeFileSync(jsonPath, JSON.stringify(seed, null, 2) + '\n'); target.install(location, { autoAllow: true }); @@ -149,9 +144,6 @@ describe('Installer targets — contract', () => { if (target.id === 'opencode') { expect(after.mcp.other).toBeDefined(); expect(after.mcp.codegraph).toBeDefined(); - } else if (target.id === 'vscode') { - expect(after.servers.other).toBeDefined(); - expect(after.servers.codegraph).toBeDefined(); } else { expect(after.mcpServers.other).toBeDefined(); expect(after.mcpServers.codegraph).toBeDefined(); @@ -509,61 +501,55 @@ describe('Installer targets — partial-state idempotency', () => { expect(paths.some((p) => p.endsWith('/.kiro/steering/codegraph.md'))).toBe(false); }); - it('vscode: global install writes profiles/default/mcp.json (servers.codegraph)', () => { - const vscode = getTarget('vscode')!; - const result = vscode.install('global', { autoAllow: true }); + it('copilot: global install writes ~/.copilot/mcp-config.json (mcpServers.codegraph)', () => { + const copilot = getTarget('copilot')!; + const result = copilot.install('global', { autoAllow: true }); expect(result.files.length).toBe(1); expect(result.files[0].action).toBe('created'); - expect(result.files[0].path).toMatch(/profiles[/\\]default[/\\]mcp\.json$/); + expect(result.files[0].path).toMatch(/\.copilot[/\\]mcp-config\.json$/); const cfg = JSON.parse(fs.readFileSync(result.files[0].path, 'utf-8')); - expect(cfg.servers.codegraph).toEqual({ type: 'stdio', command: 'codegraph', args: ['serve', '--mcp'] }); + expect(cfg.mcpServers.codegraph).toEqual({ type: 'local', command: 'codegraph', args: ['serve', '--mcp'], tools: ['*'] }); }); - it('vscode: local install writes .vscode/mcp.json (servers.codegraph)', () => { - const vscode = getTarget('vscode')!; - const result = vscode.install('local', { autoAllow: true }); + it('copilot: local install writes .github/mcp.json (mcpServers.codegraph)', () => { + const copilot = getTarget('copilot')!; + const result = copilot.install('local', { autoAllow: true }); expect(result.files.length).toBe(1); expect(result.files[0].action).toBe('created'); - expect(result.files[0].path.replace(/\\/g, '/')).toMatch(/\/\.vscode\/mcp\.json$/); + expect(result.files[0].path.replace(/\\/g, '/')).toMatch(/\/\.github\/mcp\.json$/); const cfg = JSON.parse(fs.readFileSync(result.files[0].path, 'utf-8')); - expect(cfg.servers.codegraph).toEqual({ type: 'stdio', command: 'codegraph', args: ['serve', '--mcp'] }); + expect(cfg.mcpServers.codegraph).toEqual({ type: 'local', command: 'codegraph', args: ['serve', '--mcp'], tools: ['*'] }); }); - it('vscode: install preserves a pre-existing sibling in servers', () => { - const vscode = getTarget('vscode')!; - const mcpFile = path.join(tmpCwd, '.vscode', 'mcp.json'); + it('copilot: install preserves pre-existing sibling in mcpServers', () => { + const copilot = getTarget('copilot')!; + const mcpFile = path.join(tmpHome, '.copilot', 'mcp-config.json'); fs.mkdirSync(path.dirname(mcpFile), { recursive: true }); fs.writeFileSync(mcpFile, JSON.stringify({ - servers: { playwright: { command: 'npx', args: ['-y', '@playwright/mcp@latest'] } }, + mcpServers: { other: { type: 'local', command: 'other', args: [], tools: ['*'] } }, }, null, 2) + '\n'); - vscode.install('local', { autoAllow: true }); + copilot.install('global', { autoAllow: true }); const after = JSON.parse(fs.readFileSync(mcpFile, 'utf-8')); - expect(after.servers.playwright).toBeDefined(); - expect(after.servers.codegraph).toBeDefined(); + expect(after.mcpServers.other).toBeDefined(); + expect(after.mcpServers.codegraph).toBeDefined(); }); - it('vscode: uninstall strips codegraph but leaves sibling servers intact', () => { - const vscode = getTarget('vscode')!; - const mcpFile = path.join(tmpCwd, '.vscode', 'mcp.json'); + it('copilot: uninstall strips codegraph but leaves sibling servers intact', () => { + const copilot = getTarget('copilot')!; + const mcpFile = path.join(tmpHome, '.copilot', 'mcp-config.json'); fs.mkdirSync(path.dirname(mcpFile), { recursive: true }); fs.writeFileSync(mcpFile, JSON.stringify({ - servers: { playwright: { command: 'npx', args: ['-y', '@playwright/mcp@latest'] } }, + mcpServers: { other: { type: 'local', command: 'other', args: [], tools: ['*'] } }, }, null, 2) + '\n'); - vscode.install('local', { autoAllow: true }); - vscode.uninstall('local'); + copilot.install('global', { autoAllow: true }); + copilot.uninstall('global'); const after = JSON.parse(fs.readFileSync(mcpFile, 'utf-8')); - expect(after.servers.playwright).toBeDefined(); - expect(after.servers.codegraph).toBeUndefined(); - }); - - it('vscode: install writes no instructions file (no AGENTS.md or similar)', () => { - const vscode = getTarget('vscode')!; - const result = vscode.install('global', { autoAllow: true }); - expect(result.files.every((f) => !f.path.endsWith('.md'))).toBe(true); + expect(after.mcpServers.other).toBeDefined(); + expect(after.mcpServers.codegraph).toBeUndefined(); }); it('antigravity: install writes to LEGACY ~/.gemini/antigravity/mcp_config.json when no migration marker', () => { diff --git a/src/installer/targets/copilot.ts b/src/installer/targets/copilot.ts new file mode 100644 index 000000000..8a5362704 --- /dev/null +++ b/src/installer/targets/copilot.ts @@ -0,0 +1,131 @@ +/** + * GitHub Copilot CLI target. + * + * - MCP server entry to `~/.copilot/mcp-config.json` (global) or + * `.github/mcp.json` in the project root (local) under the standard + * `mcpServers.codegraph` key. + * + * The Copilot CLI config uses `type: "local"` and requires a `tools` array + * (use `["*"]` to enable all tools). Both fields are mandatory for the CLI + * to start the server. + * + * Docs: https://docs.github.com/en/copilot/how-tos/copilot-cli/customize-copilot/add-mcp-servers + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { + AgentTarget, + DetectionResult, + InstallOptions, + Location, + WriteResult, +} from './types'; +import { + jsonDeepEqual, + readJsonFile, + writeJsonFile, +} from './shared'; + +function globalConfigDir(): string { + return path.join(os.homedir(), '.copilot'); +} + +function globalMcpPath(): string { + return path.join(globalConfigDir(), 'mcp-config.json'); +} + +function localMcpPath(): string { + return path.join(process.cwd(), '.github', 'mcp.json'); +} + +function mcpPath(loc: Location): string { + return loc === 'global' ? globalMcpPath() : localMcpPath(); +} + +function getCopilotServerEntry(): { type: string; command: string; args: string[]; tools: string[] } { + return { + type: 'local', + command: 'codegraph', + args: ['serve', '--mcp'], + tools: ['*'], + }; +} + +class CopilotCliTarget implements AgentTarget { + readonly id = 'copilot' 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 true; + } + + detect(loc: Location): DetectionResult { + const file = mcpPath(loc); + const config = readJsonFile(file); + const alreadyConfigured = !!config.mcpServers?.codegraph; + const installed = loc === 'global' + ? fs.existsSync(globalConfigDir()) || fs.existsSync(file) + : fs.existsSync(file) || fs.existsSync(path.join(process.cwd(), '.github')); + return { installed, alreadyConfigured, configPath: file }; + } + + install(loc: Location, _opts: InstallOptions): WriteResult { + return { + files: [writeMcpEntry(loc)], + }; + } + + uninstall(loc: Location): WriteResult { + return { files: [removeMcpEntry(loc)] }; + } + + printConfig(loc: Location): string { + const target = mcpPath(loc); + const snippet = JSON.stringify({ mcpServers: { codegraph: getCopilotServerEntry() } }, null, 2); + return `# Add to ${target}\n\n${snippet}\n`; + } + + describePaths(loc: Location): string[] { + return [mcpPath(loc)]; + } +} + +function writeMcpEntry(loc: Location): WriteResult['files'][number] { + const file = mcpPath(loc); + const dir = path.dirname(file); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + const existing = readJsonFile(file); + const before = existing.mcpServers?.codegraph; + const after = getCopilotServerEntry(); + + if (jsonDeepEqual(before, after)) { + return { path: file, action: 'unchanged' }; + } + + const action: 'created' | 'updated' = + fs.existsSync(file) ? 'updated' : 'created'; + if (!existing.mcpServers) existing.mcpServers = {}; + existing.mcpServers.codegraph = after; + writeJsonFile(file, existing); + return { path: file, action }; +} + +function removeMcpEntry(loc: Location): WriteResult['files'][number] { + const file = mcpPath(loc); + const config = readJsonFile(file); + if (!config.mcpServers?.codegraph) { + return { path: file, action: 'not-found' }; + } + delete config.mcpServers.codegraph; + if (Object.keys(config.mcpServers).length === 0) { + delete config.mcpServers; + } + writeJsonFile(file, config); + return { path: file, action: 'removed' }; +} + +export const copilotTarget: AgentTarget = new CopilotCliTarget(); diff --git a/src/installer/targets/registry.ts b/src/installer/targets/registry.ts index affd60d8c..e12c3056c 100644 --- a/src/installer/targets/registry.ts +++ b/src/installer/targets/registry.ts @@ -16,7 +16,7 @@ import { hermesTarget } from './hermes'; import { geminiTarget } from './gemini'; import { antigravityTarget } from './antigravity'; import { kiroTarget } from './kiro'; -import { vscodeTarget } from './vscode'; +import { copilotTarget } from './copilot'; export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([ claudeTarget, @@ -27,7 +27,7 @@ export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([ geminiTarget, antigravityTarget, kiroTarget, - vscodeTarget, + copilotTarget, ]); export function getTarget(id: string): AgentTarget | undefined { diff --git a/src/installer/targets/types.ts b/src/installer/targets/types.ts index 193c1579b..8dd7d0ab2 100644 --- a/src/installer/targets/types.ts +++ b/src/installer/targets/types.ts @@ -19,7 +19,7 @@ export type Location = 'global' | 'local'; * lookup. New targets add a value here when they're added to the * registry. Keep these short and lowercase. */ -export type TargetId = 'claude' | 'cursor' | 'codex' | 'opencode' | 'hermes' | 'gemini' | 'antigravity' | 'kiro' | 'vscode'; +export type TargetId = 'claude' | 'cursor' | 'codex' | 'opencode' | 'hermes' | 'gemini' | 'antigravity' | 'kiro' | 'copilot'; /** * Result of `target.detect(location)`. diff --git a/src/installer/targets/vscode.ts b/src/installer/targets/vscode.ts deleted file mode 100644 index 7d9fe0945..000000000 --- a/src/installer/targets/vscode.ts +++ /dev/null @@ -1,174 +0,0 @@ -/** - * VS Code Copilot Chat target. - * - * - MCP server entry to the VS Code user-profile `mcp.json` (global) or - * `.vscode/mcp.json` in the project root (local). VS Code uses the key - * `servers` (not `mcpServers`) in its MCP config files. - * - * Global path is platform-specific: - * - macOS: ~/Library/Application Support/Code/User/profiles/default/mcp.json - * - Linux: ~/.config/Code/User/profiles/default/mcp.json - * - Windows: %APPDATA%\Code\User\profiles\default\mcp.json - * - * Local path: .vscode/mcp.json in the current working directory. - * - * No permissions concept — VS Code gates tool invocations through its own - * trust prompts. `autoAllow` is silently ignored. - * - * No instructions file — VS Code Copilot Chat does not have an equivalent - * to CLAUDE.md / AGENTS.md / GEMINI.md. - * - * Docs: https://code.visualstudio.com/docs/copilot/chat/mcp-servers - */ - -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'; - -/** - * Resolve the VS Code user-profile MCP config path. - * - * VS Code stores per-user MCP configuration in the default profile directory. - * Users with custom profiles will need to add the entry manually via - * "MCP: Open User Configuration" in the VS Code command palette. - */ -function globalMcpPath(): string { - switch (process.platform) { - case 'win32': - return path.join( - process.env.APPDATA ?? path.join(os.homedir(), 'AppData', 'Roaming'), - 'Code', 'User', 'profiles', 'default', 'mcp.json', - ); - case 'darwin': - return path.join( - os.homedir(), 'Library', 'Application Support', - 'Code', 'User', 'profiles', 'default', 'mcp.json', - ); - default: // linux - return path.join( - process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), '.config'), - 'Code', 'User', 'profiles', 'default', 'mcp.json', - ); - } -} - -function localMcpPath(): string { - return path.join(process.cwd(), '.vscode', 'mcp.json'); -} - -function mcpPath(loc: Location): string { - return loc === 'global' ? globalMcpPath() : localMcpPath(); -} - -/** - * Heuristic: detect whether VS Code is installed by checking for the - * existence of its user-data directory root (`Code/User/`). - */ -function vscodeUserDir(): string { - switch (process.platform) { - case 'win32': - return path.join( - process.env.APPDATA ?? path.join(os.homedir(), 'AppData', 'Roaming'), - 'Code', 'User', - ); - case 'darwin': - return path.join(os.homedir(), 'Library', 'Application Support', 'Code', 'User'); - default: - return path.join( - process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), '.config'), - 'Code', 'User', - ); - } -} - -class VSCodeTarget implements AgentTarget { - readonly id = 'vscode' as const; - readonly displayName = 'VS Code Copilot Chat'; - readonly docsUrl = 'https://code.visualstudio.com/docs/copilot/chat/mcp-servers'; - - supportsLocation(_loc: Location): boolean { - return true; - } - - detect(loc: Location): DetectionResult { - const file = mcpPath(loc); - const config = readJsonFile(file); - const alreadyConfigured = !!config.servers?.codegraph; - const installed = loc === 'global' - ? fs.existsSync(vscodeUserDir()) || fs.existsSync(file) - : fs.existsSync(file) || 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 = mcpPath(loc); - const entry = getMcpServerConfig(); - const snippet = JSON.stringify({ servers: { codegraph: entry } }, null, 2); - return `# Add to ${target}\n\n${snippet}\n`; - } - - describePaths(loc: Location): string[] { - return [mcpPath(loc)]; - } -} - -function writeMcpEntry(loc: Location): WriteResult['files'][number] { - const file = mcpPath(loc); - const dir = path.dirname(file); - if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); - - const existing = readJsonFile(file); - const before = existing.servers?.codegraph; - const after = getMcpServerConfig(); - - if (jsonDeepEqual(before, after)) { - return { path: file, action: 'unchanged' }; - } - - const action: 'created' | 'updated' = - fs.existsSync(file) ? 'updated' : 'created'; - if (!existing.servers) existing.servers = {}; - existing.servers.codegraph = after; - writeJsonFile(file, existing); - return { path: file, action }; -} - -function removeMcpEntry(loc: Location): WriteResult['files'][number] { - const file = mcpPath(loc); - const config = readJsonFile(file); - if (!config.servers?.codegraph) { - return { path: file, action: 'not-found' }; - } - delete config.servers.codegraph; - if (Object.keys(config.servers).length === 0) { - delete config.servers; - } - writeJsonFile(file, config); - return { path: file, action: 'removed' }; -} - -export const vscodeTarget: AgentTarget = new VSCodeTarget();