feat(installer): GitHub Copilot targets — VS Code, Copilot CLI, JetBrains
Adds three new installer targets so `codegraph install` can wire the
MCP server into GitHub Copilot surfaces:
- copilot-vscode: .vscode/mcp.json (local) or the VS Code User-dir
mcp.json (global), JSONC-surgical edits, `--path` pinned via
${workspaceFolder} for global installs
- copilot-cli: ~/.copilot/mcp-config.json
- copilot-jetbrains: github-copilot config dir (XDG / %LOCALAPPDATA%)
Detection, install, uninstall, and --print-config are covered for all
three in installer-targets.test.ts, including platform-specific path
resolution.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
572d22bfbe
commit
490791c07a
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* VS Code (GitHub Copilot Chat) target.
|
||||
*
|
||||
* - MCP server entry to `.vscode/mcp.json` (local, workspace-scoped)
|
||||
* or the user-level `mcp.json` in the VS Code User dir (global):
|
||||
*
|
||||
* macOS: ~/Library/Application Support/Code/User/mcp.json
|
||||
* Windows: %APPDATA%\Code\User\mcp.json
|
||||
* Linux: $XDG_CONFIG_HOME|~/.config/Code/User/mcp.json
|
||||
*
|
||||
* VS Code moved MCP config out of settings.json into this dedicated
|
||||
* `mcp.json` (v1.102, "MCP: Open User Configuration"). Shape is
|
||||
* `{ "servers": { "<name>": { "type": "stdio", "command", "args" } } }`
|
||||
* — note `servers`, not the `mcpServers` wrapper Claude/Cursor use.
|
||||
* - No instructions file: Copilot Chat consumes the MCP `initialize`
|
||||
* instructions, the single source of truth (#529).
|
||||
* - No permissions concept — `autoAllow` is silently ignored.
|
||||
*
|
||||
* ## Why we inject `--path` (mirrors Cursor)
|
||||
*
|
||||
* VS Code's docs don't specify the working directory stdio MCP servers
|
||||
* are launched with, and (like Cursor) we can't rely on it being the
|
||||
* workspace root. Rather than depend on undocumented cwd behavior we
|
||||
* pin the project explicitly:
|
||||
*
|
||||
* - `local` install: absolute path (known at install time).
|
||||
* - `global` install: `${workspaceFolder}` — VS Code expands its
|
||||
* standard variables inside mcp.json, giving per-workspace behavior
|
||||
* from a single user-level config.
|
||||
*
|
||||
* ## JSONC
|
||||
*
|
||||
* VS Code parses its config files as JSONC (comments + trailing commas
|
||||
* allowed), so reads + writes go through `jsonc-parser` — surgical
|
||||
* edits that preserve sibling servers, user comments, and formatting
|
||||
* across install / re-install / uninstall (same approach as opencode).
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { parse as parseJsonc, modify, applyEdits } from 'jsonc-parser';
|
||||
import {
|
||||
AgentTarget,
|
||||
DetectionResult,
|
||||
InstallOptions,
|
||||
Location,
|
||||
WriteResult,
|
||||
} from './types';
|
||||
import {
|
||||
atomicWriteFileSync,
|
||||
getMcpServerConfig,
|
||||
jsonDeepEqual,
|
||||
} from './shared';
|
||||
|
||||
function vscodeUserDir(): string {
|
||||
const home = os.homedir();
|
||||
if (process.platform === 'win32') {
|
||||
const appData = process.env.APPDATA && process.env.APPDATA.trim().length > 0
|
||||
? process.env.APPDATA
|
||||
: path.join(home, 'AppData', 'Roaming');
|
||||
return path.join(appData, 'Code', 'User');
|
||||
}
|
||||
if (process.platform === 'darwin') {
|
||||
return path.join(home, 'Library', 'Application Support', 'Code', 'User');
|
||||
}
|
||||
const xdg = process.env.XDG_CONFIG_HOME && process.env.XDG_CONFIG_HOME.trim().length > 0
|
||||
? process.env.XDG_CONFIG_HOME
|
||||
: path.join(home, '.config');
|
||||
return path.join(xdg, 'Code', 'User');
|
||||
}
|
||||
|
||||
function mcpJsonPath(loc: Location): string {
|
||||
return loc === 'global'
|
||||
? path.join(vscodeUserDir(), 'mcp.json')
|
||||
: path.join(process.cwd(), '.vscode', 'mcp.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the codegraph server entry for VS Code at the given location.
|
||||
* Shared `{type, command, args}` shape plus the `--path` pin — see
|
||||
* file header for why we don't trust VS Code's launch cwd.
|
||||
*/
|
||||
function buildVscodeServerEntry(loc: Location): { type: string; command: string; args: string[] } {
|
||||
const base = getMcpServerConfig();
|
||||
const pathArg = loc === 'local' ? process.cwd() : '${workspaceFolder}';
|
||||
return { ...base, args: [...base.args, '--path', pathArg] };
|
||||
}
|
||||
|
||||
function readConfigText(file: string): string {
|
||||
if (!fs.existsSync(file)) return '';
|
||||
return fs.readFileSync(file, 'utf-8');
|
||||
}
|
||||
|
||||
function parseConfig(text: string): Record<string, any> {
|
||||
if (!text.trim()) return {};
|
||||
const errors: any[] = [];
|
||||
const result = parseJsonc(text, errors, { allowTrailingComma: true });
|
||||
if (result == null || typeof result !== 'object' || Array.isArray(result)) {
|
||||
return {};
|
||||
}
|
||||
return result as Record<string, any>;
|
||||
}
|
||||
|
||||
const FORMATTING = { tabSize: 2, insertSpaces: true, eol: '\n' };
|
||||
|
||||
class CopilotVscodeTarget implements AgentTarget {
|
||||
readonly id = 'copilot-vscode' as const;
|
||||
readonly displayName = 'VS Code (Copilot Chat)';
|
||||
readonly docsUrl = 'https://code.visualstudio.com/docs/copilot/customization/mcp-servers';
|
||||
|
||||
supportsLocation(_loc: Location): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
detect(loc: Location): DetectionResult {
|
||||
const file = mcpJsonPath(loc);
|
||||
const config = parseConfig(readConfigText(file));
|
||||
const alreadyConfigured = !!config.servers?.codegraph;
|
||||
// "Installed" heuristic: the VS Code User dir (created on first
|
||||
// launch) or ~/.vscode (extensions dir) for global; an existing
|
||||
// .vscode/ dir in the project for local.
|
||||
const installed = loc === 'global'
|
||||
? fs.existsSync(vscodeUserDir()) || fs.existsSync(path.join(os.homedir(), '.vscode'))
|
||||
: fs.existsSync(path.join(process.cwd(), '.vscode'));
|
||||
return { installed, alreadyConfigured, configPath: file };
|
||||
}
|
||||
|
||||
install(loc: Location, _opts: InstallOptions): WriteResult {
|
||||
return {
|
||||
files: [writeMcpEntry(loc)],
|
||||
notes: ['Restart VS Code for MCP changes to take effect.'],
|
||||
};
|
||||
}
|
||||
|
||||
uninstall(loc: Location): WriteResult {
|
||||
return { files: [removeMcpEntry(loc)] };
|
||||
}
|
||||
|
||||
printConfig(loc: Location): string {
|
||||
const target = mcpJsonPath(loc);
|
||||
const snippet = JSON.stringify({ servers: { codegraph: buildVscodeServerEntry(loc) } }, null, 2);
|
||||
return `# Add to ${target}\n\n${snippet}\n`;
|
||||
}
|
||||
|
||||
describePaths(loc: Location): string[] {
|
||||
return [mcpJsonPath(loc)];
|
||||
}
|
||||
}
|
||||
|
||||
function writeMcpEntry(loc: Location): WriteResult['files'][number] {
|
||||
const file = mcpJsonPath(loc);
|
||||
const existed = fs.existsSync(file);
|
||||
let text = readConfigText(file);
|
||||
if (!text.trim()) text = '{}\n';
|
||||
|
||||
const config = parseConfig(text);
|
||||
const before = config.servers?.codegraph;
|
||||
const after = buildVscodeServerEntry(loc);
|
||||
|
||||
if (jsonDeepEqual(before, after)) {
|
||||
return { path: file, action: 'unchanged' };
|
||||
}
|
||||
|
||||
// Surgical edit — preserves comments, formatting, and sibling
|
||||
// servers ("servers" is created when missing).
|
||||
const edits = modify(text, ['servers', 'codegraph'], after, {
|
||||
formattingOptions: FORMATTING,
|
||||
});
|
||||
const updated = applyEdits(text, edits);
|
||||
atomicWriteFileSync(file, updated);
|
||||
|
||||
return { path: file, action: existed ? 'updated' : 'created' };
|
||||
}
|
||||
|
||||
function removeMcpEntry(loc: Location): WriteResult['files'][number] {
|
||||
const file = mcpJsonPath(loc);
|
||||
if (!fs.existsSync(file)) return { path: file, action: 'not-found' };
|
||||
const text = readConfigText(file);
|
||||
const config = parseConfig(text);
|
||||
if (!config.servers?.codegraph) return { path: file, action: 'not-found' };
|
||||
|
||||
let edits = modify(text, ['servers', 'codegraph'], undefined, {
|
||||
formattingOptions: FORMATTING,
|
||||
});
|
||||
let updated = applyEdits(text, edits);
|
||||
|
||||
// Drop an emptied `servers` wrapper; the file itself is left in
|
||||
// place — VS Code recreates/reads it and siblings like `inputs`
|
||||
// may remain.
|
||||
const afterParsed = parseConfig(updated);
|
||||
if (afterParsed.servers && typeof afterParsed.servers === 'object' &&
|
||||
Object.keys(afterParsed.servers).length === 0) {
|
||||
edits = modify(updated, ['servers'], undefined, { formattingOptions: FORMATTING });
|
||||
updated = applyEdits(updated, edits);
|
||||
}
|
||||
|
||||
atomicWriteFileSync(file, updated);
|
||||
return { path: file, action: 'removed' };
|
||||
}
|
||||
|
||||
export const copilotVscodeTarget: AgentTarget = new CopilotVscodeTarget();
|
||||
Reference in New Issue
Block a user