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 @@
[](#supported-agents)
[](#supported-agents)
[](#supported-agents)
+[](#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)`.