Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
54 changes: 52 additions & 2 deletions __tests__/installer-targets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down Expand Up @@ -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);
Expand Down
131 changes: 131 additions & 0 deletions src/installer/targets/copilot.ts
Original file line number Diff line number Diff line change
@@ -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();
2 changes: 2 additions & 0 deletions src/installer/targets/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -26,6 +27,7 @@ export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([
geminiTarget,
antigravityTarget,
kiroTarget,
copilotTarget,
]);

export function getTarget(id: string): AgentTarget | undefined {
Expand Down
2 changes: 1 addition & 1 deletion src/installer/targets/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)`.
Expand Down