diff --git a/__tests__/installer-targets.test.ts b/__tests__/installer-targets.test.ts index 27fcbd6e8..63ec3dbe5 100644 --- a/__tests__/installer-targets.test.ts +++ b/__tests__/installer-targets.test.ts @@ -1067,6 +1067,127 @@ describe('Installer targets — partial-state idempotency', () => { expect(fs.readFileSync(file, 'utf-8')).toBe(firstPass); }); + it('copilot: global install writes ~/.copilot/mcp-config.json with type "local" + tools ["*"]', () => { + const copilot = getTarget('copilot')!; + const result = copilot.install('global', { autoAllow: true }); + const mcp = path.join(tmpHome, '.copilot', 'mcp-config.json'); + expect(result.files.some((f) => f.path === mcp)).toBe(true); + + const cfg = JSON.parse(fs.readFileSync(mcp, 'utf-8')); + expect(cfg.mcpServers.codegraph).toEqual({ + type: 'local', + command: 'codegraph', + args: ['serve', '--mcp'], + tools: ['*'], + }); + }); + + it('copilot: local install writes ./.mcp.json with type "stdio" (no tools field)', () => { + const copilot = getTarget('copilot')!; + const result = copilot.install('local', { autoAllow: true }); + const paths = result.files.map((f) => f.path.replace(/\\/g, '/')); + // macOS realpath: use suffix match like the kiro local test. + expect(paths.some((p) => p.endsWith('/.mcp.json'))).toBe(true); + + const mcpFile = path.join(tmpCwd, '.mcp.json'); + const cfg = JSON.parse(fs.readFileSync(mcpFile, 'utf-8')); + expect(cfg.mcpServers.codegraph).toEqual({ + type: 'stdio', + command: 'codegraph', + args: ['serve', '--mcp'], + }); + // No `tools` field in local config — it's shared with Claude Code. + expect(cfg.mcpServers.codegraph.tools).toBeUndefined(); + }); + + it('copilot: global install creates ~/.copilot/ directory if missing', () => { + const copilot = getTarget('copilot')!; + const copilotDir = path.join(tmpHome, '.copilot'); + expect(fs.existsSync(copilotDir)).toBe(false); + + copilot.install('global', { autoAllow: true }); + + expect(fs.existsSync(copilotDir)).toBe(true); + expect(fs.existsSync(path.join(copilotDir, 'mcp-config.json'))).toBe(true); + }); + + it('copilot: install preserves a pre-existing sibling MCP server in mcp-config.json', () => { + const copilot = getTarget('copilot')!; + const mcp = path.join(tmpHome, '.copilot', 'mcp-config.json'); + fs.mkdirSync(path.dirname(mcp), { recursive: true }); + fs.writeFileSync(mcp, JSON.stringify({ + mcpServers: { 'other-server': { type: 'local', command: 'other', args: [] } }, + }, null, 2) + '\n'); + + copilot.install('global', { autoAllow: true }); + + const after = JSON.parse(fs.readFileSync(mcp, 'utf-8')); + expect(after.mcpServers['other-server']).toBeDefined(); + expect(after.mcpServers.codegraph).toBeDefined(); + }); + + it('copilot: uninstall strips codegraph but leaves sibling MCP servers intact', () => { + const copilot = getTarget('copilot')!; + const mcp = path.join(tmpHome, '.copilot', 'mcp-config.json'); + fs.mkdirSync(path.dirname(mcp), { recursive: true }); + fs.writeFileSync(mcp, JSON.stringify({ + mcpServers: { 'other-server': { type: 'local', command: 'other', args: [] } }, + }, null, 2) + '\n'); + + copilot.install('global', { autoAllow: true }); + copilot.uninstall('global'); + + const after = JSON.parse(fs.readFileSync(mcp, 'utf-8')); + expect(after.mcpServers['other-server']).toBeDefined(); + expect(after.mcpServers.codegraph).toBeUndefined(); + }); + + it('copilot: re-running global install is idempotent', () => { + const copilot = getTarget('copilot')!; + copilot.install('global', { autoAllow: true }); + const first = fs.readFileSync(path.join(tmpHome, '.copilot', 'mcp-config.json'), 'utf-8'); + + const second = copilot.install('global', { autoAllow: true }); + for (const f of second.files) { + expect(f.action).toBe('unchanged'); + } + expect(fs.readFileSync(path.join(tmpHome, '.copilot', 'mcp-config.json'), 'utf-8')).toBe(first); + }); + + it('copilot: re-running local install is idempotent', () => { + const copilot = getTarget('copilot')!; + copilot.install('local', { autoAllow: true }); + const first = fs.readFileSync(path.join(tmpCwd, '.mcp.json'), 'utf-8'); + + const second = copilot.install('local', { autoAllow: true }); + for (const f of second.files) { + expect(f.action).toBe('unchanged'); + } + expect(fs.readFileSync(path.join(tmpCwd, '.mcp.json'), 'utf-8')).toBe(first); + }); + + it('copilot: uninstall on a clean slate reports not-found', () => { + const copilot = getTarget('copilot')!; + const result = copilot.uninstall('global'); + expect(result.files[0].action).toBe('not-found'); + }); + + it('copilot: printConfig outputs valid JSON with the correct entry shape', () => { + const copilot = getTarget('copilot')!; + const globalOut = copilot.printConfig('global'); + // printConfig uses os.homedir() → absolute path in tests; just check suffix. + expect(globalOut).toContain('.copilot/mcp-config.json'); + const globalJson = JSON.parse(globalOut.split('\n').filter((l) => !l.startsWith('#')).join('\n').trim()); + expect(globalJson.mcpServers.codegraph.type).toBe('local'); + expect(globalJson.mcpServers.codegraph.tools).toEqual(['*']); + + const localOut = copilot.printConfig('local'); + expect(localOut).toContain('.mcp.json'); + const localJson = JSON.parse(localOut.split('\n').filter((l) => !l.startsWith('#')).join('\n').trim()); + expect(localJson.mcpServers.codegraph.type).toBe('stdio'); + expect(localJson.mcpServers.codegraph.tools).toBeUndefined(); + }); + it('claude: uninstall strips stale hooks written in the npx form (local)', () => { const claude = getTarget('claude')!; const file = seedSettings('local', { @@ -1098,6 +1219,7 @@ 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')?.id).toBe('copilot'); expect(getTarget('not-a-real-target')).toBeUndefined(); }); diff --git a/src/installer/index.ts b/src/installer/index.ts index edd48ecaf..101997c35 100644 --- a/src/installer/index.ts +++ b/src/installer/index.ts @@ -146,8 +146,8 @@ export async function runInstallerWithOptions(opts: RunInstallerOptions): Promis const sel = await clack.select({ message: 'Apply agent configs to all your projects, or just this one?', options: [ - { value: 'global' as const, label: 'All projects', hint: '~/.claude, ~/.cursor, etc.' }, - { value: 'local' as const, label: 'Just this project', hint: './.claude, ./.cursor, etc.' }, + { value: 'global' as const, label: 'All projects', hint: '~/.claude, ~/.cursor, ~/.copilot, etc.' }, + { value: 'local' as const, label: 'Just this project', hint: './.claude, ./.cursor, ./.mcp.json, etc.' }, ], initialValue: 'global' as const, }); diff --git a/src/installer/targets/copilot.ts b/src/installer/targets/copilot.ts new file mode 100644 index 000000000..7ceadb3b4 --- /dev/null +++ b/src/installer/targets/copilot.ts @@ -0,0 +1,159 @@ +/** + * GitHub Copilot CLI target. Writes: + * + * - MCP server entry to `~/.copilot/mcp-config.json` (global) or + * `./.mcp.json` (local, shared with Claude Code). Copilot CLI + * auto-loads both paths and merges them. + * + * No permissions concept — Copilot CLI does not gate tool invocations + * behind an external allowlist. `autoAllow` is silently ignored. + * No instructions/steering file — the MCP server's `initialize` + * response is the single source of truth for agent guidance. + * + * The config entry shape differs by location: + * - Global: `{ type: "local", command, args, tools: ["*"] }` + * Copilot CLI convention: `"local"` is its native name for stdio + * servers (though `"stdio"` also works). `tools: ["*"]` ensures + * compatibility with Copilot CLI versions before v0.0.404, which + * required this field. + * - Local: `{ type: "stdio", command, args }` + * Uses the standard MCP shape because `./.mcp.json` is shared + * with Claude Code, which expects `"stdio"`. Copilot CLI accepts + * `"stdio"` as an alias for `"local"`, so the entry works for + * both agents. + * + * Docs: https://docs.github.com/en/copilot/customizing-copilot/extending-copilot-chat-with-mcp + */ + +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 mcpConfigPath(loc: Location): string { + return loc === 'global' + ? path.join(os.homedir(), '.copilot', 'mcp-config.json') + : path.join(process.cwd(), '.mcp.json'); +} + +/** + * Build the codegraph MCP entry in Copilot CLI's preferred shape. + * + * Global: `{ type: "local", command, args, tools: ["*"] }` + * - `"local"` is Copilot CLI's native name for stdio transports + * - `tools: ["*"]` ensures pre-v0.0.404 compatibility + * + * Local: `{ type: "stdio", command, args }` + * - Standard MCP shape for the shared `.mcp.json` file + * - Copilot CLI accepts `"stdio"` as an alias for `"local"` + */ +function buildCopilotMcpConfig(loc: Location): Record { + if (loc === 'global') { + return { + type: 'local', + command: 'codegraph', + args: ['serve', '--mcp'], + tools: ['*'], + }; + } + // Local: reuse the standard MCP shape for cross-agent compatibility + // with Claude Code, which also reads `./.mcp.json`. + return getMcpServerConfig() as Record; +} + +class CopilotTarget implements AgentTarget { + readonly id = 'copilot' as const; + readonly displayName = 'GitHub Copilot CLI'; + readonly docsUrl = 'https://docs.github.com/en/copilot/customizing-copilot/extending-copilot-chat-with-mcp'; + + supportsLocation(_loc: Location): boolean { + return true; + } + + detect(loc: Location): DetectionResult { + const file = mcpConfigPath(loc); + const config = readJsonFile(file); + const alreadyConfigured = !!config.mcpServers?.codegraph; + const installed = loc === 'global' + ? fs.existsSync(path.join(os.homedir(), '.copilot')) || fs.existsSync(file) + : fs.existsSync(file); + return { installed, alreadyConfigured, configPath: file }; + } + + install(loc: Location, _opts: InstallOptions): WriteResult { + const files: WriteResult['files'] = []; + files.push(writeMcpEntry(loc)); + + return { + files, + notes: ['Restart Copilot CLI for MCP changes to take effect.'], + }; + } + + uninstall(loc: Location): WriteResult { + const files: WriteResult['files'] = []; + + const file = mcpConfigPath(loc); + const config = readJsonFile(file); + if (config.mcpServers?.codegraph) { + delete config.mcpServers.codegraph; + if (Object.keys(config.mcpServers).length === 0) { + delete config.mcpServers; + } + writeJsonFile(file, config); + files.push({ path: file, action: 'removed' }); + } else { + files.push({ path: file, action: 'not-found' }); + } + + return { files }; + } + + printConfig(loc: Location): string { + const target = mcpConfigPath(loc); + const snippet = JSON.stringify( + { mcpServers: { codegraph: buildCopilotMcpConfig(loc) } }, + null, + 2, + ); + return `# Add to ${target}\n\n${snippet}\n`; + } + + describePaths(loc: Location): string[] { + return [mcpConfigPath(loc)]; + } +} + +function writeMcpEntry(loc: Location): WriteResult['files'][number] { + const file = mcpConfigPath(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 = buildCopilotMcpConfig(loc); + + if (jsonDeepEqual(before, after)) { + return { path: file, action: 'unchanged' }; + } + const action: 'created' | 'updated' = + before ? 'updated' : (fs.existsSync(file) ? 'updated' : 'created'); + if (!existing.mcpServers) existing.mcpServers = {}; + existing.mcpServers.codegraph = after; + writeJsonFile(file, existing); + return { path: file, action }; +} + +export const copilotTarget: AgentTarget = new CopilotTarget(); \ No newline at end of file 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 4b3267e97..ce3c7a2c5 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)`.