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
122 changes: 122 additions & 0 deletions __tests__/installer-targets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', {
Expand Down Expand Up @@ -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();
});

Expand Down
4 changes: 2 additions & 2 deletions src/installer/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
Expand Down
159 changes: 159 additions & 0 deletions src/installer/targets/copilot.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> {
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<string, unknown>;
}

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();
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