diff --git a/CHANGELOG.md b/CHANGELOG.md index d2ae567d0..4b2d3925a 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 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 6db793d65..e4c35ab9a 100644 --- a/__tests__/installer-targets.test.ts +++ b/__tests__/installer-targets.test.ts @@ -501,6 +501,57 @@ describe('Installer targets — partial-state idempotency', () => { expect(paths.some((p) => p.endsWith('/.kiro/steering/codegraph.md'))).toBe(false); }); + 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(/\.copilot[/\\]mcp-config\.json$/); + const cfg = JSON.parse(fs.readFileSync(result.files[0].path, 'utf-8')); + expect(cfg.mcpServers.codegraph).toEqual({ type: 'local', command: 'codegraph', args: ['serve', '--mcp'], tools: ['*'] }); + }); + + 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(/\/\.github\/mcp\.json$/); + const cfg = JSON.parse(fs.readFileSync(result.files[0].path, 'utf-8')); + expect(cfg.mcpServers.codegraph).toEqual({ type: 'local', command: 'codegraph', args: ['serve', '--mcp'], tools: ['*'] }); + }); + + 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({ + mcpServers: { other: { type: 'local', command: 'other', args: [], tools: ['*'] } }, + }, null, 2) + '\n'); + + copilot.install('global', { autoAllow: true }); + + const after = JSON.parse(fs.readFileSync(mcpFile, 'utf-8')); + expect(after.mcpServers.other).toBeDefined(); + expect(after.mcpServers.codegraph).toBeDefined(); + }); + + 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({ + mcpServers: { other: { type: 'local', command: 'other', args: [], tools: ['*'] } }, + }, null, 2) + '\n'); + + copilot.install('global', { autoAllow: true }); + copilot.uninstall('global'); + + const after = JSON.parse(fs.readFileSync(mcpFile, 'utf-8')); + 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', () => { const antigravity = getTarget('antigravity')!; antigravity.install('global', { autoAllow: true }); @@ -673,8 +724,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/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 5e929d468..e12c3056c 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 { copilotTarget } from './copilot'; export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([ claudeTarget, @@ -26,6 +27,7 @@ export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([ geminiTarget, antigravityTarget, kiroTarget, + copilotTarget, ]); export function getTarget(id: string): AgentTarget | undefined { diff --git a/src/installer/targets/types.ts b/src/installer/targets/types.ts index 833a801ae..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'; +export type TargetId = 'claude' | 'cursor' | 'codex' | 'opencode' | 'hermes' | 'gemini' | 'antigravity' | 'kiro' | 'copilot'; /** * Result of `target.detect(location)`.