diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a5c7d524..0142ea20a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### 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. - Anonymous usage telemetry is now stored entirely on CodeGraph's own first-party infrastructure — no third-party analytics vendor receives any of it, and the endpoint that receives it makes no outbound requests at all. Individual events are deleted after 90 days, leaving only anonymous daily totals. Nothing about what is collected changed, your IP address is still never read or stored, and every off-switch works exactly as before (`codegraph telemetry off`, `CODEGRAPH_TELEMETRY=0`, `DO_NOT_TRACK=1`). `TELEMETRY.md` remains the complete field-by-field list. ### Fixes diff --git a/__tests__/installer-targets.test.ts b/__tests__/installer-targets.test.ts index e6a363e96..08f8e8b95 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)`.