Files
codegraph/__tests__/installer-targets.test.ts
T
Max HsuandGitHub bc894802ff feat(installer): support project-local Codex installs (#1531) (#1551)
Codex CLI has a first-class project config layer — `.codex/config.toml`
is layer 4 of the loader stack, above the user config at layer 6
(`codex-rs/config/src/loader/README.md` in openai/codex), and it landed
in openai/codex#8354 on 2025-12-22. The CodexTarget's "Codex has no
project-local config concept" note was therefore never accurate, and
`supportsLocation('local') === false` made Codex the one agent that
forces a machine-wide MCP install.

`mcp_servers` is not on the project layer's denylist (which strips base
URLs, model providers, `notify`, profiles and otel — settings repo
contents shouldn't choose), so a project-scoped `[mcp_servers.codegraph]`
is honored.

- Path helpers take a `Location`: global keeps `~/.codex/config.toml` +
  `~/.codex/AGENTS.md`; local writes `<cwd>/.codex/config.toml` and the
  project-root `<cwd>/AGENTS.md` — the same split the gemini and
  opencode targets already use for their local layout.
- Drops the five `loc !== 'global'` early returns from detect, install,
  uninstall, printConfig and describePaths.
- Local install returns a note that Codex only applies a project layer
  in a project marked trusted; untrusted projects load the layer but
  leave it disabled, so a silent success would be misleading.
- Refreshes the two doc comments that used Codex as the example of a
  global-only target (now the Copilot CLI).

Tests: two new cases covering the local write layout, the trust note,
global config staying untouched, and local uninstall leaving the global
entry intact. Both fail against the previous implementation. The generic
per-target contract suite now also exercises codex at location=local.
2026-08-22 11:55:10 -05:00

2484 lines
106 KiB
TypeScript

/**
* Multi-target installer tests.
*
* Each `AgentTarget` is exercised against the same contract:
* - `install` writes the expected files
* - re-running `install` is byte-identical (idempotent)
* - sibling MCP servers / unrelated config is preserved
* - `uninstall` reverses `install`
* - `printConfig` returns parseable, non-empty content
*
* For agent-config destinations we redirect HOME to a tmpdir via
* `os.homedir` spying, and CWD via `process.chdir` — same pattern as
* the legacy `installer.test.ts`. No real `~/.claude/` etc. ever
* touched.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { parse as parseJsonc } from 'jsonc-parser';
import { ALL_TARGETS, getTarget, resolveTargetFlag } from '../src/installer/targets/registry';
import { uninstallTargets, refreshTargets } from '../src/installer';
import { upsertTomlTable, removeTomlTable, buildTomlTable } from '../src/installer/targets/toml';
import { cleanupLegacyHooks, writePromptHookEntry, removePromptHookEntry } from '../src/installer/targets/claude';
function mkTmpDir(label: string): string {
return fs.mkdtempSync(path.join(os.tmpdir(), `cg-targets-${label}-`));
}
// `os.homedir` is non-configurable on Node, so we redirect it via the
// `$HOME` (POSIX) / `$USERPROFILE` (Windows) env vars that
// `os.homedir()` reads first. Same trick the rest of the suite uses
// when it needs a mock home.
function setHome(dir: string): { restore: () => void } {
const prev = {
HOME: process.env.HOME,
USERPROFILE: process.env.USERPROFILE,
APPDATA: process.env.APPDATA,
XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME,
HERMES_HOME: process.env.HERMES_HOME,
COPILOT_HOME: process.env.COPILOT_HOME,
};
process.env.HOME = dir;
process.env.USERPROFILE = dir;
process.env.APPDATA = path.join(dir, '.config');
process.env.XDG_CONFIG_HOME = path.join(dir, '.config');
delete process.env.HERMES_HOME;
delete process.env.COPILOT_HOME;
return {
restore() {
if (prev.HOME === undefined) delete process.env.HOME; else process.env.HOME = prev.HOME;
if (prev.USERPROFILE === undefined) delete process.env.USERPROFILE; else process.env.USERPROFILE = prev.USERPROFILE;
if (prev.APPDATA === undefined) delete process.env.APPDATA; else process.env.APPDATA = prev.APPDATA;
if (prev.XDG_CONFIG_HOME === undefined) delete process.env.XDG_CONFIG_HOME; else process.env.XDG_CONFIG_HOME = prev.XDG_CONFIG_HOME;
if (prev.HERMES_HOME === undefined) delete process.env.HERMES_HOME; else process.env.HERMES_HOME = prev.HERMES_HOME;
if (prev.COPILOT_HOME === undefined) delete process.env.COPILOT_HOME; else process.env.COPILOT_HOME = prev.COPILOT_HOME;
},
};
}
// A marker-delimited CodeGraph block exactly as a previous installer
// wrote it. Issue #529: the installer no longer writes an instructions
// file, but install (self-heal on upgrade) and uninstall both still
// strip a block a prior install left, so we plant this to exercise it.
const LEGACY_BLOCK = [
'<!-- CODEGRAPH_START -->',
'## CodeGraph',
'',
'Prefer `codegraph_search` / `codegraph_callers` over grep.',
'<!-- CODEGRAPH_END -->',
].join('\n');
describe('Installer targets — contract', () => {
let tmpHome: string;
let tmpCwd: string;
let origCwd: string;
let homeRestore: { restore: () => void };
beforeEach(() => {
tmpHome = mkTmpDir('home');
tmpCwd = mkTmpDir('cwd');
origCwd = process.cwd();
process.chdir(tmpCwd);
homeRestore = setHome(tmpHome);
});
afterEach(() => {
homeRestore.restore();
process.chdir(origCwd);
fs.rmSync(tmpHome, { recursive: true, force: true });
fs.rmSync(tmpCwd, { recursive: true, force: true });
});
for (const target of ALL_TARGETS) {
describe(target.id, () => {
const supportedLocations = (['global', 'local'] as const).filter((l) =>
target.supportsLocation(l),
);
for (const location of supportedLocations) {
describe(`location=${location}`, () => {
it('install writes files; detect.alreadyConfigured becomes true', () => {
expect(target.detect(location).alreadyConfigured).toBe(false);
const result = target.install(location, { autoAllow: true });
expect(result.files.length).toBeGreaterThan(0);
for (const file of result.files) {
if (file.action !== 'unchanged') {
expect(fs.existsSync(file.path)).toBe(true);
}
}
expect(target.detect(location).alreadyConfigured).toBe(true);
});
it('re-running install is idempotent (no actions other than unchanged)', () => {
target.install(location, { autoAllow: true });
const second = target.install(location, { autoAllow: true });
for (const file of second.files) {
expect(file.action).toBe('unchanged');
}
});
it('install preserves a pre-existing sibling MCP server (where applicable)', () => {
// Plant a sibling entry in the same JSON config, install,
// and verify the sibling survives. Skip for Codex (TOML)
// and any target with no JSON config — they get covered
// by their own dedicated tests below.
const paths = target.describePaths(location);
// Match .json or .jsonc — opencode prefers .jsonc.
const jsonPath = paths.find((p) => /\.jsonc?$/.test(p));
if (!jsonPath) return;
// Seed pre-existing config.
fs.mkdirSync(path.dirname(jsonPath), { recursive: true });
const seed: Record<string, any> = { mcpServers: { other: { command: 'x' } } };
// opencode uses `mcp` not `mcpServers`. Match its shape too.
if (target.id === 'opencode') {
delete seed.mcpServers;
seed.mcp = { other: { type: 'local', command: ['x'], enabled: true } };
}
// VS Code's mcp.json uses `servers`; the JetBrains Copilot
// plugin's mcp.json is schema-compatible with it.
if (target.id === 'copilot-vscode' || target.id === 'copilot-jetbrains') {
delete seed.mcpServers;
seed.servers = { other: { command: 'x' } };
}
fs.writeFileSync(jsonPath, JSON.stringify(seed, null, 2) + '\n');
target.install(location, { autoAllow: true });
const after = JSON.parse(fs.readFileSync(jsonPath, 'utf-8'));
if (target.id === 'opencode') {
expect(after.mcp.other).toBeDefined();
expect(after.mcp.codegraph).toBeDefined();
} else if (target.id === 'copilot-vscode' || target.id === 'copilot-jetbrains') {
expect(after.servers.other).toBeDefined();
expect(after.servers.codegraph).toBeDefined();
} else {
expect(after.mcpServers.other).toBeDefined();
expect(after.mcpServers.codegraph).toBeDefined();
}
});
it('uninstall reverses install (alreadyConfigured returns to false)', () => {
target.install(location, { autoAllow: true });
expect(target.detect(location).alreadyConfigured).toBe(true);
target.uninstall(location);
expect(target.detect(location).alreadyConfigured).toBe(false);
});
it('printConfig returns non-empty output without writing anything', () => {
const before = listAllFiles(tmpHome).concat(listAllFiles(tmpCwd));
const out = target.printConfig(location);
expect(out.length).toBeGreaterThan(0);
const after = listAllFiles(tmpHome).concat(listAllFiles(tmpCwd));
expect(after.sort()).toEqual(before.sort());
});
});
}
});
}
});
describe('Installer targets — partial-state idempotency', () => {
let tmpHome: string;
let tmpCwd: string;
let origCwd: string;
let homeRestore: { restore: () => void };
beforeEach(() => {
tmpHome = mkTmpDir('home');
tmpCwd = mkTmpDir('cwd');
origCwd = process.cwd();
process.chdir(tmpCwd);
homeRestore = setHome(tmpHome);
});
afterEach(() => {
homeRestore.restore();
process.chdir(origCwd);
fs.rmSync(tmpHome, { recursive: true, force: true });
fs.rmSync(tmpCwd, { recursive: true, force: true });
});
it('codex: install writes config.toml AND the AGENTS.md codegraph block (#704)', () => {
const codex = getTarget('codex')!;
const first = codex.install('global', { autoAllow: false });
const agentsMd = path.join(tmpHome, '.codex', 'AGENTS.md');
expect(first.files.some((f) => f.path.endsWith('config.toml'))).toBe(true);
// The short instructions block IS written (subagents / non-MCP
// harnesses read AGENTS.md but never the MCP initialize instructions).
expect(fs.existsSync(agentsMd)).toBe(true);
const body = fs.readFileSync(agentsMd, 'utf-8');
expect(body).toContain('## CodeGraph');
expect(body).toContain('codegraph explore');
// Re-install is fully unchanged (byte-equal block → idempotent).
const second = codex.install('global', { autoAllow: false });
for (const f of second.files) expect(f.action).toBe('unchanged');
});
it('codex: install replaces a legacy AGENTS.md codegraph block with the current one, keeping user content', () => {
const codex = getTarget('codex')!;
const dir = path.join(tmpHome, '.codex');
fs.mkdirSync(dir, { recursive: true });
const agentsMd = path.join(dir, 'AGENTS.md');
fs.writeFileSync(agentsMd, `# My codex notes\n\nBe terse.\n\n${LEGACY_BLOCK}\n`);
const result = codex.install('global', { autoAllow: false });
const body = fs.readFileSync(agentsMd, 'utf-8');
expect(body).toContain('# My codex notes');
expect(body).toContain('Be terse.');
// Self-heal: the stale pre-#529 body is gone, the current block is in.
expect(body).not.toContain('Prefer `codegraph_search`');
expect(body).toContain('codegraph explore');
const mdEntry = result.files.find((f) => f.path.endsWith('AGENTS.md'));
expect(mdEntry?.action).toBe('updated');
});
it('codex: local install writes ./.codex/config.toml and the project-root ./AGENTS.md block (#1531)', () => {
const codex = getTarget('codex')!;
const result = codex.install('local', { autoAllow: false });
const paths = result.files.map((f) => f.path.replace(/\\/g, '/'));
// macOS realpath shenanigans (/var vs /private/var) — suffix match.
expect(paths.some((p) => p.endsWith('/.codex/config.toml'))).toBe(true);
// AGENTS.md sits at the project root, NOT under .codex/ — that's the
// file Codex reads for repo instructions.
expect(paths.some((p) => p.endsWith('/AGENTS.md') && !p.includes('/.codex/'))).toBe(true);
const toml = fs.readFileSync(path.join(process.cwd(), '.codex', 'config.toml'), 'utf-8');
expect(toml).toContain('[mcp_servers.codegraph]');
expect(fs.readFileSync(path.join(process.cwd(), 'AGENTS.md'), 'utf-8')).toContain('codegraph explore');
// The project layer is only applied in a trusted project, so say so
// instead of reporting a silent success.
expect(result.notes?.join(' ')).toMatch(/trusted/);
// Global config is untouched by a local install.
expect(fs.existsSync(path.join(tmpHome, '.codex', 'config.toml'))).toBe(false);
});
it('codex: local uninstall reverses the local install and leaves the global entry alone (#1531)', () => {
const codex = getTarget('codex')!;
codex.install('global', { autoAllow: false });
codex.install('local', { autoAllow: false });
expect(codex.detect('local').alreadyConfigured).toBe(true);
codex.uninstall('local');
expect(codex.detect('local').alreadyConfigured).toBe(false);
expect(codex.detect('global').alreadyConfigured).toBe(true);
expect(fs.readFileSync(path.join(tmpHome, '.codex', 'config.toml'), 'utf-8'))
.toContain('[mcp_servers.codegraph]');
});
it('opencode: prefers .jsonc when both .json and .jsonc exist', () => {
const opencode = getTarget('opencode')!;
const dir = path.join(tmpHome, '.config', 'opencode');
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, 'opencode.json'), '{\n "$schema": "https://opencode.ai/config.json"\n}\n');
fs.writeFileSync(path.join(dir, 'opencode.jsonc'), '{\n "$schema": "https://opencode.ai/config.json"\n}\n');
const result = opencode.install('global', { autoAllow: true });
const written = result.files.find((f) => /\.jsonc$/.test(f.path))!;
expect(written).toBeDefined();
expect(written.action).not.toBe('not-found');
// The .json file is left alone.
const jsonText = fs.readFileSync(path.join(dir, 'opencode.json'), 'utf-8');
expect(jsonText).not.toContain('codegraph');
});
it('opencode: uses .json when only .json exists (no .jsonc)', () => {
const opencode = getTarget('opencode')!;
const dir = path.join(tmpHome, '.config', 'opencode');
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, 'opencode.json'), '{\n "$schema": "https://opencode.ai/config.json"\n}\n');
const result = opencode.install('global', { autoAllow: true });
expect(result.files[0].path).toMatch(/opencode\.json$/);
expect(fs.existsSync(path.join(dir, 'opencode.jsonc'))).toBe(false);
});
it('opencode: defaults to .jsonc for fresh installs (no existing file)', () => {
const opencode = getTarget('opencode')!;
const result = opencode.install('global', { autoAllow: true });
expect(result.files[0].path).toMatch(/opencode\.jsonc$/);
expect(result.files[0].action).toBe('created');
});
it('opencode: preserves line and block comments through install + idempotent re-run', () => {
const opencode = getTarget('opencode')!;
const dir = path.join(tmpHome, '.config', 'opencode');
fs.mkdirSync(dir, { recursive: true });
const file = path.join(dir, 'opencode.jsonc');
const original = [
'{',
' // top-level note about my opencode setup',
' "$schema": "https://opencode.ai/config.json",',
' /* multi-line block comment',
' describing the providers section */',
' "providers": {',
' "anthropic": { "model": "claude-opus-4-7" } // pinned',
' }',
'}',
'',
].join('\n');
fs.writeFileSync(file, original);
opencode.install('global', { autoAllow: true });
const afterInstall = fs.readFileSync(file, 'utf-8');
expect(afterInstall).toContain('// top-level note about my opencode setup');
expect(afterInstall).toContain('/* multi-line block comment');
expect(afterInstall).toContain('// pinned');
expect(afterInstall).toContain('"codegraph"');
expect(afterInstall).toContain('"providers"');
// Idempotent re-run reports unchanged, file is byte-identical.
const second = opencode.install('global', { autoAllow: true });
expect(second.files[0].action).toBe('unchanged');
expect(fs.readFileSync(file, 'utf-8')).toBe(afterInstall);
});
it('opencode: install writes the AGENTS.md codegraph block (#704)', () => {
const opencode = getTarget('opencode')!;
const result = opencode.install('global', { autoAllow: true });
const agentsMd = path.join(tmpHome, '.config', 'opencode', 'AGENTS.md');
expect(fs.existsSync(agentsMd)).toBe(true);
expect(fs.readFileSync(agentsMd, 'utf-8')).toContain('codegraph explore');
expect(result.files.find((f) => f.path.endsWith('AGENTS.md'))?.action).toBe('created');
});
it('opencode: install replaces a legacy AGENTS.md codegraph block, preserving user content', () => {
const opencode = getTarget('opencode')!;
const dir = path.join(tmpHome, '.config', 'opencode');
fs.mkdirSync(dir, { recursive: true });
const agentsMd = path.join(dir, 'AGENTS.md');
fs.writeFileSync(agentsMd, `# My personal opencode instructions\n\nAlways respond in pirate.\n\n${LEGACY_BLOCK}\n`);
const result = opencode.install('global', { autoAllow: true });
const body = fs.readFileSync(agentsMd, 'utf-8');
expect(body).toContain('# My personal opencode instructions');
expect(body).toContain('Always respond in pirate.');
expect(body).not.toContain('Prefer `codegraph_search`');
expect(body).toContain('codegraph explore');
expect(result.files.find((f) => f.path.endsWith('AGENTS.md'))?.action).toBe('updated');
});
it('opencode: uninstall strips a leftover codegraph block from AGENTS.md, keeping user content', () => {
const opencode = getTarget('opencode')!;
const dir = path.join(tmpHome, '.config', 'opencode');
fs.mkdirSync(dir, { recursive: true });
const agentsMd = path.join(dir, 'AGENTS.md');
fs.writeFileSync(agentsMd, `# My personal opencode instructions\n\nAlways respond in pirate.\n\n${LEGACY_BLOCK}\n`);
opencode.uninstall('global');
const body = fs.readFileSync(agentsMd, 'utf-8');
expect(body).toContain('# My personal opencode instructions');
expect(body).toContain('Always respond in pirate.');
expect(body).not.toContain('CODEGRAPH_START');
});
it('opencode: local install writes ./opencode.jsonc and the ./AGENTS.md block (#704)', () => {
const opencode = getTarget('opencode')!;
const result = opencode.install('local', { autoAllow: true });
const paths = result.files.map((f) => f.path.replace(/\\/g, '/'));
// macOS realpath shenanigans (/var vs /private/var) — suffix match.
expect(paths.some((p) => p.endsWith('/opencode.jsonc'))).toBe(true);
expect(paths.some((p) => p.endsWith('/AGENTS.md'))).toBe(true);
expect(fs.existsSync(path.join(process.cwd(), 'AGENTS.md'))).toBe(true);
});
it('gemini: install writes settings.json (mcpServers.codegraph) and the GEMINI.md block (#704)', () => {
const gemini = getTarget('gemini')!;
const result = gemini.install('global', { autoAllow: true });
const settings = path.join(tmpHome, '.gemini', 'settings.json');
const geminiMd = path.join(tmpHome, '.gemini', 'GEMINI.md');
expect(result.files.some((f) => f.path === settings)).toBe(true);
expect(result.files.some((f) => f.path === geminiMd)).toBe(true);
expect(fs.existsSync(geminiMd)).toBe(true);
expect(fs.readFileSync(geminiMd, 'utf-8')).toContain('codegraph explore');
const cfg = JSON.parse(fs.readFileSync(settings, 'utf-8'));
expect(cfg.mcpServers.codegraph).toEqual({ type: 'stdio', command: 'codegraph', args: ['serve', '--mcp'] });
});
it('gemini: install preserves pre-existing settings (security.auth survives)', () => {
const gemini = getTarget('gemini')!;
const settings = path.join(tmpHome, '.gemini', 'settings.json');
fs.mkdirSync(path.dirname(settings), { recursive: true });
fs.writeFileSync(settings, JSON.stringify({
security: { auth: { selectedType: 'oauth-personal' } },
}, null, 2) + '\n');
gemini.install('global', { autoAllow: true });
const after = JSON.parse(fs.readFileSync(settings, 'utf-8'));
expect(after.security?.auth?.selectedType).toBe('oauth-personal');
expect(after.mcpServers?.codegraph).toBeDefined();
});
it('gemini: uninstall strips codegraph but leaves pre-existing settings (security.auth) intact', () => {
const gemini = getTarget('gemini')!;
const settings = path.join(tmpHome, '.gemini', 'settings.json');
fs.mkdirSync(path.dirname(settings), { recursive: true });
fs.writeFileSync(settings, JSON.stringify({
security: { auth: { selectedType: 'oauth-personal' } },
}, null, 2) + '\n');
gemini.install('global', { autoAllow: true });
gemini.uninstall('global');
const after = JSON.parse(fs.readFileSync(settings, 'utf-8'));
expect(after.security?.auth?.selectedType).toBe('oauth-personal');
expect(after.mcpServers).toBeUndefined();
});
it('gemini: local install writes ./.gemini/settings.json and the project-root ./GEMINI.md block (#704)', () => {
const gemini = getTarget('gemini')!;
const result = gemini.install('local', { autoAllow: true });
const paths = result.files.map((f) => f.path.replace(/\\/g, '/'));
expect(paths.some((p) => p.endsWith('/.gemini/settings.json'))).toBe(true);
expect(paths.some((p) => p.endsWith('/GEMINI.md'))).toBe(true);
expect(fs.existsSync(path.join(process.cwd(), 'GEMINI.md'))).toBe(true);
});
it('gemini: uninstall strips a leftover GEMINI.md codegraph block, keeping user content', () => {
const gemini = getTarget('gemini')!;
const geminiMd = path.join(tmpHome, '.gemini', 'GEMINI.md');
fs.mkdirSync(path.dirname(geminiMd), { recursive: true });
fs.writeFileSync(geminiMd, `# My personal Gemini context\n\nAlways respond concisely.\n\n${LEGACY_BLOCK}\n`);
gemini.uninstall('global');
const body = fs.readFileSync(geminiMd, 'utf-8');
expect(body).toContain('# My personal Gemini context');
expect(body).toContain('Always respond concisely.');
expect(body).not.toContain('CODEGRAPH_START');
});
it('kiro: install writes settings/mcp.json (mcpServers.codegraph) and no steering doc (#529)', () => {
const kiro = getTarget('kiro')!;
const result = kiro.install('global', { autoAllow: true });
const mcp = path.join(tmpHome, '.kiro', 'settings', 'mcp.json');
const steering = path.join(tmpHome, '.kiro', 'steering', 'codegraph.md');
expect(result.files.some((f) => f.path === mcp)).toBe(true);
expect(result.files.some((f) => f.path === steering)).toBe(false);
expect(fs.existsSync(steering)).toBe(false);
const cfg = JSON.parse(fs.readFileSync(mcp, 'utf-8'));
expect(cfg.mcpServers.codegraph).toEqual({ type: 'stdio', command: 'codegraph', args: ['serve', '--mcp'] });
});
it('kiro: install deletes a leftover steering codegraph.md (self-heal) (#529)', () => {
const kiro = getTarget('kiro')!;
const steering = path.join(tmpHome, '.kiro', 'steering', 'codegraph.md');
fs.mkdirSync(path.dirname(steering), { recursive: true });
fs.writeFileSync(steering, `${LEGACY_BLOCK}\n`);
const result = kiro.install('global', { autoAllow: true });
expect(fs.existsSync(steering)).toBe(false);
expect(result.files.find((f) => f.path === steering)?.action).toBe('removed');
});
it('kiro: install preserves a pre-existing sibling MCP server in mcp.json', () => {
const kiro = getTarget('kiro')!;
const mcp = path.join(tmpHome, '.kiro', 'settings', 'mcp.json');
fs.mkdirSync(path.dirname(mcp), { recursive: true });
fs.writeFileSync(mcp, JSON.stringify({
mcpServers: { other: { command: 'uvx', args: ['other-server'] } },
}, null, 2) + '\n');
kiro.install('global', { autoAllow: true });
const after = JSON.parse(fs.readFileSync(mcp, 'utf-8'));
expect(after.mcpServers.other).toBeDefined();
expect(after.mcpServers.codegraph).toBeDefined();
});
it('kiro: uninstall strips codegraph but leaves sibling MCP servers intact', () => {
const kiro = getTarget('kiro')!;
const mcp = path.join(tmpHome, '.kiro', 'settings', 'mcp.json');
fs.mkdirSync(path.dirname(mcp), { recursive: true });
fs.writeFileSync(mcp, JSON.stringify({
mcpServers: { other: { command: 'uvx', args: ['other-server'] } },
}, null, 2) + '\n');
kiro.install('global', { autoAllow: true });
kiro.uninstall('global');
const after = JSON.parse(fs.readFileSync(mcp, 'utf-8'));
expect(after.mcpServers.other).toBeDefined();
expect(after.mcpServers.codegraph).toBeUndefined();
});
it('kiro: uninstall removes a leftover steering codegraph.md file outright', () => {
const kiro = getTarget('kiro')!;
const steering = path.join(tmpHome, '.kiro', 'steering', 'codegraph.md');
fs.mkdirSync(path.dirname(steering), { recursive: true });
fs.writeFileSync(steering, `${LEGACY_BLOCK}\n`);
kiro.uninstall('global');
expect(fs.existsSync(steering)).toBe(false);
});
it('kiro: uninstall removes our steering doc but leaves a sibling (product.md) untouched', () => {
const kiro = getTarget('kiro')!;
const sibling = path.join(tmpHome, '.kiro', 'steering', 'product.md');
const ours = path.join(tmpHome, '.kiro', 'steering', 'codegraph.md');
fs.mkdirSync(path.dirname(sibling), { recursive: true });
fs.writeFileSync(sibling, '# Product\n\nMy team practices.\n');
fs.writeFileSync(ours, `${LEGACY_BLOCK}\n`);
kiro.uninstall('global');
expect(fs.existsSync(ours)).toBe(false);
expect(fs.existsSync(sibling)).toBe(true);
expect(fs.readFileSync(sibling, 'utf-8')).toContain('My team practices.');
});
it('kiro: local install writes ./.kiro/settings/mcp.json and no steering doc (#529)', () => {
const kiro = getTarget('kiro')!;
const result = kiro.install('local', { autoAllow: true });
const paths = result.files.map((f) => f.path.replace(/\\/g, '/'));
expect(paths.some((p) => p.endsWith('/.kiro/settings/mcp.json'))).toBe(true);
expect(paths.some((p) => p.endsWith('/.kiro/steering/codegraph.md'))).toBe(false);
});
it('antigravity: install writes to LEGACY ~/.gemini/antigravity/mcp_config.json when no migration marker', () => {
const antigravity = getTarget('antigravity')!;
antigravity.install('global', { autoAllow: true });
const legacyFile = path.join(tmpHome, '.gemini', 'antigravity', 'mcp_config.json');
expect(fs.existsSync(legacyFile)).toBe(true);
const cfg = JSON.parse(fs.readFileSync(legacyFile, 'utf-8'));
expect(cfg.mcpServers.codegraph).toBeDefined();
// Crucially: does NOT touch the Gemini CLI's settings.json.
expect(fs.existsSync(path.join(tmpHome, '.gemini', 'settings.json'))).toBe(false);
});
it('antigravity: install writes to UNIFIED ~/.gemini/config/mcp_config.json when .migrated marker present', () => {
const antigravity = getTarget('antigravity')!;
// Plant the migration marker — same signal Antigravity itself drops
// when it migrates a user's config.
const unifiedDir = path.join(tmpHome, '.gemini', 'config');
fs.mkdirSync(unifiedDir, { recursive: true });
fs.writeFileSync(path.join(unifiedDir, '.migrated'), '');
antigravity.install('global', { autoAllow: true });
const unifiedFile = path.join(unifiedDir, 'mcp_config.json');
expect(fs.existsSync(unifiedFile)).toBe(true);
const cfg = JSON.parse(fs.readFileSync(unifiedFile, 'utf-8'));
expect(cfg.mcpServers.codegraph).toBeDefined();
// Legacy path is NOT touched when the marker tells us migration happened.
expect(fs.existsSync(path.join(tmpHome, '.gemini', 'antigravity', 'mcp_config.json'))).toBe(false);
});
it('antigravity: install writes to UNIFIED path when ~/.gemini/config/mcp_config.json already exists (even without marker)', () => {
const antigravity = getTarget('antigravity')!;
// Antigravity creates this file on first launch post-migration — its
// presence is the second signal we accept, in case the .migrated
// marker semantics change across Antigravity versions.
const unifiedFile = path.join(tmpHome, '.gemini', 'config', 'mcp_config.json');
fs.mkdirSync(path.dirname(unifiedFile), { recursive: true });
fs.writeFileSync(unifiedFile, JSON.stringify({ mcpServers: {} }, null, 2) + '\n');
antigravity.install('global', { autoAllow: true });
const cfg = JSON.parse(fs.readFileSync(unifiedFile, 'utf-8'));
expect(cfg.mcpServers.codegraph).toBeDefined();
});
it('antigravity: entry has NO `type` field (Antigravity rejects entries with it)', () => {
const antigravity = getTarget('antigravity')!;
// Marker → unified path; doesn't matter which path, just inspect the entry shape.
fs.mkdirSync(path.join(tmpHome, '.gemini', 'config'), { recursive: true });
fs.writeFileSync(path.join(tmpHome, '.gemini', 'config', '.migrated'), '');
antigravity.install('global', { autoAllow: true });
const cfg = JSON.parse(fs.readFileSync(
path.join(tmpHome, '.gemini', 'config', 'mcp_config.json'), 'utf-8'
));
expect(cfg.mcpServers.codegraph.type).toBeUndefined();
expect(cfg.mcpServers.codegraph.command).toBeDefined();
expect(cfg.mcpServers.codegraph.args).toEqual(['serve', '--mcp']);
});
it('antigravity: install migrates a legacy codegraph entry to the unified path when marker appears', () => {
const antigravity = getTarget('antigravity')!;
// Simulate: user installed on the legacy path, then Antigravity
// migrated their config (dropped the `.migrated` marker + created
// the unified file). Re-running codegraph install should land
// codegraph in the new file AND strip the stale legacy entry.
const legacyFile = path.join(tmpHome, '.gemini', 'antigravity', 'mcp_config.json');
fs.mkdirSync(path.dirname(legacyFile), { recursive: true });
fs.writeFileSync(legacyFile, JSON.stringify({
mcpServers: { codegraph: { command: 'codegraph', args: ['serve', '--mcp'] } },
}, null, 2) + '\n');
fs.mkdirSync(path.join(tmpHome, '.gemini', 'config'), { recursive: true });
fs.writeFileSync(path.join(tmpHome, '.gemini', 'config', '.migrated'), '');
antigravity.install('global', { autoAllow: true });
const unified = JSON.parse(fs.readFileSync(
path.join(tmpHome, '.gemini', 'config', 'mcp_config.json'), 'utf-8'
));
expect(unified.mcpServers.codegraph).toBeDefined();
// Legacy file's codegraph entry got stripped.
const legacy = JSON.parse(fs.readFileSync(legacyFile, 'utf-8'));
expect(legacy.mcpServers).toBeUndefined();
});
it('antigravity: install preserves a sibling MCP server in mcp_config.json (legacy path)', () => {
const antigravity = getTarget('antigravity')!;
const mcpFile = path.join(tmpHome, '.gemini', 'antigravity', 'mcp_config.json');
fs.mkdirSync(path.dirname(mcpFile), { recursive: true });
fs.writeFileSync(mcpFile, JSON.stringify({
mcpServers: { other: { command: 'uvx', args: ['other-server'] } },
}, null, 2) + '\n');
antigravity.install('global', { autoAllow: true });
const after = JSON.parse(fs.readFileSync(mcpFile, 'utf-8'));
expect(after.mcpServers.other).toBeDefined();
expect(after.mcpServers.codegraph).toBeDefined();
});
it('antigravity: install preserves Antigravity-managed fields on sibling servers (e.g. disabled flag)', () => {
const antigravity = getTarget('antigravity')!;
// Antigravity adds `"disabled": true` to entries the user disables via
// the IDE. Install must not clobber that on sibling entries.
fs.mkdirSync(path.join(tmpHome, '.gemini', 'config'), { recursive: true });
fs.writeFileSync(path.join(tmpHome, '.gemini', 'config', '.migrated'), '');
const unified = path.join(tmpHome, '.gemini', 'config', 'mcp_config.json');
fs.writeFileSync(unified, JSON.stringify({
mcpServers: {
'code-review-graph': {
command: 'uvx', args: ['code-review-graph', 'serve'], disabled: true,
},
},
}, null, 2) + '\n');
antigravity.install('global', { autoAllow: true });
const after = JSON.parse(fs.readFileSync(unified, 'utf-8'));
expect(after.mcpServers['code-review-graph'].disabled).toBe(true);
expect(after.mcpServers.codegraph).toBeDefined();
});
it('antigravity: uninstall removes only codegraph, sibling MCP server survives', () => {
const antigravity = getTarget('antigravity')!;
const mcpFile = path.join(tmpHome, '.gemini', 'antigravity', 'mcp_config.json');
fs.mkdirSync(path.dirname(mcpFile), { recursive: true });
fs.writeFileSync(mcpFile, JSON.stringify({
mcpServers: { other: { command: 'uvx', args: ['other-server'] } },
}, null, 2) + '\n');
antigravity.install('global', { autoAllow: true });
antigravity.uninstall('global');
const after = JSON.parse(fs.readFileSync(mcpFile, 'utf-8'));
expect(after.mcpServers.other).toBeDefined();
expect(after.mcpServers.codegraph).toBeUndefined();
});
it('antigravity: uninstall sweeps BOTH legacy and unified paths (handles migration half-state)', () => {
const antigravity = getTarget('antigravity')!;
// User had codegraph in BOTH files (e.g. legacy install + post-migration
// re-install before our migration cleanup landed). Uninstall must clean
// both so a "fresh slate" really is fresh.
const legacy = path.join(tmpHome, '.gemini', 'antigravity', 'mcp_config.json');
const unified = path.join(tmpHome, '.gemini', 'config', 'mcp_config.json');
fs.mkdirSync(path.dirname(legacy), { recursive: true });
fs.mkdirSync(path.dirname(unified), { recursive: true });
fs.writeFileSync(legacy, JSON.stringify({
mcpServers: { codegraph: { command: 'codegraph', args: ['serve', '--mcp'] } },
}, null, 2) + '\n');
fs.writeFileSync(unified, JSON.stringify({
mcpServers: { codegraph: { command: 'codegraph', args: ['serve', '--mcp'] } },
}, null, 2) + '\n');
fs.writeFileSync(path.join(path.dirname(unified), '.migrated'), '');
antigravity.uninstall('global');
const legacyAfter = JSON.parse(fs.readFileSync(legacy, 'utf-8'));
const unifiedAfter = JSON.parse(fs.readFileSync(unified, 'utf-8'));
expect(legacyAfter.mcpServers).toBeUndefined();
expect(unifiedAfter.mcpServers).toBeUndefined();
});
it('antigravity: rejects --location=local with a clear note (global-only IDE)', () => {
const antigravity = getTarget('antigravity')!;
expect(antigravity.supportsLocation('local')).toBe(false);
const result = antigravity.install('local', { autoAllow: true });
expect(result.files).toEqual([]);
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')!;
antigravity.install('global', { autoAllow: true });
const geminiMd = path.join(tmpHome, '.gemini', 'GEMINI.md');
expect(fs.existsSync(geminiMd)).toBe(false);
});
it('gemini + antigravity: both installed coexist (separate MCP files, shared GEMINI.md)', () => {
const gemini = getTarget('gemini')!;
const antigravity = getTarget('antigravity')!;
gemini.install('global', { autoAllow: true });
antigravity.install('global', { autoAllow: true });
const cliCfg = JSON.parse(fs.readFileSync(path.join(tmpHome, '.gemini', 'settings.json'), 'utf-8'));
// Antigravity lands on the LEGACY path here since no .migrated marker
// was planted — same end-to-end check either way.
const ideCfg = JSON.parse(fs.readFileSync(path.join(tmpHome, '.gemini', 'antigravity', 'mcp_config.json'), 'utf-8'));
expect(cliCfg.mcpServers.codegraph).toBeDefined();
expect(ideCfg.mcpServers.codegraph).toBeDefined();
// Uninstall one — the other's MCP entry must survive.
antigravity.uninstall('global');
const cliAfter = JSON.parse(fs.readFileSync(path.join(tmpHome, '.gemini', 'settings.json'), 'utf-8'));
expect(cliAfter.mcpServers.codegraph).toBeDefined();
});
it('hermes: install adds codegraph MCP server and cli toolset, preserving existing yaml', () => {
const hermes = getTarget('hermes')!;
const config = path.join(tmpHome, '.hermes', 'config.yaml');
fs.mkdirSync(path.dirname(config), { recursive: true });
fs.writeFileSync(config, [
'model:',
' default: qwen-3.7',
'mcp_servers:',
' other:',
' command: other',
'platform_toolsets:',
' cli:',
' - hermes-cli',
' discord:',
' - hermes-discord',
'',
].join('\n'));
const result = hermes.install('global', { autoAllow: true });
expect(result.files[0].action).toBe('updated');
const body = fs.readFileSync(config, 'utf-8');
expect(body).toContain('model:\n default: qwen-3.7');
expect(body).toContain('mcp_servers:\n other:\n command: other');
expect(body).toContain(' codegraph:\n command: codegraph');
expect(body).toContain(' - hermes-cli');
expect(body).toContain(' - mcp-codegraph');
expect(body).toContain(' discord:\n - hermes-discord');
const second = hermes.install('global', { autoAllow: true });
expect(second.files[0].action).toBe('unchanged');
});
it('hermes: uninstall removes only codegraph MCP server and toolset entry', () => {
const hermes = getTarget('hermes')!;
const config = path.join(tmpHome, '.hermes', 'config.yaml');
fs.mkdirSync(path.dirname(config), { recursive: true });
hermes.install('global', { autoAllow: true });
fs.appendFileSync(config, 'custom:\n keep: true\n');
hermes.uninstall('global');
const body = fs.readFileSync(config, 'utf-8');
expect(body).not.toContain('codegraph:');
expect(body).not.toContain('mcp-codegraph');
expect(body).toContain('custom:\n keep: true');
});
// Regression for #456: PyYAML's default block style writes list items at the
// SAME indent as the parent key (`cli:` and its `- hermes-cli` are both at
// indent 2). The pre-fix line-based patcher mistook that first list item for
// the next sibling key, truncated the cli block, and spliced `- mcp-codegraph`
// at indent 4 BEFORE the existing items — producing unparseable YAML.
it('hermes: install preserves PyYAML-default list-at-same-indent style (issue #456)', () => {
const hermes = getTarget('hermes')!;
const config = path.join(tmpHome, '.hermes', 'config.yaml');
fs.mkdirSync(path.dirname(config), { recursive: true });
const original = [
'model:',
' default: gpt-4o',
'platform_toolsets:',
' cli:',
' - hermes-cli',
' - browser',
' - clarify',
' - terminal',
' - web',
' telegram:',
' - hermes-telegram',
' discord:',
' - hermes-discord',
'',
].join('\n');
fs.writeFileSync(config, original);
hermes.install('global', { autoAllow: true });
const body = fs.readFileSync(config, 'utf-8');
// mcp-codegraph appended at the same 2-space indent as existing items
expect(body).toContain('\n - mcp-codegraph\n');
// hermes-cli preserved
expect(body).toContain('\n - hermes-cli\n');
// Sibling sections kept their indent — `telegram:` is still a key under
// platform_toolsets, not promoted up.
expect(body).toContain('\n telegram:\n - hermes-telegram\n');
expect(body).toContain('\n discord:\n - hermes-discord\n');
// No list items leaked to the platform_toolsets level (indent 0).
expect(body).not.toMatch(/^- browser/m);
expect(body).not.toMatch(/^- hermes-telegram/m);
// The whole platform_toolsets block extracted by line search should
// start with `cli:` and not contain a stray 4-space `mcp-codegraph`
// appearing before the rest of the existing items.
expect(body).toContain(' cli:\n - hermes-cli\n - browser');
// Idempotent
const second = hermes.install('global', { autoAllow: true });
expect(second.files[0]?.action).toBe('unchanged');
});
it('hermes: uninstall reverses the install on a PyYAML-default config', () => {
const hermes = getTarget('hermes')!;
const config = path.join(tmpHome, '.hermes', 'config.yaml');
fs.mkdirSync(path.dirname(config), { recursive: true });
const original = [
'platform_toolsets:',
' cli:',
' - hermes-cli',
' - browser',
' telegram:',
' - hermes-telegram',
'',
].join('\n');
fs.writeFileSync(config, original);
hermes.install('global', { autoAllow: true });
const installed = fs.readFileSync(config, 'utf-8');
expect(installed).toContain('- mcp-codegraph');
expect(installed).toContain('codegraph:');
hermes.uninstall('global');
const body = fs.readFileSync(config, 'utf-8');
expect(body).not.toContain('mcp-codegraph');
expect(body).not.toContain('command: codegraph');
expect(body).toContain(' cli:\n - hermes-cli\n - browser');
expect(body).toContain(' telegram:\n - hermes-telegram');
});
it('opencode: uninstall removes only mcp.codegraph, preserves comments and siblings', () => {
const opencode = getTarget('opencode')!;
const dir = path.join(tmpHome, '.config', 'opencode');
fs.mkdirSync(dir, { recursive: true });
const file = path.join(dir, 'opencode.jsonc');
fs.writeFileSync(file, [
'{',
' // important comment',
' "$schema": "https://opencode.ai/config.json",',
' "mcp": {',
' "other": { "type": "local", "command": ["x"], "enabled": true }',
' }',
'}',
'',
].join('\n'));
opencode.install('global', { autoAllow: true });
const afterInstall = fs.readFileSync(file, 'utf-8');
expect(afterInstall).toContain('"codegraph"');
expect(afterInstall).toContain('"other"');
opencode.uninstall('global');
const afterUninstall = fs.readFileSync(file, 'utf-8');
expect(afterUninstall).not.toContain('codegraph');
expect(afterUninstall).toContain('// important comment');
expect(afterUninstall).toContain('"other"');
});
it('codex: user-added key inside [mcp_servers.codegraph] survives idempotent re-install', () => {
const codex = getTarget('codex')!;
codex.install('global', { autoAllow: false });
const tomlPath = path.join(tmpHome, '.codex', 'config.toml');
const original = fs.readFileSync(tomlPath, 'utf-8');
// User edits the block to add a custom key.
const edited = original.replace(
'args = ["serve", "--mcp"]',
'args = ["serve", "--mcp"]\nenabled = true',
);
fs.writeFileSync(tomlPath, edited);
// Re-install: our serializer doesn't know `enabled = true`, so
// the block no longer matches the canonical form — we'll
// overwrite it. This is the documented contract: we own the
// codegraph block exclusively.
const second = codex.install('global', { autoAllow: false });
const tomlEntry = second.files.find((f) => f.path.endsWith('config.toml'))!;
expect(tomlEntry.action).toBe('updated');
const after = fs.readFileSync(tomlPath, 'utf-8');
expect(after).not.toContain('enabled = true');
});
it('codex: install, re-install, and uninstall preserve trailing array-of-tables siblings', () => {
const codex = getTarget('codex')!;
const tomlPath = path.join(tmpHome, '.codex', 'config.toml');
fs.mkdirSync(path.dirname(tomlPath), { recursive: true });
const historyTables = [
'[[history]]',
'id = 1',
'note = "keep first"',
'',
'[[history]]',
'id = 2',
'note = "keep second"',
'',
].join('\n');
fs.writeFileSync(tomlPath, [
'[mcp_servers.codegraph]',
'command = "old-codegraph"',
'args = ["old"]',
'description = """',
'header-shaped text inside a multiline string:',
'[[not-a-table]]',
'still part of the string',
'"""',
'',
historyTables,
].join('\n'));
const first = codex.install('global', { autoAllow: false });
expect(first.files.find((f) => f.path === tomlPath)?.action).toBe('updated');
const afterInstall = fs.readFileSync(tomlPath, 'utf-8');
expect(afterInstall).toContain('command = "codegraph"');
expect(afterInstall).not.toContain('[[not-a-table]]');
expect(afterInstall.endsWith(historyTables)).toBe(true);
const second = codex.install('global', { autoAllow: false });
expect(second.files.find((f) => f.path === tomlPath)?.action).toBe('unchanged');
expect(fs.readFileSync(tomlPath, 'utf-8')).toBe(afterInstall);
codex.uninstall('global');
expect(fs.readFileSync(tomlPath, 'utf-8')).toBe(historyTables);
});
it('claude: local install writes ./.mcp.json (project scope), not ./.claude.json', () => {
const claude = getTarget('claude')!;
const result = claude.install('local', { autoAllow: false });
// The MCP entry lands in ./.mcp.json — the file Claude Code reads.
expect(result.files.some((f) => f.path.replace(/\\/g, '/').endsWith('/.mcp.json'))).toBe(true);
expect(fs.existsSync(path.join(tmpCwd, '.mcp.json'))).toBe(true);
expect(fs.existsSync(path.join(tmpCwd, '.claude.json'))).toBe(false);
const cfg = JSON.parse(fs.readFileSync(path.join(tmpCwd, '.mcp.json'), 'utf-8'));
expect(cfg.mcpServers.codegraph).toBeDefined();
});
it('claude: install creates the CLAUDE.md codegraph block (#704)', () => {
const claude = getTarget('claude')!;
const result = claude.install('local', { autoAllow: false });
const claudeMd = path.join(tmpCwd, '.claude', 'CLAUDE.md');
expect(fs.existsSync(claudeMd)).toBe(true);
const body = fs.readFileSync(claudeMd, 'utf-8');
expect(body).toContain('## CodeGraph');
expect(body).toContain('codegraph explore');
expect(result.files.find((f) => f.path.endsWith('CLAUDE.md'))?.action).toBe('created');
});
it('claude: install replaces a legacy CLAUDE.md codegraph block, keeping user content', () => {
const claude = getTarget('claude')!;
const claudeMd = path.join(tmpCwd, '.claude', 'CLAUDE.md');
fs.mkdirSync(path.dirname(claudeMd), { recursive: true });
fs.writeFileSync(claudeMd, `# My project rules\n\nUse tabs.\n\n${LEGACY_BLOCK}\n`);
const result = claude.install('local', { autoAllow: false });
const body = fs.readFileSync(claudeMd, 'utf-8');
expect(body).toContain('# My project rules');
expect(body).toContain('Use tabs.');
expect(body).not.toContain('Prefer `codegraph_search`');
expect(body).toContain('codegraph explore');
expect(result.files.find((f) => f.path.endsWith('CLAUDE.md'))?.action).toBe('updated');
});
it('claude: global install targets ~/.claude.json (user scope)', () => {
const claude = getTarget('claude')!;
claude.install('global', { autoAllow: false });
const cfg = JSON.parse(fs.readFileSync(path.join(tmpHome, '.claude.json'), 'utf-8'));
expect(cfg.mcpServers.codegraph).toBeDefined();
});
it('claude: local install migrates a legacy ./.claude.json codegraph entry into ./.mcp.json', () => {
const claude = getTarget('claude')!;
const legacy = path.join(tmpCwd, '.claude.json');
fs.writeFileSync(
legacy,
JSON.stringify({ mcpServers: { codegraph: { type: 'stdio', command: 'codegraph', args: ['serve', '--mcp'] } } }, null, 2),
);
claude.install('local', { autoAllow: false });
// codegraph now lives in .mcp.json; the legacy file (which held only
// codegraph) is gone.
const mcp = JSON.parse(fs.readFileSync(path.join(tmpCwd, '.mcp.json'), 'utf-8'));
expect(mcp.mcpServers.codegraph).toBeDefined();
expect(fs.existsSync(legacy)).toBe(false);
});
it('claude: legacy ./.claude.json migration preserves sibling servers and unrelated keys', () => {
const claude = getTarget('claude')!;
const legacy = path.join(tmpCwd, '.claude.json');
fs.writeFileSync(
legacy,
JSON.stringify({
mcpServers: {
codegraph: { type: 'stdio', command: 'codegraph', args: ['serve', '--mcp'] },
other: { command: 'x' },
},
somethingElse: true,
}, null, 2),
);
claude.install('local', { autoAllow: false });
// Only codegraph is stripped from the legacy file; siblings survive.
const after = JSON.parse(fs.readFileSync(legacy, 'utf-8'));
expect(after.mcpServers.codegraph).toBeUndefined();
expect(after.mcpServers.other).toBeDefined();
expect(after.somethingElse).toBe(true);
const mcp = JSON.parse(fs.readFileSync(path.join(tmpCwd, '.mcp.json'), 'utf-8'));
expect(mcp.mcpServers.codegraph).toBeDefined();
});
it('claude: uninstall strips codegraph from ./.mcp.json and a legacy ./.claude.json', () => {
const claude = getTarget('claude')!;
// A user left with both the working .mcp.json and a stale .claude.json.
fs.writeFileSync(
path.join(tmpCwd, '.mcp.json'),
JSON.stringify({ mcpServers: { codegraph: { command: 'codegraph' } } }, null, 2),
);
fs.writeFileSync(
path.join(tmpCwd, '.claude.json'),
JSON.stringify({ mcpServers: { codegraph: { command: 'codegraph' }, other: { command: 'x' } } }, null, 2),
);
claude.uninstall('local');
const mcp = JSON.parse(fs.readFileSync(path.join(tmpCwd, '.mcp.json'), 'utf-8'));
expect(mcp.mcpServers).toBeUndefined();
const legacy = JSON.parse(fs.readFileSync(path.join(tmpCwd, '.claude.json'), 'utf-8'));
expect(legacy.mcpServers.codegraph).toBeUndefined();
expect(legacy.mcpServers.other).toBeDefined();
});
// ---- Legacy auto-sync hook cleanup ----
// Pre-0.8 installs wrote `codegraph mark-dirty` / `sync-if-dirty`
// hooks to settings.json. Both subcommands were removed from the CLI,
// so the Stop hook fails every turn ("unknown command
// 'sync-if-dirty'"). The installer must strip them on upgrade and
// uninstall — without touching the user's unrelated hooks.
function seedSettings(loc: 'global' | 'local', settings: Record<string, any>): string {
const dir = path.join(loc === 'global' ? tmpHome : tmpCwd, '.claude');
fs.mkdirSync(dir, { recursive: true });
const file = path.join(dir, 'settings.json');
fs.writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
return file;
}
// Realistic pre-0.8 settings.json: our two auto-sync hooks plus an
// unrelated GitKraken Stop hook the user added (matches the report).
function legacyHookSettings(): Record<string, any> {
return {
hooks: {
PostToolUse: [
{ matcher: 'Edit|Write', hooks: [{ type: 'command', command: 'codegraph mark-dirty', async: true }] },
],
Stop: [
{ hooks: [{ type: 'command', command: 'codegraph sync-if-dirty' }] },
{ hooks: [{ type: 'command', command: '"/Users/me/gk" ai hook run --host claude-code' }] },
],
},
};
}
it('claude: install strips stale codegraph auto-sync hooks but keeps the user\'s GitKraken hook', () => {
const claude = getTarget('claude')!;
const file = seedSettings('global', legacyHookSettings());
claude.install('global', { autoAllow: true });
const after = JSON.parse(fs.readFileSync(file, 'utf-8'));
// The only PostToolUse group held mark-dirty → the event is gone.
expect(after.hooks?.PostToolUse).toBeUndefined();
const stopCommands = (after.hooks?.Stop ?? []).flatMap((g: any) =>
(g.hooks ?? []).map((h: any) => h.command),
);
expect(stopCommands).not.toContain('codegraph sync-if-dirty');
// The unrelated GitKraken hook survives untouched.
expect(stopCommands.some((c: string) => c.includes('gk') && c.includes('ai hook run'))).toBe(true);
// Permissions still written as normal alongside the cleanup.
expect(after.permissions?.allow).toContain('mcp__codegraph__*');
});
it('claude: cleanupLegacyHooks preserves a sibling hook sharing our matcher group', () => {
const file = seedSettings('global', {
hooks: {
Stop: [
{
hooks: [
{ type: 'command', command: 'codegraph sync-if-dirty' },
{ type: 'command', command: 'gk ai hook run --host claude-code' },
],
},
],
},
});
expect(cleanupLegacyHooks('global').action).toBe('removed');
const after = JSON.parse(fs.readFileSync(file, 'utf-8'));
expect(after.hooks.Stop[0].hooks.map((h: any) => h.command)).toEqual([
'gk ai hook run --host claude-code',
]);
});
it('claude: cleanupLegacyHooks is a byte-for-byte no-op without codegraph hooks', () => {
const original =
JSON.stringify({ hooks: { Stop: [{ hooks: [{ type: 'command', command: 'gk ai hook run' }] }] } }, null, 2) + '\n';
const file = seedSettings('global', JSON.parse(original));
expect(cleanupLegacyHooks('global').action).toBe('unchanged');
expect(fs.readFileSync(file, 'utf-8')).toBe(original);
});
it('claude: cleanupLegacyHooks reports not-found when settings.json is absent', () => {
expect(cleanupLegacyHooks('global').action).toBe('not-found');
});
it('claude: re-running install after a legacy cleanup leaves settings.json unchanged', () => {
const claude = getTarget('claude')!;
const file = seedSettings('global', legacyHookSettings());
claude.install('global', { autoAllow: true });
const firstPass = fs.readFileSync(file, 'utf-8');
claude.install('global', { autoAllow: true });
expect(fs.readFileSync(file, 'utf-8')).toBe(firstPass);
});
it('claude: uninstall strips stale hooks written in the npx form (local)', () => {
const claude = getTarget('claude')!;
const file = seedSettings('local', {
hooks: {
PostToolUse: [
{ matcher: 'Edit|Write', hooks: [{ type: 'command', command: 'npx @colbymchenry/codegraph mark-dirty', async: true }] },
],
Stop: [
{ hooks: [{ type: 'command', command: 'npx @colbymchenry/codegraph sync-if-dirty' }] },
],
},
});
claude.uninstall('local');
const after = JSON.parse(fs.readFileSync(file, 'utf-8'));
// Both events emptied → the whole `hooks` object is removed.
expect(after.hooks).toBeUndefined();
});
// ---- Front-load prompt hook (UserPromptSubmit) — #841 follow-up ----
// Opt-in (default-yes in the installer) UserPromptSubmit hook that runs
// `codegraph prompt-hook`. Must write/remove surgically, be idempotent, and
// round-trip an opt-out — without disturbing the user's own hooks.
// Platform-aware since #1466: Windows writes `codegraph.cmd prompt-hook`
// (Git Bash applies no PATHEXT, so the bare form is exit 127 there), and
// install self-heals the other platform's spelling in place.
const HOOK_CMD = process.platform === 'win32' ? 'codegraph.cmd prompt-hook' : 'codegraph prompt-hook';
const OTHER_PLATFORM_HOOK_CMD = process.platform === 'win32' ? 'codegraph prompt-hook' : 'codegraph.cmd prompt-hook';
const promptCommands = (s: any): string[] =>
(s.hooks?.UserPromptSubmit ?? []).flatMap((g: any) => (g.hooks ?? []).map((h: any) => h.command));
it('claude: install with promptHook:true writes the UserPromptSubmit hook (alongside permissions)', () => {
const claude = getTarget('claude')!;
claude.install('global', { autoAllow: true, promptHook: true });
const s = JSON.parse(fs.readFileSync(path.join(tmpHome, '.claude', 'settings.json'), 'utf-8'));
expect(promptCommands(s)).toContain(HOOK_CMD);
expect(s.permissions?.allow).toContain('mcp__codegraph__*');
});
it('claude: install without promptHook does NOT add the hook', () => {
const claude = getTarget('claude')!;
claude.install('global', { autoAllow: true });
const s = JSON.parse(fs.readFileSync(path.join(tmpHome, '.claude', 'settings.json'), 'utf-8'));
expect(promptCommands(s)).not.toContain(HOOK_CMD);
});
it('claude: install with promptHook:true is idempotent (no duplicate, byte-identical re-run)', () => {
const claude = getTarget('claude')!;
const file = path.join(tmpHome, '.claude', 'settings.json');
claude.install('global', { autoAllow: true, promptHook: true });
const first = fs.readFileSync(file, 'utf-8');
claude.install('global', { autoAllow: true, promptHook: true });
expect(fs.readFileSync(file, 'utf-8')).toBe(first);
const s = JSON.parse(first);
expect(promptCommands(s).filter((c: string) => c === HOOK_CMD)).toHaveLength(1);
});
it('claude: install with promptHook:false strips a hook a prior install wrote (opt-out round-trips)', () => {
const claude = getTarget('claude')!;
claude.install('global', { autoAllow: true, promptHook: true });
claude.install('global', { autoAllow: true, promptHook: false });
const s = JSON.parse(fs.readFileSync(path.join(tmpHome, '.claude', 'settings.json'), 'utf-8'));
expect(promptCommands(s)).not.toContain(HOOK_CMD);
});
it('claude: writePromptHookEntry preserves a sibling UserPromptSubmit hook', () => {
const file = seedSettings('global', {
hooks: { UserPromptSubmit: [{ hooks: [{ type: 'command', command: 'my-own-hook' }] }] },
});
expect(writePromptHookEntry('global').action).toBe('updated');
const s = JSON.parse(fs.readFileSync(file, 'utf-8'));
expect(promptCommands(s)).toEqual(['my-own-hook', HOOK_CMD]);
});
it('claude: writePromptHookEntry migrates the other platform\'s spelling in place (#1466 self-heal)', () => {
const file = seedSettings('global', {
hooks: { UserPromptSubmit: [{ hooks: [{ type: 'command', command: OTHER_PLATFORM_HOOK_CMD }] }] },
});
expect(writePromptHookEntry('global').action).toBe('updated');
const s = JSON.parse(fs.readFileSync(file, 'utf-8'));
expect(promptCommands(s)).toEqual([HOOK_CMD]);
// A re-run after migration is byte-identical.
const healed = fs.readFileSync(file, 'utf-8');
expect(writePromptHookEntry('global').action).toBe('unchanged');
expect(fs.readFileSync(file, 'utf-8')).toBe(healed);
});
it('claude: writePromptHookEntry leaves an npx-form hook untouched (no duplicate, no rewrite)', () => {
const npxCmd = 'npx @colbymchenry/codegraph prompt-hook';
const file = seedSettings('global', {
hooks: { UserPromptSubmit: [{ hooks: [{ type: 'command', command: npxCmd }] }] },
});
expect(writePromptHookEntry('global').action).toBe('unchanged');
const s = JSON.parse(fs.readFileSync(file, 'utf-8'));
expect(promptCommands(s)).toEqual([npxCmd]);
});
it('claude: uninstall removes the prompt hook but keeps the user\'s sibling', () => {
const file = seedSettings('global', {
hooks: {
UserPromptSubmit: [
{ hooks: [{ type: 'command', command: HOOK_CMD }] },
{ hooks: [{ type: 'command', command: 'my-own-hook' }] },
],
},
});
getTarget('claude')!.uninstall('global');
const s = JSON.parse(fs.readFileSync(file, 'utf-8'));
expect(promptCommands(s)).toEqual(['my-own-hook']);
});
it('claude: removePromptHookEntry removes the other platform\'s spelling too', () => {
const file = seedSettings('global', {
hooks: {
UserPromptSubmit: [{ hooks: [{ type: 'command', command: OTHER_PLATFORM_HOOK_CMD }] }],
},
});
expect(removePromptHookEntry('global').action).toBe('removed');
const s = JSON.parse(fs.readFileSync(file, 'utf-8'));
expect(promptCommands(s)).toEqual([]);
});
it('claude: removePromptHookEntry leaves the legacy auto-sync hook untouched', () => {
const file = seedSettings('global', {
hooks: {
UserPromptSubmit: [{ hooks: [{ type: 'command', command: HOOK_CMD }] }],
Stop: [{ hooks: [{ type: 'command', command: 'codegraph sync-if-dirty' }] }],
},
});
expect(removePromptHookEntry('global').action).toBe('removed');
const s = JSON.parse(fs.readFileSync(file, 'utf-8'));
expect(promptCommands(s)).not.toContain(HOOK_CMD);
const stopCmds = (s.hooks?.Stop ?? []).flatMap((g: any) => (g.hooks ?? []).map((h: any) => h.command));
expect(stopCmds).toContain('codegraph sync-if-dirty');
});
});
describe('Installer targets — registry', () => {
it('getTarget returns the right target for each id', () => {
expect(getTarget('claude')?.id).toBe('claude');
expect(getTarget('cursor')?.id).toBe('cursor');
expect(getTarget('codex')?.id).toBe('codex');
expect(getTarget('opencode')?.id).toBe('opencode');
expect(getTarget('hermes')?.id).toBe('hermes');
expect(getTarget('gemini')?.id).toBe('gemini');
expect(getTarget('antigravity')?.id).toBe('antigravity');
expect(getTarget('kiro')?.id).toBe('kiro');
expect(getTarget('copilot-vscode')?.id).toBe('copilot-vscode');
expect(getTarget('copilot-cli')?.id).toBe('copilot-cli');
expect(getTarget('copilot-jetbrains')?.id).toBe('copilot-jetbrains');
expect(getTarget('not-a-real-target')).toBeUndefined();
});
it('resolveTargetFlag handles auto/all/none/csv', () => {
expect(resolveTargetFlag('none', 'global')).toEqual([]);
expect(resolveTargetFlag('all', 'global').length).toBe(ALL_TARGETS.length);
const csv = resolveTargetFlag('claude,cursor', 'global');
expect(csv.map((t) => t.id)).toEqual(['claude', 'cursor']);
});
it("resolveTargetFlag('all') includes every Copilot target", () => {
const ids = resolveTargetFlag('all', 'global').map((t) => t.id);
expect(ids).toContain('copilot-vscode');
expect(ids).toContain('copilot-cli');
expect(ids).toContain('copilot-jetbrains');
});
it('resolveTargetFlag resolves the Copilot ids from a csv list', () => {
const csv = resolveTargetFlag('copilot-vscode,copilot-cli,copilot-jetbrains', 'global');
expect(csv.map((t) => t.id)).toEqual(['copilot-vscode', 'copilot-cli', 'copilot-jetbrains']);
});
it('resolveTargetFlag throws on unknown id', () => {
expect(() => resolveTargetFlag('claude,bogus', 'global')).toThrow(/Unknown --target/);
});
});
describe('Installer targets — TOML serializer (Codex backbone)', () => {
it('builds a [mcp_servers.codegraph] block with command + args', () => {
const block = buildTomlTable('mcp_servers.codegraph', {
command: 'codegraph',
args: ['serve', '--mcp'],
});
expect(block).toContain('[mcp_servers.codegraph]');
expect(block).toContain('command = "codegraph"');
expect(block).toContain('args = ["serve", "--mcp"]');
});
it('upsert inserts into empty content', () => {
const block = buildTomlTable('mcp_servers.codegraph', { command: 'codegraph', args: ['serve'] });
const { content, action } = upsertTomlTable('', 'mcp_servers.codegraph', block);
expect(action).toBe('inserted');
expect(content.startsWith('[mcp_servers.codegraph]')).toBe(true);
});
it('upsert is idempotent — second call returns unchanged', () => {
const block = buildTomlTable('mcp_servers.codegraph', { command: 'codegraph', args: ['serve'] });
const first = upsertTomlTable('', 'mcp_servers.codegraph', block);
const second = upsertTomlTable(first.content, 'mcp_servers.codegraph', block);
expect(second.action).toBe('unchanged');
expect(second.content).toBe(first.content);
});
it('upsert replaces an existing block in place, preserving sibling tables', () => {
const existing = [
'[other_table]',
'foo = "bar"',
'',
'[mcp_servers.codegraph]',
'command = "old-codegraph"',
'args = ["old"]',
'',
'[zzz]',
'baz = "qux"',
'',
].join('\n');
const newBlock = buildTomlTable('mcp_servers.codegraph', {
command: 'codegraph',
args: ['serve', '--mcp'],
});
const { content, action } = upsertTomlTable(existing, 'mcp_servers.codegraph', newBlock);
expect(action).toBe('replaced');
expect(content).toContain('[other_table]');
expect(content).toContain('foo = "bar"');
expect(content).toContain('[zzz]');
expect(content).toContain('baz = "qux"');
expect(content).toContain('command = "codegraph"');
expect(content).not.toContain('old-codegraph');
});
it('removeTomlTable strips the block and preserves siblings', () => {
const existing = [
'[other_table]',
'foo = "bar"',
'',
'[mcp_servers.codegraph]',
'command = "codegraph"',
'args = ["serve"]',
].join('\n');
const { content, action } = removeTomlTable(existing, 'mcp_servers.codegraph');
expect(action).toBe('removed');
expect(content).toContain('[other_table]');
expect(content).toContain('foo = "bar"');
expect(content).not.toContain('mcp_servers.codegraph');
});
it('removeTomlTable on missing table returns not-found, no content change', () => {
const existing = '[other]\nfoo = "bar"\n';
const { content, action } = removeTomlTable(existing, 'mcp_servers.codegraph');
expect(action).toBe('not-found');
expect(content).toBe(existing);
});
it('upsert preserves an array-of-tables sibling [[foo]]', () => {
const existing = [
'[[foo]]',
'name = "a"',
'',
'[[foo]]',
'name = "b"',
'',
].join('\n');
const block = buildTomlTable('mcp_servers.codegraph', { command: 'codegraph', args: ['serve'] });
const { content } = upsertTomlTable(existing, 'mcp_servers.codegraph', block);
expect(content.match(/\[\[foo\]\]/g)?.length).toBe(2);
expect(content).toContain('[mcp_servers.codegraph]');
});
it('upsert replaces the managed table without consuming trailing array-of-tables siblings', () => {
const historyTables = [
'[[history]]',
'id = 1',
'note = "keep first"',
'',
'[[history]]',
'id = 2',
'note = "keep second"',
'',
].join('\n');
const existing = [
'[mcp_servers.codegraph]',
'command = "old-codegraph"',
'args = ["old"]',
'',
historyTables,
].join('\n');
const block = buildTomlTable('mcp_servers.codegraph', {
command: 'codegraph',
args: ['serve', '--mcp'],
});
const { content, action } = upsertTomlTable(existing, 'mcp_servers.codegraph', block);
expect(action).toBe('replaced');
expect(content).toBe(`${block}\n\n${historyTables}`);
});
it('remove preserves trailing array-of-tables siblings byte-for-byte', () => {
const historyTables = [
'[[history]]',
'id = 1',
'note = "keep first"',
'',
'[[history]]',
'id = 2',
'note = "keep second"',
'',
].join('\n');
const existing = [
'[mcp_servers.codegraph]',
'command = "codegraph"',
'args = ["serve", "--mcp"]',
'',
historyTables,
].join('\n');
const { content, action } = removeTomlTable(existing, 'mcp_servers.codegraph');
expect(action).toBe('removed');
expect(content).toBe(historyTables);
});
it.each([
['table', '[ mcp_servers.other ]'],
['array-of-tables', '[[ history ]]'],
])('preserves a trailing %s header with inner whitespace', (_kind, siblingHeader) => {
const siblingTable = `${siblingHeader}\nvalue = "keep"\n`;
const existing = [
'[mcp_servers.codegraph]',
'command = "old-codegraph"',
'args = ["old"]',
'',
siblingTable,
].join('\n');
const block = buildTomlTable('mcp_servers.codegraph', {
command: 'codegraph',
args: ['serve', '--mcp'],
});
const upserted = upsertTomlTable(existing, 'mcp_servers.codegraph', block);
const removed = removeTomlTable(existing, 'mcp_servers.codegraph');
expect(upserted.content).toBe(`${block}\n\n${siblingTable}`);
expect(removed.content).toBe(siblingTable);
});
it.each([
['basic', '"""'],
['literal', "'''"],
])('ignores header-shaped text inside a multiline %s string', (_kind, delimiter) => {
const historyTable = '[[history]]\nid = 1\n';
const existing = [
'[mcp_servers.codegraph]',
'command = "old-codegraph"',
'args = [',
` ${delimiter}first line`,
'[[not-a-table]]',
`last line${delimiter},`,
' "serve",',
']',
'',
historyTable,
].join('\n');
const block = buildTomlTable('mcp_servers.codegraph', {
command: 'codegraph',
args: ['serve', '--mcp'],
});
const upserted = upsertTomlTable(existing, 'mcp_servers.codegraph', block);
const removed = removeTomlTable(existing, 'mcp_servers.codegraph');
expect(upserted.content).toBe(`${block}\n\n${historyTable}`);
expect(removed.content).toBe(historyTable);
});
});
describe('Installer — uninstallTargets sweep (codegraph uninstall)', () => {
let tmpHome: string;
let tmpCwd: string;
let origCwd: string;
let homeRestore: { restore: () => void };
beforeEach(() => {
tmpHome = mkTmpDir('un-home');
tmpCwd = mkTmpDir('un-cwd');
origCwd = process.cwd();
process.chdir(tmpCwd);
homeRestore = setHome(tmpHome);
});
afterEach(() => {
homeRestore.restore();
process.chdir(origCwd);
fs.rmSync(tmpHome, { recursive: true, force: true });
fs.rmSync(tmpCwd, { recursive: true, force: true });
});
it('sweeps every agent it was installed on and reports removed for each (global)', () => {
for (const t of ALL_TARGETS) {
if (t.supportsLocation('global')) t.install('global', { autoAllow: true });
}
const reports = uninstallTargets(ALL_TARGETS, 'global');
for (const t of ALL_TARGETS) {
const r = reports.find((x) => x.id === t.id)!;
expect(r.status).toBe('removed');
expect(r.removedPaths.length).toBeGreaterThan(0);
// The actual config is gone afterward.
expect(t.detect('global').alreadyConfigured).toBe(false);
}
});
it('is safe on a clean slate — every agent reports not-configured, nothing removed', () => {
const reports = uninstallTargets(ALL_TARGETS, 'global');
for (const r of reports) {
expect(r.status).toBe('not-configured');
expect(r.removedPaths).toEqual([]);
}
});
it('reports removed only for agents that were actually configured', () => {
// Install on Claude only; the rest stay untouched.
getTarget('claude')!.install('global', { autoAllow: true });
const reports = uninstallTargets(ALL_TARGETS, 'global');
const claude = reports.find((r) => r.id === 'claude')!;
expect(claude.status).toBe('removed');
expect(claude.displayName).toBe(getTarget('claude')!.displayName);
for (const r of reports.filter((x) => x.id !== 'claude')) {
expect(r.status).toBe('not-configured');
}
});
it('marks global-only agents as unsupported for a local sweep (and never touches them)', () => {
const reports = uninstallTargets(ALL_TARGETS, 'local');
for (const t of ALL_TARGETS) {
const r = reports.find((x) => x.id === t.id)!;
if (t.supportsLocation('local')) {
expect(r.status).toBe('not-configured');
} else {
expect(r.status).toBe('unsupported');
expect(r.removedPaths).toEqual([]);
expect(r.notes[0]).toMatch(/global-only/);
}
}
});
it('is idempotent — a second sweep finds nothing left to remove', () => {
for (const t of ALL_TARGETS) {
if (t.supportsLocation('global')) t.install('global', { autoAllow: true });
}
const first = uninstallTargets(ALL_TARGETS, 'global');
expect(first.some((r) => r.status === 'removed')).toBe(true);
const second = uninstallTargets(ALL_TARGETS, 'global');
for (const r of second) {
expect(r.status).toBe('not-configured');
expect(r.removedPaths).toEqual([]);
}
});
it('a --target subset removes only the chosen agents, leaving siblings configured', () => {
getTarget('claude')!.install('global', { autoAllow: true });
getTarget('cursor')!.install('global', { autoAllow: true });
const reports = uninstallTargets(resolveTargetFlag('claude', 'global'), 'global');
expect(reports.map((r) => r.id)).toEqual(['claude']);
expect(reports[0].status).toBe('removed');
// Cursor was not in the subset — still configured.
expect(getTarget('cursor')!.detect('global').alreadyConfigured).toBe(true);
expect(getTarget('claude')!.detect('global').alreadyConfigured).toBe(false);
});
});
describe('Installer — refreshTargets sweep (codegraph install --refresh)', () => {
let tmpHome: string;
let tmpCwd: string;
let origCwd: string;
let homeRestore: { restore: () => void };
beforeEach(() => {
tmpHome = mkTmpDir('rf-home');
tmpCwd = mkTmpDir('rf-cwd');
origCwd = process.cwd();
process.chdir(tmpCwd);
homeRestore = setHome(tmpHome);
});
afterEach(() => {
homeRestore.restore();
process.chdir(origCwd);
fs.rmSync(tmpHome, { recursive: true, force: true });
fs.rmSync(tmpCwd, { recursive: true, force: true });
});
it('rewrites a stale instructions block a previous version left, and reports refreshed', () => {
const claude = getTarget('claude')!;
claude.install('global', { autoAllow: true });
// Simulate the file as an old install left it: same markers, the old
// multi-tool wording.
const claudeMd = path.join(tmpHome, '.claude', 'CLAUDE.md');
fs.writeFileSync(claudeMd, LEGACY_BLOCK + '\n');
const reports = refreshTargets([claude], 'global');
expect(reports[0].status).toBe('refreshed');
expect(reports[0].changedPaths).toContain(claudeMd);
const md = fs.readFileSync(claudeMd, 'utf-8');
expect(md).not.toContain('codegraph_search');
expect(md).toContain('codegraph_explore');
});
it('never performs a first install — unconfigured agents stay untouched', () => {
const reports = refreshTargets(ALL_TARGETS, 'global');
for (const t of ALL_TARGETS) {
const r = reports.find((x) => x.id === t.id)!;
expect(r.status).toBe(t.supportsLocation('global') ? 'not-configured' : 'unsupported');
expect(r.changedPaths).toEqual([]);
expect(t.detect('global').alreadyConfigured).toBe(false);
}
});
it('preserves the user\'s permission choices (refresh never writes permissions)', () => {
const claude = getTarget('claude')!;
claude.install('global', { autoAllow: true });
// The user has since trimmed the allowlist by hand.
const settingsPath = path.join(tmpHome, '.claude', 'settings.json');
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
settings.permissions.allow = [];
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
refreshTargets([claude], 'global');
const after = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
expect(after.permissions.allow).toEqual([]);
});
it('is idempotent — a second sweep on a current machine reports unchanged everywhere', () => {
for (const t of ALL_TARGETS) {
if (t.supportsLocation('global')) t.install('global', { autoAllow: true });
}
const first = refreshTargets(ALL_TARGETS, 'global');
// Fresh installs are already current, so even the first sweep may be
// all-unchanged; what matters is the second definitely is.
const second = refreshTargets(ALL_TARGETS, 'global');
for (const r of [...first, ...second]) {
expect(['unchanged', 'refreshed']).toContain(r.status);
}
for (const r of second) {
expect(r.status).toBe('unchanged');
expect(r.changedPaths).toEqual([]);
}
});
});
describe('Installer — Cursor rules file cleanup on uninstall', () => {
let tmpHome: string;
let tmpCwd: string;
let origCwd: string;
let homeRestore: { restore: () => void };
const cursor = getTarget('cursor')!;
beforeEach(() => {
tmpHome = mkTmpDir('cur-home');
tmpCwd = mkTmpDir('cur-cwd');
origCwd = process.cwd();
process.chdir(tmpCwd);
homeRestore = setHome(tmpHome);
});
afterEach(() => {
homeRestore.restore();
process.chdir(origCwd);
fs.rmSync(tmpHome, { recursive: true, force: true });
fs.rmSync(tmpCwd, { recursive: true, force: true });
});
const rulesFile = () => path.join(process.cwd(), '.cursor', 'rules', 'codegraph.mdc');
// The frontmatter a previous install wrote ahead of the marked block.
// `removeRulesEntry` recognizes it to decide whether the leftover .mdc
// is ours-to-delete or carries user content worth keeping.
const MDC_FRONTMATTER = [
'---',
'description: CodeGraph MCP usage guide — when to use which tool',
'alwaysApply: true',
'---',
'',
].join('\n');
function plantLegacyRulesFile(extra = ''): void {
fs.mkdirSync(path.dirname(rulesFile()), { recursive: true });
fs.writeFileSync(rulesFile(), MDC_FRONTMATTER + LEGACY_BLOCK + '\n' + extra);
}
it('uninstall deletes a leftover codegraph.mdc entirely (no orphaned frontmatter left behind)', () => {
plantLegacyRulesFile();
expect(fs.existsSync(rulesFile())).toBe(true);
cursor.uninstall('local');
// The whole file — frontmatter included — is gone, not just the block.
expect(fs.existsSync(rulesFile())).toBe(false);
});
it('install self-heals a leftover codegraph.mdc (#529)', () => {
plantLegacyRulesFile();
const result = cursor.install('local', { autoAllow: true });
expect(fs.existsSync(rulesFile())).toBe(false);
expect(result.files.some((f) => f.path.endsWith('codegraph.mdc') && f.action === 'removed')).toBe(true);
});
it('uninstall preserves user content added outside the codegraph markers (strips only our block)', () => {
plantLegacyRulesFile('## My own rule\nkeep me\n');
cursor.uninstall('local');
expect(fs.existsSync(rulesFile())).toBe(true);
const after = fs.readFileSync(rulesFile(), 'utf-8');
expect(after).toContain('keep me');
// Our tool-usage block is gone.
expect(after).not.toContain('codegraph_search');
expect(after).not.toContain('CODEGRAPH_START');
});
});
function listAllFiles(dir: string): string[] {
if (!fs.existsSync(dir)) return [];
const out: string[] = [];
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) out.push(...listAllFiles(full));
else out.push(full);
}
return out;
}
// ---------------------------------------------------------------------------
// opencode global config path — XDG on every platform (#535)
//
// opencode resolves its config dir with `xdg-basedir`: XDG_CONFIG_HOME if
// set, else ~/.config — on ALL platforms, Windows included. It never reads
// %APPDATA%; we used to write there on Windows, so opencode never saw the
// entry. The suite-wide setHome() points APPDATA and XDG_CONFIG_HOME at the
// SAME directory (which is exactly how this bug stayed invisible), so these
// tests deliberately split them.
// ---------------------------------------------------------------------------
describe('Installer targets — opencode XDG config path (#535)', () => {
let tmpHome: string;
let tmpCwd: string;
let origCwd: string;
let homeRestore: { restore: () => void };
let appDataDir: string; // distinct from ~/.config, like real Windows
beforeEach(() => {
tmpHome = mkTmpDir('home');
tmpCwd = mkTmpDir('cwd');
origCwd = process.cwd();
process.chdir(tmpCwd);
homeRestore = setHome(tmpHome);
appDataDir = path.join(tmpHome, 'AppData', 'Roaming');
process.env.APPDATA = appDataDir; // realistic split: APPDATA ≠ ~/.config
delete process.env.XDG_CONFIG_HOME; // default resolution: ~/.config
});
afterEach(() => {
homeRestore.restore();
process.chdir(origCwd);
fs.rmSync(tmpHome, { recursive: true, force: true });
fs.rmSync(tmpCwd, { recursive: true, force: true });
});
const xdgConfigFile = () => path.join(tmpHome, '.config', 'opencode', 'opencode.jsonc');
const legacyDir = () => path.join(appDataDir, 'opencode');
// NOTE: never match on an 'AppData' substring — on Windows os.tmpdir()
// itself lives under AppData\Local\Temp, so EVERY harness path contains
// it. Match on the legacy dir prefix instead.
const inLegacyDir = (p: string) => path.resolve(p).startsWith(path.resolve(legacyDir()) + path.sep);
it('global install writes to ~/.config/opencode, never %APPDATA% (#535)', () => {
const opencode = getTarget('opencode')!;
const result = opencode.install('global', { autoAllow: true });
const written = result.files.find((f) => f.path.endsWith('opencode.jsonc'))!;
expect(written.action).toBe('created');
expect(path.resolve(written.path)).toBe(path.resolve(xdgConfigFile()));
expect(fs.existsSync(xdgConfigFile())).toBe(true);
// Nothing of ours may land in the legacy location.
expect(fs.existsSync(legacyDir())).toBe(false);
});
it('greenfield: targets ~/.config/opencode even when the dir does not exist yet (#535)', () => {
// The rejected fallback design (#670) would send this install to
// %APPDATA% — where opencode would never find it. opencode creates
// ~/.config/opencode itself on first run; installing codegraph FIRST
// must land where opencode will look.
expect(fs.existsSync(path.join(tmpHome, '.config', 'opencode'))).toBe(false);
const opencode = getTarget('opencode')!;
const result = opencode.install('global', { autoAllow: true });
expect(path.resolve(result.files[0]!.path)).toBe(path.resolve(xdgConfigFile()));
expect(fs.existsSync(xdgConfigFile())).toBe(true);
expect(fs.existsSync(legacyDir())).toBe(false);
});
it('honors XDG_CONFIG_HOME for the global path, like opencode does', () => {
const custom = path.join(tmpHome, 'xdg-custom');
process.env.XDG_CONFIG_HOME = custom;
const opencode = getTarget('opencode')!;
const result = opencode.install('global', { autoAllow: true });
expect(path.resolve(result.files[0]!.path))
.toBe(path.resolve(path.join(custom, 'opencode', 'opencode.jsonc')));
});
it('install self-heals a pre-#535 %APPDATA% entry, preserving siblings and comments', () => {
// A previous codegraph version wrote into %APPDATA%/opencode. The user
// also has another MCP server and a comment there — those must survive.
fs.mkdirSync(legacyDir(), { recursive: true });
fs.writeFileSync(path.join(legacyDir(), 'opencode.jsonc'), [
'{',
' // my servers',
' "$schema": "https://opencode.ai/config.json",',
' "mcp": {',
' "codegraph": { "type": "local", "command": ["codegraph", "serve", "--mcp"], "enabled": true },',
' "other": { "type": "local", "command": ["other"], "enabled": true }',
' }',
'}',
'',
].join('\n'));
fs.writeFileSync(path.join(legacyDir(), 'AGENTS.md'), LEGACY_BLOCK + '\n');
const opencode = getTarget('opencode')!;
const result = opencode.install('global', { autoAllow: true });
// New entry in the right place…
expect(fs.existsSync(xdgConfigFile())).toBe(true);
// …stale entry swept out of the legacy file, siblings + comment intact.
const legacyText = fs.readFileSync(path.join(legacyDir(), 'opencode.jsonc'), 'utf-8');
expect(legacyText).not.toContain('codegraph');
expect(legacyText).toContain('"other"');
expect(legacyText).toContain('// my servers');
// …and the legacy AGENTS.md — block-only, so emptied — removed outright
// (removeMarkedSection unlinks a file it leaves empty).
expect(fs.existsSync(path.join(legacyDir(), 'AGENTS.md'))).toBe(false);
// Both cleanups are reported.
const removed = result.files.filter((f) => f.action === 'removed').map((f) => f.path);
expect(removed.some((p) => inLegacyDir(p) && p.endsWith('opencode.jsonc'))).toBe(true);
expect(removed.some((p) => inLegacyDir(p) && p.endsWith('AGENTS.md'))).toBe(true);
});
it('uninstall sweeps the legacy %APPDATA% entry too (no prior re-install needed)', () => {
// A user on the broken version goes straight to `codegraph uninstall`:
// the only entry that exists is the stale %APPDATA% one.
fs.mkdirSync(legacyDir(), { recursive: true });
fs.writeFileSync(path.join(legacyDir(), 'opencode.json'),
'{\n "mcp": {\n "codegraph": { "type": "local", "command": ["codegraph", "serve", "--mcp"], "enabled": true }\n }\n}\n');
const opencode = getTarget('opencode')!;
const result = opencode.uninstall('global');
expect(fs.readFileSync(path.join(legacyDir(), 'opencode.json'), 'utf-8')).not.toContain('codegraph');
expect(result.files.some((f) => f.action === 'removed' && inLegacyDir(f.path))).toBe(true);
});
it('install after install sweeps only once — second run reports no legacy changes', () => {
fs.mkdirSync(legacyDir(), { recursive: true });
fs.writeFileSync(path.join(legacyDir(), 'opencode.json'),
'{\n "mcp": {\n "codegraph": { "type": "local", "command": ["codegraph", "serve", "--mcp"], "enabled": true }\n }\n}\n');
const opencode = getTarget('opencode')!;
const first = opencode.install('global', { autoAllow: true });
expect(first.files.some((f) => f.action === 'removed' && inLegacyDir(f.path))).toBe(true);
const second = opencode.install('global', { autoAllow: true });
expect(second.files.some((f) => inLegacyDir(f.path))).toBe(false);
expect(second.files.find((f) => f.path.endsWith('opencode.jsonc'))!.action).toBe('unchanged');
});
it('detects opencode as installed from a legacy-only %APPDATA% dir (so install can heal it)', () => {
fs.mkdirSync(legacyDir(), { recursive: true });
const opencode = getTarget('opencode')!;
expect(opencode.detect('global').installed).toBe(true);
// But configuration state is read from the REAL path only.
expect(opencode.detect('global').alreadyConfigured).toBe(false);
});
});
// ---------------------------------------------------------------------------
// Copilot family — copilot-vscode / copilot-cli / copilot-jetbrains (CG-5)
//
// The registry-driven contract suite above covers the shared surface
// (install/idempotency/sibling/uninstall/printConfig). These pin the
// target-specific behavior: OS-specific global paths, `--path` injection
// (copilot-vscode mirrors Cursor), global-only skip semantics (cli +
// jetbrains, Codex pattern), COPILOT_HOME resolution, JSONC comment
// preservation, empty-`servers`-wrapper cleanup, and printConfig parity
// with what install writes.
// ---------------------------------------------------------------------------
describe('Installer targets — Copilot family', () => {
let tmpHome: string;
let tmpCwd: string;
let origCwd: string;
let homeRestore: { restore: () => void };
beforeEach(() => {
tmpHome = mkTmpDir('cop-home');
tmpCwd = mkTmpDir('cop-cwd');
origCwd = process.cwd();
process.chdir(tmpCwd);
homeRestore = setHome(tmpHome);
});
afterEach(() => {
homeRestore.restore();
process.chdir(origCwd);
fs.rmSync(tmpHome, { recursive: true, force: true });
fs.rmSync(tmpCwd, { recursive: true, force: true });
});
// printConfig embeds the paste-able snippet after a `# Add to <path>`
// header — extract and parse just the JSON body.
function snippetJson(out: string): any {
const start = out.indexOf('{');
expect(start).toBeGreaterThanOrEqual(0);
return JSON.parse(out.slice(start));
}
// ---- copilot-vscode ----
it('copilot-vscode: local install writes ./.vscode/mcp.json with servers.codegraph and an absolute --path pin', () => {
const t = getTarget('copilot-vscode')!;
const result = t.install('local', { autoAllow: true });
const file = path.join(process.cwd(), '.vscode', 'mcp.json');
expect(result.files[0].path).toBe(file);
expect(result.files[0].action).toBe('created');
const cfg = JSON.parse(fs.readFileSync(file, 'utf-8'));
expect(cfg.servers.codegraph.type).toBe('stdio');
expect(cfg.servers.codegraph.command).toBe('codegraph');
// Cursor-mirror: local installs pin the project with an absolute path.
expect(cfg.servers.codegraph.args).toEqual(['serve', '--mcp', '--path', process.cwd()]);
// No mcpServers wrapper — VS Code's mcp.json uses `servers`.
expect(cfg.mcpServers).toBeUndefined();
});
it('copilot-vscode: global install writes a variable-free entry — no --path, no ${workspaceFolder}', () => {
// VS Code refuses to start a user-level server whose entry uses
// ${workspaceFolder} in any window with no folder open, toasting
// "Variable workspaceFolder can not be resolved" (hit live). VS Code
// documents cwd = workspace folder for stdio servers, and the
// codegraph server resolves the project from roots/cwd — so the
// global entry must carry no --path and no variables at all.
const t = getTarget('copilot-vscode')!;
const result = t.install('global', { autoAllow: true });
const cfg = JSON.parse(fs.readFileSync(result.files[0].path, 'utf-8'));
expect(cfg.servers.codegraph.args).toEqual(['serve', '--mcp']);
expect(JSON.stringify(cfg)).not.toContain('${');
});
it.runIf(process.platform === 'darwin')('copilot-vscode: global path is ~/Library/Application Support/Code/User/mcp.json on macOS', () => {
const t = getTarget('copilot-vscode')!;
const expected = path.join(tmpHome, 'Library', 'Application Support', 'Code', 'User', 'mcp.json');
expect(t.describePaths('global')).toEqual([expected]);
const result = t.install('global', { autoAllow: true });
expect(result.files[0].path).toBe(expected);
expect(fs.existsSync(expected)).toBe(true);
});
it.runIf(process.platform === 'linux')('copilot-vscode: global path honors XDG_CONFIG_HOME on Linux', () => {
const t = getTarget('copilot-vscode')!;
// setHome() points XDG_CONFIG_HOME at <home>/.config.
const expected = path.join(tmpHome, '.config', 'Code', 'User', 'mcp.json');
expect(t.describePaths('global')).toEqual([expected]);
const result = t.install('global', { autoAllow: true });
expect(result.files[0].path).toBe(expected);
});
it.runIf(process.platform === 'win32')('copilot-vscode: global path is %APPDATA%\\Code\\User\\mcp.json on Windows', () => {
const t = getTarget('copilot-vscode')!;
// setHome() points APPDATA at <home>/.config.
const expected = path.join(process.env.APPDATA!, 'Code', 'User', 'mcp.json');
expect(t.describePaths('global')).toEqual([expected]);
const result = t.install('global', { autoAllow: true });
expect(result.files[0].path).toBe(expected);
});
it('copilot-vscode: supports both global and local locations', () => {
const t = getTarget('copilot-vscode')!;
expect(t.supportsLocation('global')).toBe(true);
expect(t.supportsLocation('local')).toBe(true);
});
it('copilot-vscode: preserves comments and sibling servers through install + idempotent re-run (JSONC)', () => {
const t = getTarget('copilot-vscode')!;
const dir = path.join(tmpCwd, '.vscode');
fs.mkdirSync(dir, { recursive: true });
const file = path.join(dir, 'mcp.json');
fs.writeFileSync(file, [
'{',
' // my MCP servers',
' "servers": {',
' "other": { "type": "stdio", "command": "other-server" } // keep',
' }',
'}',
'',
].join('\n'));
t.install('local', { autoAllow: true });
const afterInstall = fs.readFileSync(file, 'utf-8');
expect(afterInstall).toContain('// my MCP servers');
expect(afterInstall).toContain('// keep');
expect(afterInstall).toContain('"other-server"');
expect(afterInstall).toContain('"codegraph"');
const second = t.install('local', { autoAllow: true });
expect(second.files[0].action).toBe('unchanged');
expect(fs.readFileSync(file, 'utf-8')).toBe(afterInstall);
});
it('copilot-vscode: uninstall drops an emptied servers wrapper but keeps the file and its siblings (e.g. inputs)', () => {
const t = getTarget('copilot-vscode')!;
const dir = path.join(tmpCwd, '.vscode');
fs.mkdirSync(dir, { recursive: true });
const file = path.join(dir, 'mcp.json');
fs.writeFileSync(file, [
'{',
' // prompt-time inputs',
' "inputs": [{ "id": "api-key", "type": "promptString" }]',
'}',
'',
].join('\n'));
t.install('local', { autoAllow: true });
const result = t.uninstall('local');
expect(result.files[0].action).toBe('removed');
// File survives; our entry and the now-empty `servers` wrapper are gone.
expect(fs.existsSync(file)).toBe(true);
const text = fs.readFileSync(file, 'utf-8');
expect(text).toContain('// prompt-time inputs');
const cfg = parseJsonc(text);
expect(cfg.inputs).toBeDefined();
expect(cfg.servers).toBeUndefined();
expect(text).not.toContain('codegraph');
});
it('copilot-vscode: uninstall keeps a non-empty servers wrapper (sibling server survives)', () => {
const t = getTarget('copilot-vscode')!;
const file = path.join(tmpCwd, '.vscode', 'mcp.json');
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, JSON.stringify({
servers: { other: { type: 'stdio', command: 'other-server' } },
}, null, 2) + '\n');
t.install('local', { autoAllow: true });
t.uninstall('local');
const cfg = JSON.parse(fs.readFileSync(file, 'utf-8'));
expect(cfg.servers.other).toBeDefined();
expect(cfg.servers.codegraph).toBeUndefined();
});
it('copilot-vscode: uninstall when never installed reports not-found for both locations, no throw', () => {
const t = getTarget('copilot-vscode')!;
for (const loc of ['global', 'local'] as const) {
const result = t.uninstall(loc);
expect(result.files).toHaveLength(1);
expect(result.files[0].action).toBe('not-found');
}
});
it('copilot-vscode: detect() local reports installed only when a .vscode dir exists', () => {
const t = getTarget('copilot-vscode')!;
expect(t.detect('local').installed).toBe(false);
fs.mkdirSync(path.join(tmpCwd, '.vscode'), { recursive: true });
expect(t.detect('local').installed).toBe(true);
expect(t.detect('local').alreadyConfigured).toBe(false);
});
it('copilot-vscode: detect() global falls back to ~/.vscode (extensions dir) as the installed heuristic', () => {
const t = getTarget('copilot-vscode')!;
expect(t.detect('global').installed).toBe(false);
fs.mkdirSync(path.join(tmpHome, '.vscode'), { recursive: true });
expect(t.detect('global').installed).toBe(true);
});
it('copilot-vscode: printConfig matches what install writes, at both locations', () => {
const t = getTarget('copilot-vscode')!;
for (const loc of ['global', 'local'] as const) {
const printed = snippetJson(t.printConfig(loc));
const result = t.install(loc, { autoAllow: true });
const onDisk = JSON.parse(fs.readFileSync(result.files[0].path, 'utf-8'));
expect(printed.servers.codegraph).toEqual(onDisk.servers.codegraph);
}
});
it('copilot-vscode: install note tells the user to restart VS Code', () => {
const t = getTarget('copilot-vscode')!;
const result = t.install('local', { autoAllow: true });
expect(result.notes?.join(' ')).toMatch(/[Rr]estart VS Code/);
});
// ---- copilot-cli ----
it('copilot-cli: global install writes ~/.copilot/mcp-config.json with the documented entry shape (tools: ["*"])', () => {
const t = getTarget('copilot-cli')!;
const result = t.install('global', { autoAllow: true });
const file = path.join(tmpHome, '.copilot', 'mcp-config.json');
expect(result.files[0].path).toBe(file);
expect(result.files[0].action).toBe('created');
const cfg = JSON.parse(fs.readFileSync(file, 'utf-8'));
expect(cfg.mcpServers.codegraph).toEqual({
type: 'stdio',
command: 'codegraph',
args: ['serve', '--mcp'],
tools: ['*'],
});
});
it('copilot-cli: is global-only — local install skips with a clear note, uninstall is a no-op', () => {
const t = getTarget('copilot-cli')!;
expect(t.supportsLocation('local')).toBe(false);
expect(t.supportsLocation('global')).toBe(true);
const install = t.install('local', { autoAllow: true });
expect(install.files).toEqual([]);
expect(install.notes?.join(' ')).toMatch(/no project-local config/);
expect(t.uninstall('local').files).toEqual([]);
expect(t.describePaths('local')).toEqual([]);
expect(t.detect('local').installed).toBe(false);
});
it('copilot-cli: honors the COPILOT_HOME override for install, detect, and uninstall', () => {
const t = getTarget('copilot-cli')!;
const custom = path.join(tmpHome, 'copilot-custom');
process.env.COPILOT_HOME = custom;
const result = t.install('global', { autoAllow: true });
const expected = path.join(custom, 'mcp-config.json');
expect(result.files[0].path).toBe(expected);
expect(fs.existsSync(expected)).toBe(true);
expect(t.detect('global').alreadyConfigured).toBe(true);
// The default location was never touched.
expect(fs.existsSync(path.join(tmpHome, '.copilot'))).toBe(false);
t.uninstall('global');
expect(t.detect('global').alreadyConfigured).toBe(false);
});
it('copilot-cli: uninstall removes only codegraph — sibling server and unrelated keys survive', () => {
const t = getTarget('copilot-cli')!;
const file = path.join(tmpHome, '.copilot', 'mcp-config.json');
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, JSON.stringify({
mcpServers: { other: { type: 'stdio', command: 'other-server' } },
banner: 'never',
}, null, 2) + '\n');
t.install('global', { autoAllow: true });
t.uninstall('global');
const after = JSON.parse(fs.readFileSync(file, 'utf-8'));
expect(after.mcpServers.other).toBeDefined();
expect(after.mcpServers.codegraph).toBeUndefined();
expect(after.banner).toBe('never');
});
it('copilot-cli: uninstall of a from-scratch install deletes the file — no `{}` husk to fool detect()', () => {
const t = getTarget('copilot-cli')!;
t.install('global', { autoAllow: true });
t.uninstall('global');
const file = path.join(tmpHome, '.copilot', 'mcp-config.json');
// A leftover empty mcp-config.json would count as a CLI footprint
// and keep the target showing as detected after uninstall.
expect(fs.existsSync(file)).toBe(false);
});
it('copilot-cli: uninstall keeps the file when unrelated top-level keys remain', () => {
const t = getTarget('copilot-cli')!;
const file = path.join(tmpHome, '.copilot', 'mcp-config.json');
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, JSON.stringify({ banner: 'never' }, null, 2) + '\n');
t.install('global', { autoAllow: true });
t.uninstall('global');
const after = JSON.parse(fs.readFileSync(file, 'utf-8'));
expect(after.mcpServers).toBeUndefined();
expect(after.banner).toBe('never');
});
it('copilot-cli: uninstall when never installed reports not-found, no throw', () => {
const t = getTarget('copilot-cli')!;
const result = t.uninstall('global');
expect(result.files).toHaveLength(1);
expect(result.files[0].action).toBe('not-found');
// Same when the file exists but holds no codegraph entry.
const file = path.join(tmpHome, '.copilot', 'mcp-config.json');
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, JSON.stringify({ mcpServers: { other: { command: 'x' } } }) + '\n');
expect(t.uninstall('global').files[0].action).toBe('not-found');
});
it('copilot-cli: detect() reports installed from CLI artifacts in ~/.copilot', () => {
const t = getTarget('copilot-cli')!;
// The tmp PATH may or may not carry a real `copilot` binary; only
// assert the positive signal we control. The CLI writes config.json
// on first run — that's the footprint.
fs.mkdirSync(path.join(tmpHome, '.copilot'), { recursive: true });
fs.writeFileSync(path.join(tmpHome, '.copilot', 'config.json'), '{}');
expect(t.detect('global').installed).toBe(true);
expect(t.detect('global').alreadyConfigured).toBe(false);
});
it('copilot-cli: detect() is NOT fooled by the VS Code extension\'s ~/.copilot/ide/ locks', () => {
// The VS Code Copilot Chat extension writes MCP socket-handoff lock
// files into ~/.copilot/ide/ on every launch — a machine with only
// the extension has ~/.copilot with a lone `ide` entry and no CLI.
const t = getTarget('copilot-cli')!;
const ideDir = path.join(tmpHome, '.copilot', 'ide');
fs.mkdirSync(ideDir, { recursive: true });
fs.writeFileSync(path.join(ideDir, 'some-uuid.lock'), '{"socketPath":"/tmp/mcp.sock"}');
// Pin PATH to an empty dir so a real `copilot` binary on the host
// can't turn this negative assertion into a false failure.
const prevPath = process.env.PATH;
process.env.PATH = ideDir;
try {
expect(t.detect('global').installed).toBe(false);
// An empty ~/.copilot (no CLI footprint at all) is also not enough.
fs.rmSync(ideDir, { recursive: true });
expect(t.detect('global').installed).toBe(false);
} finally {
process.env.PATH = prevPath;
}
});
it('copilot-cli: printConfig matches what install writes; local variant points at --location=global', () => {
const t = getTarget('copilot-cli')!;
const printed = snippetJson(t.printConfig('global'));
const result = t.install('global', { autoAllow: true });
const onDisk = JSON.parse(fs.readFileSync(result.files[0].path, 'utf-8'));
expect(printed.mcpServers.codegraph).toEqual(onDisk.mcpServers.codegraph);
expect(t.printConfig('local')).toMatch(/--location=global/);
});
// ---- copilot-jetbrains ----
it('copilot-jetbrains: global install writes github-copilot/intellij/mcp.json with the VS Code-compatible servers shape', () => {
const t = getTarget('copilot-jetbrains')!;
const result = t.install('global', { autoAllow: true });
// setHome() sets XDG_CONFIG_HOME, honored on every platform.
const file = path.join(tmpHome, '.config', 'github-copilot', 'intellij', 'mcp.json');
expect(result.files[0].path).toBe(file);
expect(result.files[0].action).toBe('created');
const cfg = JSON.parse(fs.readFileSync(file, 'utf-8'));
// Plain entry — no --path injection for this user-global config.
expect(cfg.servers.codegraph).toEqual({ type: 'stdio', command: 'codegraph', args: ['serve', '--mcp'] });
expect(cfg.mcpServers).toBeUndefined();
});
it.runIf(process.platform !== 'win32')('copilot-jetbrains: falls back to ~/.config/github-copilot when XDG_CONFIG_HOME is unset', () => {
delete process.env.XDG_CONFIG_HOME;
const t = getTarget('copilot-jetbrains')!;
const expected = path.join(tmpHome, '.config', 'github-copilot', 'intellij', 'mcp.json');
expect(t.describePaths('global')).toEqual([expected]);
const result = t.install('global', { autoAllow: true });
expect(result.files[0].path).toBe(expected);
});
it.runIf(process.platform === 'win32')('copilot-jetbrains: falls back to %LOCALAPPDATA%\\github-copilot on Windows when XDG_CONFIG_HOME is unset', () => {
const prevLocal = process.env.LOCALAPPDATA;
delete process.env.XDG_CONFIG_HOME;
process.env.LOCALAPPDATA = path.join(tmpHome, 'AppData', 'Local');
try {
const t = getTarget('copilot-jetbrains')!;
const expected = path.join(tmpHome, 'AppData', 'Local', 'github-copilot', 'intellij', 'mcp.json');
expect(t.describePaths('global')).toEqual([expected]);
const result = t.install('global', { autoAllow: true });
expect(result.files[0].path).toBe(expected);
} finally {
if (prevLocal === undefined) delete process.env.LOCALAPPDATA;
else process.env.LOCALAPPDATA = prevLocal;
}
});
it('copilot-jetbrains: is global-only — local install skips with a clear note, uninstall is a no-op', () => {
const t = getTarget('copilot-jetbrains')!;
expect(t.supportsLocation('local')).toBe(false);
expect(t.supportsLocation('global')).toBe(true);
const install = t.install('local', { autoAllow: true });
expect(install.files).toEqual([]);
expect(install.notes?.join(' ')).toMatch(/no project-local MCP config/);
expect(t.uninstall('local').files).toEqual([]);
expect(t.describePaths('local')).toEqual([]);
expect(t.detect('local').installed).toBe(false);
});
it('copilot-jetbrains: preserves comments and sibling servers through install + idempotent re-run (JSONC)', () => {
const t = getTarget('copilot-jetbrains')!;
const file = path.join(tmpHome, '.config', 'github-copilot', 'intellij', 'mcp.json');
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, [
'{',
' // hand-edited via Settings → Tools → GitHub Copilot',
' "servers": {',
' "other": { "type": "stdio", "command": "other-server" }',
' }',
'}',
'',
].join('\n'));
t.install('global', { autoAllow: true });
const afterInstall = fs.readFileSync(file, 'utf-8');
expect(afterInstall).toContain('// hand-edited via Settings');
expect(afterInstall).toContain('"other-server"');
expect(afterInstall).toContain('"codegraph"');
const second = t.install('global', { autoAllow: true });
expect(second.files[0].action).toBe('unchanged');
expect(fs.readFileSync(file, 'utf-8')).toBe(afterInstall);
});
it('copilot-jetbrains: uninstall removes only codegraph and drops an emptied servers wrapper, keeping the file', () => {
const t = getTarget('copilot-jetbrains')!;
t.install('global', { autoAllow: true });
const file = path.join(tmpHome, '.config', 'github-copilot', 'intellij', 'mcp.json');
const result = t.uninstall('global');
expect(result.files[0].action).toBe('removed');
expect(fs.existsSync(file)).toBe(true);
const cfg = parseJsonc(fs.readFileSync(file, 'utf-8'));
expect(cfg.servers).toBeUndefined();
});
it('copilot-jetbrains: uninstall keeps a sibling server (wrapper not dropped when non-empty)', () => {
const t = getTarget('copilot-jetbrains')!;
const file = path.join(tmpHome, '.config', 'github-copilot', 'intellij', 'mcp.json');
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, JSON.stringify({
servers: { other: { type: 'stdio', command: 'other-server' } },
}, null, 2) + '\n');
t.install('global', { autoAllow: true });
t.uninstall('global');
const cfg = JSON.parse(fs.readFileSync(file, 'utf-8'));
expect(cfg.servers.other).toBeDefined();
expect(cfg.servers.codegraph).toBeUndefined();
});
it('copilot-jetbrains: uninstall when never installed reports not-found, no throw', () => {
const t = getTarget('copilot-jetbrains')!;
const result = t.uninstall('global');
expect(result.files).toHaveLength(1);
expect(result.files[0].action).toBe('not-found');
});
it('copilot-jetbrains: detect() reports installed from the intellij config dir', () => {
const t = getTarget('copilot-jetbrains')!;
expect(t.detect('global').installed).toBe(false);
fs.mkdirSync(path.join(tmpHome, '.config', 'github-copilot', 'intellij'), { recursive: true });
expect(t.detect('global').installed).toBe(true);
expect(t.detect('global').alreadyConfigured).toBe(false);
});
it('copilot-jetbrains: printConfig matches what install writes and names the IDE settings path', () => {
const t = getTarget('copilot-jetbrains')!;
const out = t.printConfig('global');
expect(out).toContain('Settings → Tools → GitHub Copilot');
const printed = snippetJson(out);
const result = t.install('global', { autoAllow: true });
const onDisk = JSON.parse(fs.readFileSync(result.files[0].path, 'utf-8'));
expect(printed.servers.codegraph).toEqual(onDisk.servers.codegraph);
expect(t.printConfig('local')).toMatch(/--location=global/);
});
it('copilot-jetbrains: install note tells the user to restart the IDE', () => {
const t = getTarget('copilot-jetbrains')!;
const result = t.install('global', { autoAllow: true });
expect(result.notes?.join(' ')).toMatch(/[Rr]estart your JetBrains IDE/);
});
it('copilot family: all three coexist — uninstalling one leaves the others configured', () => {
const vscode = getTarget('copilot-vscode')!;
const cli = getTarget('copilot-cli')!;
const jetbrains = getTarget('copilot-jetbrains')!;
vscode.install('global', { autoAllow: true });
cli.install('global', { autoAllow: true });
jetbrains.install('global', { autoAllow: true });
cli.uninstall('global');
expect(cli.detect('global').alreadyConfigured).toBe(false);
expect(vscode.detect('global').alreadyConfigured).toBe(true);
expect(jetbrains.detect('global').alreadyConfigured).toBe(true);
});
});