diff --git a/CHANGELOG.md b/CHANGELOG.md index 09fbad95a..228a083de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - `codegraph init` now builds the initial index by default — you no longer need the `-i`/`--index` flag (it's still accepted, so existing commands and scripts keep working). (#483) - Go: Gin middleware chains now connect end-to-end in `codegraph_trace` and `codegraph_explore` — following a request reaches the middleware and route handlers registered via `.Use()` / `.GET()` instead of dead-ending where the framework dispatches the chain dynamically. - `codegraph_explore` now sizes its response to the *answer* instead of the file count: it shows the mechanism and the exact methods you asked about in full — even when they're buried deep in a large file — while collapsing the redundant interchangeable implementations of an interface (an HTTP interceptor chain, a query-compiler family) down to signatures. Fewer tokens for a more complete answer, so on the flows that used to occasionally cost more than plain grep/read it's now clearly cheaper — and the win holds across small, medium, and large codebases. Distinct, non-interchangeable code is shown in full as before. Disable with `CODEGRAPH_ADAPTIVE_EXPLORE=0`. +- GitHub Copilot CLI: `codegraph install --target=copilot` now configures the Copilot CLI agent, writing MCP server entries to `~/.copilot/mcp-config.json` (global) or `.mcp.json` (local/workspace). ### Fixes diff --git a/README.md b/README.md index acdb726bf..8874fa518 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ # CodeGraph -### Supercharge Claude Code, Cursor, Codex, OpenCode, Hermes Agent, Gemini, Antigravity, and Kiro with Semantic Code Intelligence +### Supercharge Claude Code, Cursor, Codex, OpenCode, Hermes Agent, Gemini, Antigravity, Kiro, and Copilot CLI with Semantic Code Intelligence **~25% cheaper · ~62% fewer tool calls · 100% local** @@ -24,6 +24,7 @@ [![Gemini](https://img.shields.io/badge/Gemini-supported-blueviolet.svg)](#supported-agents) [![Antigravity](https://img.shields.io/badge/Antigravity-supported-blueviolet.svg)](#supported-agents) [![Kiro](https://img.shields.io/badge/Kiro-supported-blueviolet.svg)](#supported-agents) +[![Copilot CLI](https://img.shields.io/badge/Copilot_CLI-supported-blueviolet.svg)](#supported-agents) @@ -46,7 +47,7 @@ npx @colbymchenry/codegraph # zero-install, or: npm i -g @colbymchenry/codegraph ``` -CodeGraph bundles its own runtime — nothing to compile, no native build, works the same everywhere. The interactive installer auto-configures your agent(s) — Claude Code, Cursor, Codex CLI, opencode, Hermes Agent, Gemini CLI, Antigravity IDE, Kiro. +CodeGraph bundles its own runtime — nothing to compile, no native build, works the same everywhere. The interactive installer auto-configures your agent(s) — Claude Code, Cursor, Codex CLI, opencode, Hermes Agent, Gemini CLI, Antigravity IDE, Kiro, Copilot CLI. ### Initialize Projects @@ -571,6 +572,7 @@ is written): - **Gemini CLI** - **Antigravity IDE** - **Kiro** +- **Copilot CLI** ## Supported Languages diff --git a/__tests__/installer-targets.test.ts b/__tests__/installer-targets.test.ts index 27fcbd6e8..e8136a1ce 100644 --- a/__tests__/installer-targets.test.ts +++ b/__tests__/installer-targets.test.ts @@ -494,6 +494,71 @@ describe('Installer targets — partial-state idempotency', () => { expect(paths.some((p) => p.endsWith('/.kiro/steering/codegraph.md'))).toBe(false); }); + it('copilot: local install writes ./.mcp.json with "mcpServers" key', () => { + const copilot = getTarget('copilot')!; + const result = copilot.install('local', { autoAllow: true }); + const mcpPath = path.join(tmpCwd, '.mcp.json'); + expect(result.files.some((f) => f.path === mcpPath)).toBe(true); + expect(fs.existsSync(mcpPath)).toBe(true); + + const cfg = JSON.parse(fs.readFileSync(mcpPath, 'utf-8')); + // Copilot CLI uses "mcpServers" (same as Claude/Cursor). + expect(cfg.mcpServers.codegraph).toBeDefined(); + expect(cfg.mcpServers.codegraph.type).toBe('stdio'); + expect(cfg.mcpServers.codegraph.command).toBeDefined(); + expect(cfg.mcpServers.codegraph.args).toEqual(['serve', '--mcp']); + }); + + it('copilot: global install writes to ~/.copilot/mcp-config.json', () => { + const copilot = getTarget('copilot')!; + const result = copilot.install('global', { autoAllow: true }); + const mcpPath = path.join(tmpHome, '.copilot', 'mcp-config.json'); + expect(result.files.some((f) => f.path === mcpPath)).toBe(true); + expect(fs.existsSync(mcpPath)).toBe(true); + + const cfg = JSON.parse(fs.readFileSync(mcpPath, 'utf-8')); + expect(cfg.mcpServers.codegraph).toBeDefined(); + expect(cfg.mcpServers.codegraph.type).toBe('stdio'); + }); + + it('copilot: install preserves a pre-existing sibling MCP server in .mcp.json', () => { + const copilot = getTarget('copilot')!; + const mcpPath = path.join(tmpCwd, '.mcp.json'); + fs.mkdirSync(path.dirname(mcpPath), { recursive: true }); + fs.writeFileSync(mcpPath, JSON.stringify({ + mcpServers: { other: { type: 'stdio', command: 'uvx', args: ['other-server'] } }, + }, null, 2) + '\n'); + + copilot.install('local', { autoAllow: true }); + + const after = JSON.parse(fs.readFileSync(mcpPath, 'utf-8')); + expect(after.mcpServers.other).toBeDefined(); + expect(after.mcpServers.codegraph).toBeDefined(); + }); + + it('copilot: uninstall strips codegraph but leaves sibling MCP servers intact', () => { + const copilot = getTarget('copilot')!; + const mcpPath = path.join(tmpCwd, '.mcp.json'); + fs.mkdirSync(path.dirname(mcpPath), { recursive: true }); + fs.writeFileSync(mcpPath, JSON.stringify({ + mcpServers: { other: { type: 'stdio', command: 'uvx', args: ['other-server'] } }, + }, null, 2) + '\n'); + + copilot.install('local', { autoAllow: true }); + copilot.uninstall('local'); + + const after = JSON.parse(fs.readFileSync(mcpPath, 'utf-8')); + expect(after.mcpServers.other).toBeDefined(); + expect(after.mcpServers.codegraph).toBeUndefined(); + }); + + it('copilot: printConfig returns mcpServers format', () => { + const copilot = getTarget('copilot')!; + const output = copilot.printConfig('local'); + expect(output).toContain('"mcpServers"'); + expect(output).toContain('codegraph'); + }); + it('antigravity: install writes to LEGACY ~/.gemini/antigravity/mcp_config.json when no migration marker', () => { const antigravity = getTarget('antigravity')!; antigravity.install('global', { autoAllow: true }); @@ -1098,6 +1163,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/bin/codegraph.ts b/src/bin/codegraph.ts index 9e7f98887..4df50f92a 100644 --- a/src/bin/codegraph.ts +++ b/src/bin/codegraph.ts @@ -1607,7 +1607,7 @@ program */ program .command('install') - .description('Install codegraph MCP server into one or more agents (Claude Code, Cursor, Codex CLI, opencode, Hermes Agent)') + .description('Install codegraph MCP server into one or more agents (Claude Code, Cursor, Codex CLI, opencode, Hermes Agent, Copilot CLI)') .option('-t, --target ', 'Target agent(s): comma-separated ids, or "auto"|"all"|"none". Default: prompt') .option('-l, --location ', 'Install location: "global" or "local". Default: prompt') .option('-y, --yes', 'Non-interactive: defaults to --location=global --target=auto, auto-allow on') @@ -1674,7 +1674,7 @@ program */ program .command('uninstall') - .description('Remove codegraph from your agents (Claude Code, Cursor, Codex CLI, opencode, Hermes Agent)') + .description('Remove codegraph from your agents (Claude Code, Cursor, Codex CLI, opencode, Hermes Agent, Copilot CLI)') .option('-t, --target ', 'Target agent(s): comma-separated ids, or "all". Default: all') .option('-l, --location ', 'Uninstall location: "global" or "local". Default: prompt') .option('-y, --yes', 'Non-interactive: defaults to --location=global --target=all') diff --git a/src/installer/index.ts b/src/installer/index.ts index edd48ecaf..342f9c9b7 100644 --- a/src/installer/index.ts +++ b/src/installer/index.ts @@ -257,35 +257,79 @@ export interface UninstallReport { * * Each target's `uninstall()` is already safe to call when nothing was * installed (it returns `not-found` actions), so this is safe to run - * across every target unconditionally. + * across every target unconditionally. We still gate on `detect()` + * for targets that share a config file (e.g. `.mcp.json` is used by + * both Claude Code and Copilot CLI for local installs) — without the + * gate, one agent's uninstall would strip the other agent's entry + * from the shared file. + * + * When a shared config file has its codegraph entry removed, other + * targets that also write to that same file are marked "removed" as + * well — because the entry they rely on no longer exists. A note + * explains the cascade so the user understands what happened. */ export function uninstallTargets( targets: readonly AgentTarget[], location: Location, ): UninstallReport[] { - return targets.map((target) => { + // Track config paths that had their codegraph entry removed. + // When multiple targets share a file (e.g. .mcp.json), removing + // the entry for one target removes it for all of them. + const removedConfigPaths = new Set(); + const reports: UninstallReport[] = []; + + for (const target of targets) { if (!target.supportsLocation(location)) { const only: Location = location === 'local' ? 'global' : 'local'; - return { + reports.push({ id: target.id, displayName: target.displayName, status: 'unsupported' as const, removedPaths: [], notes: [`no ${location} config — this agent is ${only}-only`], - }; + }); + continue; + } + if (!target.detect(location).installed) { + reports.push({ + id: target.id, + displayName: target.displayName, + status: 'not-configured' as const, + removedPaths: [], + notes: [], + }); + continue; } + + // Check if a prior target already removed the entry from this + // target's config file (shared-file cascade). + const cfgPath = target.detect(location).configPath; + if (cfgPath && removedConfigPaths.has(cfgPath)) { + reports.push({ + id: target.id, + displayName: target.displayName, + status: 'removed' as const, + removedPaths: [cfgPath], + notes: [`shared config file (${cfgPath}) — entry was already removed by a previous uninstall`], + }); + continue; + } + const result = target.uninstall(location); const removedPaths = result.files .filter((f) => f.action === 'removed') .map((f) => f.path); - return { + for (const p of removedPaths) removedConfigPaths.add(p); + reports.push({ id: target.id, displayName: target.displayName, status: removedPaths.length > 0 ? ('removed' as const) : ('not-configured' as const), removedPaths, notes: result.notes ?? [], - }; - }); + }); + } + + return reports; } /** @@ -317,7 +361,7 @@ export async function runUninstaller(opts: RunUninstallerOptions): Promise const sel = await clack.select({ message: 'Remove CodeGraph from all your projects, or just this one?', options: [ - { value: 'global' as const, label: 'All projects (global)', hint: '~/.claude, ~/.cursor, ~/.codex, ~/.config/opencode, ~/.hermes, ~/.gemini, ~/.kiro' }, + { value: 'global' as const, label: 'All projects (global)', hint: '~/.claude, ~/.cursor, ~/.codex, ~/.config/opencode, ~/.hermes, ~/.gemini, ~/.kiro, ~/.copilot' }, { value: 'local' as const, label: 'Just this project (local)', hint: './.claude, ./.cursor, ./opencode.jsonc, ./.gemini, ./.kiro' }, ], initialValue: 'global' as const, @@ -353,6 +397,11 @@ export async function runUninstaller(opts: RunUninstallerOptions): Promise for (const p of r.removedPaths) { clack.log.success(`${r.displayName}: removed ${tildify(p)}`); } + if (r.notes.length > 0) { + for (const n of r.notes) { + clack.log.warn(`${r.displayName}: ${n}`); + } + } } else if (r.status === 'not-configured') { clack.log.info(`${r.displayName}: not configured — nothing to remove`); } else { @@ -360,6 +409,28 @@ export async function runUninstaller(opts: RunUninstallerOptions): Promise } } + // Step 3b: warn about shared-config cascade. When multiple targets + // write to the same file (e.g. .mcp.json is shared by Claude Code, + // Cursor, and Copilot CLI for local installs), removing the + // codegraph entry for one target also removes it for the others. + const removedPaths = new Set(removed.flatMap((r) => r.removedPaths)); + if (removedPaths.size > 0) { + const affected = ALL_TARGETS.filter((t) => { + if (reports.some((r) => r.id === t.id && r.status === 'removed')) return false; + if (!t.supportsLocation(location)) return false; + const det = t.detect(location); + return det.installed && det.configPath && removedPaths.has(det.configPath); + }); + if (affected.length > 0) { + const names = affected.map((t) => t.displayName).join(', '); + const files = [...removedPaths].map((p) => tildify(p)).join(', '); + clack.log.warn( + `Shared config: ${files} is also used by ${names}. ` + + `The codegraph entry was removed from that file — those agents will need re-installing.`, + ); + } + } + // Step 4: for local uninstall, the index dir is separate — point at // `uninit` so the user knows it's still there (and how to remove it). if (location === 'local' && fs.existsSync(path.join(process.cwd(), '.codegraph'))) { diff --git a/src/installer/targets/claude.ts b/src/installer/targets/claude.ts index 3259dea1b..02d3cf2f5 100644 --- a/src/installer/targets/claude.ts +++ b/src/installer/targets/claude.ts @@ -86,9 +86,15 @@ class ClaudeCodeTarget implements AgentTarget { // For "installed" we infer from the existence of either the dir // (global) or the project marker file (local). Cheap and avoids // shelling out to `claude --version`. + // For local: .mcp.json is shared infrastructure (Copilot CLI + // also writes to it), so we CANNOT use it as a Claude-specific + // detection signal. Only .claude/ is Claude's own marker. + // Copilot has no equivalent local dir, so it keeps .mcp.json + // as its detection signal. The shared-config cascade in + // uninstallTargets handles the conflict when both are installed. const installed = loc === 'global' ? fs.existsSync(configDir(loc)) || fs.existsSync(mcpPath) - : fs.existsSync(mcpPath) || fs.existsSync(configDir(loc)); + : fs.existsSync(configDir(loc)); return { installed, alreadyConfigured, configPath: mcpPath }; } diff --git a/src/installer/targets/copilot.ts b/src/installer/targets/copilot.ts new file mode 100644 index 000000000..66efa87b8 --- /dev/null +++ b/src/installer/targets/copilot.ts @@ -0,0 +1,164 @@ +/** + * GitHub Copilot CLI target. + * + * Copilot CLI reads MCP server definitions from: + * + * - **User-level**: `~/.copilot/mcp-config.json` (since v0.0.340) + * - **Workspace-level**: `.mcp.json` in the project root (since v1.0.22) + * + * The JSON shape uses `"mcpServers"` as the top-level key — same as + * Claude Code, Cursor, Kiro, and Gemini. + * + * ## Location + * + * - `global` writes to `~/.copilot/mcp-config.json` (user-level, all projects). + * - `local` writes to `./.mcp.json` (workspace-level, shared with team). + * + * ## No permissions concept + * + * Copilot CLI gates tool invocations through its own UI prompts. + * `autoAllow` is silently ignored. + * + * Docs: https://github.com/github/copilot-cli + */ + +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'; + +// --------------------------------------------------------------------------- +// Paths +// --------------------------------------------------------------------------- + +function globalMcpPath(): string { + return path.join(os.homedir(), '.copilot', 'mcp-config.json'); +} + +function localMcpPath(): string { + return path.join(process.cwd(), '.mcp.json'); +} + +function mcpPath(loc: Location): string { + return loc === 'global' ? globalMcpPath() : localMcpPath(); +} + +// --------------------------------------------------------------------------- +// Detection +// --------------------------------------------------------------------------- + +/** + * Best-effort heuristic for "Copilot CLI is installed." + * + * Global: check that `~/.copilot` exists or the mcp-config.json exists. + * Local: check that `~/.copilot` exists (Copilot CLI's user-level + * directory). We intentionally do NOT use `.mcp.json` as a local + * detection signal because it is shared infrastructure — Claude Code + * and Cursor also write to it for their local installs, so its mere + * existence does not imply Copilot CLI is present. + */ +function detectInstalled(loc: Location): boolean { + const copilotDir = path.join(os.homedir(), '.copilot'); + if (loc === 'global') { + return fs.existsSync(copilotDir) || fs.existsSync(globalMcpPath()); + } + return fs.existsSync(copilotDir); +} + +// --------------------------------------------------------------------------- +// Target +// --------------------------------------------------------------------------- + +class CopilotTarget implements AgentTarget { + readonly id = 'copilot' as const; + readonly displayName = 'GitHub Copilot CLI'; + readonly docsUrl = 'https://github.com/github/copilot-cli'; + + 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 = detectInstalled(loc); + 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 = mcpPath(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 = mcpPath(loc); + const snippet = JSON.stringify({ mcpServers: { codegraph: getMcpServerConfig() } }, null, 2); + return `# Add to ${target}\n\n${snippet}\n`; + } + + describePaths(loc: Location): string[] { + return [mcpPath(loc)]; + } +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +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 = getMcpServerConfig(); + + 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(); 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)`.