` | dump snippet for one agent and exit | — |
### 2. Restart Your Agent
-Restart your agent (Claude Code / Cursor / Codex CLI / opencode / Hermes Agent / Gemini CLI / Antigravity IDE / Kiro / VS Code, the Copilot CLI, or your JetBrains IDE for GitHub Copilot) for the MCP server to load.
+Restart your agent (Claude Code / Cursor / Codex CLI / opencode / Hermes Agent / Gemini CLI / Antigravity IDE / Kiro / VS Code, the Copilot CLI, or your JetBrains IDE for GitHub Copilot / IBM Bob / Factory Droid / Windsurf / Qwen Code / DeepSeek Harness / Grok Build / Kimi Code / Qoder CLI / Pi) for the MCP server to load.
### 3. Initialize Projects
@@ -799,10 +808,11 @@ See [Get Started](#get-started) for the one-line install commands.
The interactive installer auto-detects and configures each of these — wiring up
the MCP server, which delivers its own usage guidance. For Claude Code, Codex
-CLI, Gemini CLI and opencode it also writes a short marker-fenced CodeGraph
-section into the agent's instructions file (`CLAUDE.md` / `AGENTS.md` /
-`GEMINI.md`), so subagents and non-MCP agents learn the `codegraph explore`
-command:
+CLI, Gemini CLI, opencode, Factory Droid, Qwen Code, DeepSeek Harness, Grok
+Build, Kimi Code and Pi it also writes a short marker-fenced CodeGraph section
+into the agent's instructions file (`CLAUDE.md` / `AGENTS.md` / `GEMINI.md` /
+`QWEN.md`, or `rules/codegraph.md` for Grok Build), so subagents and non-MCP
+agents learn the `codegraph explore` command:
- **Claude Code**
- **Cursor**
@@ -813,6 +823,15 @@ command:
- **Antigravity IDE**
- **Kiro**
- **GitHub Copilot** — Copilot Chat in VS Code (`copilot-vscode`), the Copilot CLI (`copilot-cli`), and the Copilot plugin in JetBrains IDEs (`copilot-jetbrains`)
+- **IBM Bob** (`bob`) — Bob IDE and Bob Shell: `~/.bob/settings/mcp.json`, or `.bob/mcp.json` in a project
+- **Factory Droid** (`droid`) — `~/.factory/mcp.json`, or `.factory/mcp.json`, which is meant to be committed; Droid reloads the file on its own
+- **Windsurf** (`windsurf`) — global only: `~/.codeium/windsurf/mcp_config.json`, which Devin, Windsurf's new name, imports by default
+- **Qwen Code** (`qwen`) — `settings.json` in `$QWEN_HOME` (default `~/.qwen`) or `.qwen/`, keeping any comments in it
+- **DeepSeek Harness** (`dsh`) — global only: one entry in `$DSH_HOME/cordis.patch.yml` (default `~/.dsh`) covers every profile
+- **Grok Build** (`grok`) — `$GROK_HOME/config.toml` (default `~/.grok`), or `.grok/config.toml`, which Grok uses only in a trusted folder
+- **Kimi Code** (`kimi`) — `$KIMI_CODE_HOME/mcp.json` (default `~/.kimi-code`), or `.kimi-code/mcp.json`, which Kimi Code uses only in a trusted folder
+- **Qoder CLI** (`qoder`) — `$QODER_CONFIG_DIR/settings.json` (default `~/.qoder`), or `.qoder/settings.local.json`; in the Qoder IDE, add the server under Settings > MCP
+- **Pi** (`pi`) — `mcp.json` in Pi's agent directory (default `~/.pi/agent`), or `.pi/mcp.json`, which Pi reads once you trust the project; `exposure: "direct"` keeps `codegraph_explore` on the model's tool list
## Supported Languages
@@ -919,7 +938,7 @@ MIT
-**Made for AI coding agents — Claude Code, Cursor, Codex CLI, opencode, Hermes Agent, Gemini CLI, Antigravity IDE, Kiro, and GitHub Copilot**
+**Made for AI coding agents — Claude Code, Cursor, Codex CLI, opencode, Hermes Agent, Gemini CLI, Antigravity IDE, Kiro, GitHub Copilot, IBM Bob, Factory Droid, Windsurf, Qwen Code, DeepSeek Harness, Grok Build, Kimi Code, Qoder CLI, and Pi**
[Report Bug](https://github.com/colbymchenry/codegraph/issues) · [Request Feature](https://github.com/colbymchenry/codegraph/issues)
diff --git a/__tests__/installer-targets.test.ts b/__tests__/installer-targets.test.ts
index 778c14b6a..7a932fcd7 100644
--- a/__tests__/installer-targets.test.ts
+++ b/__tests__/installer-targets.test.ts
@@ -24,7 +24,7 @@ import { ALL_TARGETS, getTarget, resolveTargetFlag } from '../src/installer/targ
import { uninstallTargets, refreshTargets } from '../src/installer';
import { upsertTomlTable, removeTomlTable, buildTomlTable, tomlDefinesTable } from '../src/installer/targets/toml';
import { cleanupLegacyHooks, writePromptHookEntry, removePromptHookEntry } from '../src/installer/targets/claude';
-import { replaceOrAppendMarkedSection, removeMarkedSection } from '../src/installer/targets/shared';
+import { configDirFromEnv, replaceOrAppendMarkedSection, removeMarkedSection } from '../src/installer/targets/shared';
import {
CODEGRAPH_INSTRUCTIONS_BLOCK,
CODEGRAPH_SECTION_END,
@@ -75,7 +75,11 @@ describe('standalone installer Windows shell guidance (#1294)', () => {
// `$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.
+const AGENT_HOME_OVERRIDES = ['QWEN_HOME', 'DSH_HOME', 'GROK_HOME', 'KIMI_CODE_HOME', 'QODER_CONFIG_DIR', 'PI_CODING_AGENT_DIR'];
+
function setHome(dir: string): { restore: () => void } {
+ const overrides = AGENT_HOME_OVERRIDES.map((name) => [name, process.env[name]] as const);
+ for (const [name] of overrides) delete process.env[name];
const prev = {
HOME: process.env.HOME,
USERPROFILE: process.env.USERPROFILE,
@@ -104,6 +108,9 @@ function setHome(dir: string): { restore: () => void } {
if (prev.COPILOT_HOME === undefined) delete process.env.COPILOT_HOME; else process.env.COPILOT_HOME = prev.COPILOT_HOME;
if (prev.CODEX_HOME === undefined) delete process.env.CODEX_HOME; else process.env.CODEX_HOME = prev.CODEX_HOME;
if (prev.CLAUDE_CONFIG_DIR === undefined) delete process.env.CLAUDE_CONFIG_DIR; else process.env.CLAUDE_CONFIG_DIR = prev.CLAUDE_CONFIG_DIR;
+ for (const [name, value] of overrides) {
+ if (value === undefined) delete process.env[name]; else process.env[name] = value;
+ }
},
};
}
@@ -1380,6 +1387,9 @@ describe('Installer targets — registry', () => {
expect(getTarget('copilot-vscode')?.id).toBe('copilot-vscode');
expect(getTarget('copilot-cli')?.id).toBe('copilot-cli');
expect(getTarget('copilot-jetbrains')?.id).toBe('copilot-jetbrains');
+ for (const id of ['bob', 'droid', 'windsurf', 'qwen', 'dsh', 'grok', 'kimi', 'qoder', 'pi']) {
+ expect(getTarget(id)?.id).toBe(id);
+ }
expect(getTarget('not-a-real-target')).toBeUndefined();
});
@@ -2341,6 +2351,12 @@ describe('Installer — install --refresh keeps each agent\'s instructions file
['codex', (home) => path.join(home, '.codex', 'AGENTS.md')],
['gemini', (home) => path.join(home, '.gemini', 'GEMINI.md')],
['opencode', (home) => path.join(home, '.config', 'opencode', 'AGENTS.md')],
+ ['droid', (home) => path.join(home, '.factory', 'AGENTS.md')],
+ ['qwen', (home) => path.join(home, '.qwen', 'QWEN.md')],
+ ['dsh', (home) => path.join(home, '.dsh', 'AGENTS.md')],
+ ['grok', (home) => path.join(home, '.grok', 'rules', 'codegraph.md')],
+ ['kimi', (home) => path.join(home, '.kimi-code', 'AGENTS.md')],
+ ['pi', (home) => path.join(home, '.pi', 'agent', 'AGENTS.md')],
];
const USER = `${CODEGRAPH_SECTION_START}\n## CodeGraph\n\n# My notes\n\nAlways run the tests first.\n\n`;
@@ -2572,22 +2588,30 @@ describe.skipIf(!FILE_SYMLINKS)(`Installer — a symbolic link is written throug
const agentsMd = sharedFile('AGENTS.md');
const codexToml = sharedFile('codex.toml');
const copilot = sharedFile('copilot.json');
+ const droid = sharedFile('droid.json');
+ const grokToml = sharedFile('grok.toml');
+ const dshPatch = sharedFile('cordis.patch.yml');
link(path.join(tmpHome, '.codex', 'AGENTS.md'), agentsMd);
link(path.join(tmpHome, '.codex', 'config.toml'), codexToml);
link(path.join(tmpHome, '.copilot', 'mcp-config.json'), copilot);
- getTarget('codex')!.install('global', { autoAllow: false });
- getTarget('copilot-cli')!.install('global', { autoAllow: false });
+ link(path.join(tmpHome, '.factory', 'mcp.json'), droid);
+ link(path.join(tmpHome, '.grok', 'config.toml'), grokToml);
+ link(path.join(tmpHome, '.dsh', 'cordis.patch.yml'), dshPatch);
+ const ids = ['codex', 'copilot-cli', 'droid', 'grok', 'dsh'] as const;
+ for (const id of ids) getTarget(id)!.install('global', { autoAllow: false });
expect(fs.readFileSync(agentsMd, 'utf-8')).toBe(`${CODEGRAPH_INSTRUCTIONS_BLOCK}\n`);
- getTarget('codex')!.uninstall('global');
- getTarget('copilot-cli')!.uninstall('global');
+ for (const id of ids) getTarget(id)!.uninstall('global');
- for (const p of ['.codex/AGENTS.md', '.codex/config.toml', '.copilot/mcp-config.json']) {
+ for (const p of ['.codex/AGENTS.md', '.codex/config.toml', '.copilot/mcp-config.json', '.factory/mcp.json', '.grok/config.toml', '.dsh/cordis.patch.yml']) {
expect(isLink(path.join(tmpHome, p)), p).toBe(true);
}
expect(fs.readFileSync(agentsMd, 'utf-8')).toBe('');
expect(fs.readFileSync(codexToml, 'utf-8')).toBe('');
+ expect(fs.readFileSync(grokToml, 'utf-8')).toBe('');
expect(JSON.parse(fs.readFileSync(copilot, 'utf-8'))).toEqual({});
+ expect(JSON.parse(fs.readFileSync(droid, 'utf-8'))).toEqual({});
+ expect(fs.readFileSync(dshPatch, 'utf-8')).toBe('[]\n');
});
it('a link that can\'t be written fails only its own agent in a refresh: the others are still refreshed', () => {
@@ -3860,6 +3884,815 @@ describe('Installer targets — Codex CODEX_HOME override (#1627)', () => {
});
});
+// ---------------------------------------------------------------------------
+// The agents that keep `mcpServers.codegraph` in a JSON file: each one's
+// documented paths and entry, and what the shared editor promises all of them.
+// ---------------------------------------------------------------------------
+
+interface JsonTargetCase {
+ id: string;
+ /** Path of the MCP config under the home (global) or the project (local), and the entry written there. */
+ global: string[];
+ local: string[] | null;
+ entry: Record;
+ /** Instructions file under the home; null when the target writes none. */
+ globalInstructions: string[] | null;
+ /** Instructions file under the project. */
+ localInstructions: string[] | null;
+}
+
+const STDIO_ENTRY = { command: 'codegraph', args: ['serve', '--mcp'] };
+
+const JSON_TARGETS: JsonTargetCase[] = [
+ { id: 'bob', global: ['.bob', 'settings', 'mcp.json'], local: ['.bob', 'mcp.json'], entry: STDIO_ENTRY, globalInstructions: null, localInstructions: null },
+ { id: 'droid', global: ['.factory', 'mcp.json'], local: ['.factory', 'mcp.json'], entry: { type: 'stdio', ...STDIO_ENTRY }, globalInstructions: ['.factory', 'AGENTS.md'], localInstructions: ['AGENTS.md'] },
+ { id: 'windsurf', global: ['.codeium', 'windsurf', 'mcp_config.json'], local: null, entry: STDIO_ENTRY, globalInstructions: null, localInstructions: null },
+ { id: 'qwen', global: ['.qwen', 'settings.json'], local: ['.qwen', 'settings.json'], entry: STDIO_ENTRY, globalInstructions: ['.qwen', 'QWEN.md'], localInstructions: ['QWEN.md'] },
+ { id: 'kimi', global: ['.kimi-code', 'mcp.json'], local: ['.kimi-code', 'mcp.json'], entry: STDIO_ENTRY, globalInstructions: ['.kimi-code', 'AGENTS.md'], localInstructions: ['AGENTS.md'] },
+ { id: 'qoder', global: ['.qoder', 'settings.json'], local: ['.qoder', 'settings.local.json'], entry: STDIO_ENTRY, globalInstructions: null, localInstructions: null },
+ { id: 'pi', global: ['.pi', 'agent', 'mcp.json'], local: ['.pi', 'mcp.json'], entry: { ...STDIO_ENTRY, exposure: 'direct' }, globalInstructions: ['.pi', 'agent', 'AGENTS.md'], localInstructions: ['AGENTS.md'] },
+];
+
+/** Runs each test with a throwaway home and working directory. */
+function useTmpDirs(label: string): { home: () => string; cwd: () => string } {
+ let tmpHome: string;
+ let tmpCwd: string;
+ let origCwd: string;
+ let homeRestore: { restore: () => void };
+
+ beforeEach(() => {
+ tmpHome = mkTmpDir(`${label}-home`);
+ tmpCwd = mkTmpDir(`${label}-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 });
+ });
+
+ return { home: () => tmpHome, cwd: () => tmpCwd };
+}
+
+describe('Installer targets — agents with a JSON mcpServers file', () => {
+ const dirs = useTmpDirs('jsoncfg');
+
+ for (const c of JSON_TARGETS) {
+ const locations = (['global', 'local'] as const).filter((l) => c[l] !== null);
+
+ for (const location of locations) {
+ const file = () => path.join(location === 'global' ? dirs.home() : dirs.cwd(), ...c[location]!);
+ const instructions = location === 'global' ? c.globalInstructions : c.localInstructions;
+ const instructionsFile = () => path.join(location === 'global' ? dirs.home() : dirs.cwd(), ...instructions!);
+
+ describe(`${c.id} (${location})`, () => {
+ it('writes the documented entry to the documented file, and nothing else for the MCP config', () => {
+ const result = getTarget(c.id)!.install(location, { autoAllow: false });
+
+ expect(JSON.parse(fs.readFileSync(file(), 'utf-8'))).toEqual({ mcpServers: { codegraph: c.entry } });
+ expect(result.files.find((f) => f.path === file())?.action).toBe('created');
+ });
+
+ it(instructions ? 'writes the instructions block where the agent reads it' : 'writes no instructions file', () => {
+ const result = getTarget(c.id)!.install(location, { autoAllow: false });
+
+ if (instructions) {
+ expect(fs.readFileSync(instructionsFile(), 'utf-8')).toBe(`${CODEGRAPH_INSTRUCTIONS_BLOCK}\n`);
+ expect(result.files.map((f) => f.path)).toEqual([file(), instructionsFile()]);
+ } else {
+ expect(result.files.map((f) => f.path)).toEqual([file()]);
+ }
+ });
+
+ it('an entry the user changed stays as it is through a re-install and a refresh', () => {
+ const pinned = { ...c.entry, command: '/opt/bin/codegraph', args: ['serve', '--mcp', '--path', '/work'], cwd: '/work', disabled: true, alwaysAllow: ['codegraph_explore'], exposure: 'deferred' };
+ const content = `${JSON.stringify({ mcpServers: { codegraph: pinned } }, null, 2)}\n`;
+ fs.mkdirSync(path.dirname(file()), { recursive: true });
+ fs.writeFileSync(file(), content);
+
+ const target = getTarget(c.id)!;
+ expect(target.detect(location).alreadyConfigured).toBe(true);
+ const again = target.install(location, { autoAllow: false });
+
+ expect(again.files.find((f) => f.path === file())?.action).toBe('unchanged');
+ expect(fs.readFileSync(file(), 'utf-8')).toBe(content);
+ if (location === 'global') {
+ expect(refreshTargets([target], 'global')[0]!.status).not.toBe('failed');
+ expect(fs.readFileSync(file(), 'utf-8')).toBe(content);
+ }
+ });
+
+ it('an entry missing a key gets only that key', () => {
+ fs.mkdirSync(path.dirname(file()), { recursive: true });
+ fs.writeFileSync(file(), JSON.stringify({ mcpServers: { codegraph: { disabled: true } } }));
+
+ getTarget(c.id)!.install(location, { autoAllow: false });
+
+ expect(JSON.parse(fs.readFileSync(file(), 'utf-8')).mcpServers.codegraph).toEqual({ ...c.entry, disabled: true });
+ });
+
+ it('the rest of the file is not reformatted, and uninstall gives its values back', () => {
+ const content = '{\n "theme": "dark",\n "mcpServers": {\n "other": { "command": "x" }\n },\n "tail": [1, 2]\n}\n';
+ fs.mkdirSync(path.dirname(file()), { recursive: true });
+ fs.writeFileSync(file(), content);
+ const target = getTarget(c.id)!;
+
+ target.install(location, { autoAllow: false });
+
+ const installed = fs.readFileSync(file(), 'utf-8');
+ expect(installed).toContain('{\n "theme": "dark",\n "mcpServers": {\n');
+ expect(installed).toContain(' "tail": [1, 2]\n}\n');
+ expect(installed).toContain('\n "codegraph": {\n "');
+ expect(JSON.parse(installed).mcpServers.other).toEqual({ command: 'x' });
+ expect(JSON.parse(installed).mcpServers.codegraph).toEqual(c.entry);
+
+ target.uninstall(location);
+ expect(JSON.parse(fs.readFileSync(file(), 'utf-8'))).toEqual(JSON.parse(content));
+ });
+
+ it('uninstall deletes a file left with nothing in it, and is then a no-op', () => {
+ const target = getTarget(c.id)!;
+ target.install(location, { autoAllow: false });
+
+ const result = target.uninstall(location);
+
+ expect(result.files.find((f) => f.path === file())?.action).toBe('removed');
+ expect(fs.existsSync(file())).toBe(false);
+ if (instructions) expect(fs.existsSync(instructionsFile())).toBe(false);
+ expect(target.uninstall(location).files.find((f) => f.path === file())?.action).toBe('not-found');
+ });
+
+ it('a config that does not parse is backed up before it is replaced', () => {
+ fs.mkdirSync(path.dirname(file()), { recursive: true });
+ fs.writeFileSync(file(), '{ half a config');
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
+ try {
+ expect(getTarget(c.id)!.detect(location).alreadyConfigured).toBe(false);
+ expect(warn).not.toHaveBeenCalled();
+ getTarget(c.id)!.install(location, { autoAllow: false });
+ expect(warn).toHaveBeenCalled();
+ } finally {
+ warn.mockRestore();
+ }
+ expect(fs.readFileSync(`${file()}.backup`, 'utf-8')).toBe('{ half a config');
+ expect(JSON.parse(fs.readFileSync(file(), 'utf-8')).mcpServers.codegraph).toEqual(c.entry);
+ });
+
+ it('uninstall leaves a config that does not parse alone', () => {
+ fs.mkdirSync(path.dirname(file()), { recursive: true });
+ fs.writeFileSync(file(), '{ half a config');
+
+ const result = getTarget(c.id)!.uninstall(location);
+
+ expect(result.files.find((f) => f.path === file())?.action).toBe('not-found');
+ expect(fs.readFileSync(file(), 'utf-8')).toBe('{ half a config');
+ });
+
+ it('an mcpServers that is not an object stops the install with nothing changed', () => {
+ const content = '{ "mcpServers": ["not", "an", "object"] }\n';
+ fs.mkdirSync(path.dirname(file()), { recursive: true });
+ fs.writeFileSync(file(), content);
+
+ expect(() => getTarget(c.id)!.install(location, { autoAllow: false })).toThrow(/mcpServers.*Nothing was changed/);
+ expect(fs.readFileSync(file(), 'utf-8')).toBe(content);
+ });
+
+ it('keeps CRLF line endings and a byte order mark', () => {
+ const content = '\uFEFF{\r\n "theme": "dark"\r\n}\r\n';
+ fs.mkdirSync(path.dirname(file()), { recursive: true });
+ fs.writeFileSync(file(), content);
+
+ getTarget(c.id)!.install(location, { autoAllow: false });
+
+ const text = fs.readFileSync(file(), 'utf-8');
+ expect(text.startsWith('\uFEFF')).toBe(true);
+ expect(text.replace(/\r\n/g, '')).not.toContain('\n');
+ expect(JSON.parse(text.slice(1)).mcpServers.codegraph).toEqual(c.entry);
+ expect(fs.existsSync(`${file()}.backup`)).toBe(false);
+ });
+ });
+ }
+ }
+
+ it('bob: a global file under the earlier name is the one used, while mcp.json is absent', () => {
+ const settings = path.join(dirs.home(), '.bob', 'settings');
+ const earlier = path.join(settings, 'mcp_settings.json');
+ fs.mkdirSync(settings, { recursive: true });
+ fs.writeFileSync(earlier, '{\n "mcpServers": {\n "other": { "command": "x" }\n }\n}\n');
+ const bob = getTarget('bob')!;
+
+ bob.install('global', { autoAllow: false });
+
+ expect(JSON.parse(fs.readFileSync(earlier, 'utf-8')).mcpServers.codegraph).toEqual(STDIO_ENTRY);
+ expect(fs.existsSync(path.join(settings, 'mcp.json'))).toBe(false);
+ expect(bob.detect('global').alreadyConfigured).toBe(true);
+
+ bob.uninstall('global');
+ expect(JSON.parse(fs.readFileSync(earlier, 'utf-8'))).toEqual({ mcpServers: { other: { command: 'x' } } });
+ });
+
+ it('bob: mcp.json wins as soon as it exists', () => {
+ const settings = path.join(dirs.home(), '.bob', 'settings');
+ fs.mkdirSync(settings, { recursive: true });
+ fs.writeFileSync(path.join(settings, 'mcp_settings.json'), '{}\n');
+ fs.writeFileSync(path.join(settings, 'mcp.json'), '{}\n');
+
+ getTarget('bob')!.install('global', { autoAllow: false });
+
+ expect(JSON.parse(fs.readFileSync(path.join(settings, 'mcp.json'), 'utf-8')).mcpServers.codegraph).toEqual(STDIO_ENTRY);
+ expect(fs.readFileSync(path.join(settings, 'mcp_settings.json'), 'utf-8')).toBe('{}\n');
+ });
+
+ it('droid: a local install says the file is meant to be committed; a global one has nothing to say', () => {
+ const droid = getTarget('droid')!;
+ expect(droid.install('global', { autoAllow: false }).notes).toBeUndefined();
+ expect(droid.install('local', { autoAllow: false }).notes?.join(' ')).toMatch(/committed/);
+ });
+
+ it('windsurf: global only, with a clear note', () => {
+ const windsurf = getTarget('windsurf')!;
+ expect(windsurf.supportsLocation('local')).toBe(false);
+ expect(windsurf.detect('local')).toEqual({ installed: false, alreadyConfigured: false });
+ expect(windsurf.describePaths('local')).toEqual([]);
+ const result = windsurf.install('local', { autoAllow: false });
+ expect(result.files).toEqual([]);
+ expect(result.notes?.join(' ')).toMatch(/no project-local/);
+ expect(windsurf.uninstall('local').files).toEqual([]);
+ expect(windsurf.printConfig('local')).toMatch(/no project-local/);
+ });
+
+ it('kimi, pi, qoder: a local install says the folder has to be trusted first', () => {
+ for (const id of ['kimi', 'pi', 'qoder']) {
+ expect(getTarget(id)!.install('local', { autoAllow: false }).notes?.join(' '), id).toMatch(/trust/i);
+ expect(getTarget(id)!.install('global', { autoAllow: false }).notes?.join(' '), id).not.toMatch(/trust/i);
+ }
+ });
+
+ it('qoder: a local install stays out of the .mcp.json Claude Code shares', () => {
+ const shared = path.join(dirs.cwd(), '.mcp.json');
+ getTarget('claude')!.install('local', { autoAllow: false });
+ const before = fs.readFileSync(shared, 'utf-8');
+ const qoder = getTarget('qoder')!;
+
+ qoder.install('local', { autoAllow: false });
+ qoder.uninstall('local');
+
+ expect(fs.readFileSync(shared, 'utf-8')).toBe(before);
+ });
+
+ it.each([
+ ['qwen', 'QWEN_HOME', ['settings.json', 'QWEN.md']],
+ ['kimi', 'KIMI_CODE_HOME', ['mcp.json', 'AGENTS.md']],
+ ['qoder', 'QODER_CONFIG_DIR', ['settings.json']],
+ ['pi', 'PI_CODING_AGENT_DIR', ['mcp.json', 'AGENTS.md']],
+ ['dsh', 'DSH_HOME', ['cordis.patch.yml', 'AGENTS.md']],
+ ['grok', 'GROK_HOME', ['config.toml', path.join('rules', 'codegraph.md')]],
+ ] as const)('%s: a global install follows %s', (id, name, files) => {
+ process.env[name] = path.join(dirs.home(), 'moved');
+ try {
+ const target = getTarget(id)!;
+ const result = target.install('global', { autoAllow: false });
+
+ const expected = files.map((f) => path.join(dirs.home(), 'moved', f));
+ expect(result.files.map((f) => f.path)).toEqual(expected);
+ for (const file of expected) expect(fs.existsSync(file), file).toBe(true);
+ expect(fs.readdirSync(dirs.home())).toEqual(['moved']);
+ expect(target.detect('global').alreadyConfigured).toBe(true);
+ } finally {
+ delete process.env[name];
+ }
+ });
+
+ it.each([
+ ['a blank value is unset', ' ', (home: string) => path.join(home, 'fallback')],
+ ['~ is the home', '~', (home: string) => home],
+ ['~/ starts in the home', '~/moved/here', (home: string) => path.join(home, 'moved', 'here')],
+ ])('configDirFromEnv: %s', (_name, value, expected) => {
+ process.env.CODEGRAPH_TEST_HOME_DIR = value;
+ try {
+ expect(configDirFromEnv('CODEGRAPH_TEST_HOME_DIR', path.join(dirs.home(), 'fallback'))).toBe(expected(dirs.home()));
+ } finally {
+ delete process.env.CODEGRAPH_TEST_HOME_DIR;
+ }
+ });
+
+ it('configDirFromEnv: a relative path starts in the working directory', () => {
+ process.env.CODEGRAPH_TEST_HOME_DIR = path.join('some', 'dir');
+ try {
+ expect(configDirFromEnv('CODEGRAPH_TEST_HOME_DIR', 'unused')).toBe(path.join(process.cwd(), 'some', 'dir'));
+ } finally {
+ delete process.env.CODEGRAPH_TEST_HOME_DIR;
+ }
+ });
+});
+
+describe('Installer targets — settings files that hold comments', () => {
+ const dirs = useTmpDirs('jsonc');
+
+ const SETTINGS = [
+ '{',
+ ' // my setup',
+ ' "general": { "language": "zh" }, // keep Chinese replies',
+ ' /* providers below */',
+ ' "model": { "name": "qwen3-max" },',
+ '}',
+ '',
+ ].join('\n');
+
+ for (const [id, relative] of [['qwen', ['.qwen', 'settings.json']], ['qoder', ['.qoder', 'settings.json']]] as const) {
+ it(`${id}: comments survive install, re-install and uninstall, and detect sees through them`, () => {
+ const file = path.join(dirs.home(), ...relative);
+ fs.mkdirSync(path.dirname(file), { recursive: true });
+ fs.writeFileSync(file, SETTINGS);
+ const target = getTarget(id)!;
+ expect(target.detect('global').alreadyConfigured).toBe(false);
+
+ target.install('global', { autoAllow: false });
+
+ const installed = fs.readFileSync(file, 'utf-8');
+ for (const kept of ['// my setup', '// keep Chinese replies', '/* providers below */', '"qwen3-max"']) {
+ expect(installed).toContain(kept);
+ }
+ expect(parseJsonc(installed).mcpServers.codegraph).toEqual(STDIO_ENTRY);
+ expect(target.detect('global').alreadyConfigured).toBe(true);
+ expect(fs.existsSync(`${file}.backup`)).toBe(false);
+
+ for (const f of target.install('global', { autoAllow: false }).files) expect(f.action, f.path).toBe('unchanged');
+ expect(fs.readFileSync(file, 'utf-8')).toBe(installed);
+
+ target.uninstall('global');
+ const after = fs.readFileSync(file, 'utf-8');
+ expect(after).toContain('// my setup');
+ expect(parseJsonc(after)).toEqual(parseJsonc(SETTINGS));
+ });
+ }
+});
+
+describe('Installer targets — Qwen Code auto-allow', () => {
+ const dirs = useTmpDirs('qwen');
+ const settings = () => path.join(dirs.home(), '.qwen', 'settings.json');
+ const read = () => parseJsonc(fs.readFileSync(settings(), 'utf-8'));
+ const seed = (content: string) => {
+ fs.mkdirSync(path.dirname(settings()), { recursive: true });
+ fs.writeFileSync(settings(), content);
+ };
+
+ it('autoAllow adds the rule for the whole server, in the one write of the settings file', () => {
+ const result = getTarget('qwen')!.install('global', { autoAllow: true });
+
+ expect(read().permissions).toEqual({ allow: ['mcp__codegraph'] });
+ expect(result.files.filter((f) => f.path === settings())).toEqual([{ path: settings(), action: 'created' }]);
+ });
+
+ it('without autoAllow no permissions are written', () => {
+ getTarget('qwen')!.install('global', { autoAllow: false });
+ expect(read().permissions).toBeUndefined();
+ });
+
+ it('the rule joins the rules already there, with comments kept, and only once', () => {
+ seed('{\n "permissions": {\n // team rules\n "allow": ["Bash(git *)"],\n "deny": ["Read(.env)"]\n }\n}\n');
+ const qwen = getTarget('qwen')!;
+
+ qwen.install('global', { autoAllow: true });
+ const installed = fs.readFileSync(settings(), 'utf-8');
+ expect(read().permissions).toEqual({ allow: ['Bash(git *)', 'mcp__codegraph'], deny: ['Read(.env)'] });
+ expect(installed).toContain('// team rules');
+
+ for (const f of qwen.install('global', { autoAllow: true }).files) expect(f.action, f.path).toBe('unchanged');
+ expect(fs.readFileSync(settings(), 'utf-8')).toBe(installed);
+ });
+
+ it('a permissions.allow that is not a list is left alone', () => {
+ seed('{ "permissions": { "allow": "Bash" } }\n');
+ getTarget('qwen')!.install('global', { autoAllow: true });
+ expect(read().permissions).toEqual({ allow: 'Bash' });
+ });
+
+ it('a refresh never adds the rule back', () => {
+ const qwen = getTarget('qwen')!;
+ qwen.install('global', { autoAllow: true });
+ const trimmed = read();
+ trimmed.permissions.allow = [];
+ fs.writeFileSync(settings(), `${JSON.stringify(trimmed, null, 2)}\n`);
+
+ refreshTargets([qwen], 'global');
+
+ expect(read().permissions.allow).toEqual([]);
+ });
+
+ it('uninstall takes out only the rule and the server', () => {
+ seed(`${JSON.stringify({
+ model: { name: 'qwen3-max' },
+ mcpServers: { idea: { command: 'java' } },
+ permissions: { allow: ['mcp__idea', 'Read'], deny: ['Read(.env)'] },
+ }, null, 2)}\n`);
+ const qwen = getTarget('qwen')!;
+
+ qwen.install('global', { autoAllow: true });
+ qwen.uninstall('global');
+
+ expect(read()).toEqual({
+ model: { name: 'qwen3-max' },
+ mcpServers: { idea: { command: 'java' } },
+ permissions: { allow: ['mcp__idea', 'Read'], deny: ['Read(.env)'] },
+ });
+ });
+
+ it('uninstall removes a rule left without its server, and the wrappers that held only it', () => {
+ seed(`${JSON.stringify({ theme: 'dark', permissions: { allow: ['mcp__codegraph'] } }, null, 2)}\n`);
+
+ const result = getTarget('qwen')!.uninstall('global');
+
+ expect(result.files[0]).toEqual({ path: settings(), action: 'removed' });
+ expect(read()).toEqual({ theme: 'dark' });
+ });
+});
+
+describe('Installer targets — Grok Build', () => {
+ const dirs = useTmpDirs('grok');
+ const tomlPath = () => path.join(dirs.home(), '.grok', 'config.toml');
+ const rulesPath = () => path.join(dirs.home(), '.grok', 'rules', 'codegraph.md');
+ const seed = (content: string) => {
+ fs.mkdirSync(path.dirname(tomlPath()), { recursive: true });
+ fs.writeFileSync(tomlPath(), content);
+ };
+ const TABLE = '[mcp_servers.codegraph]\ncommand = "codegraph"\nargs = ["serve", "--mcp"]\n';
+
+ it('a global install writes the table and the rules file', () => {
+ const grok = getTarget('grok')!;
+ const result = grok.install('global', { autoAllow: true });
+
+ expect(fs.readFileSync(tomlPath(), 'utf-8')).toBe(TABLE);
+ expect(fs.readFileSync(rulesPath(), 'utf-8')).toBe(`${CODEGRAPH_INSTRUCTIONS_BLOCK}\n`);
+ expect(result.files.map((f) => f.path)).toEqual([tomlPath(), rulesPath()]);
+ expect(result.notes?.join(' ')).toMatch(/\/mcps/);
+ expect(result.notes?.join(' ')).not.toMatch(/trusted/);
+
+ for (const f of grok.install('global', { autoAllow: true }).files) expect(f.action, f.path).toBe('unchanged');
+ });
+
+ it('a local install writes under ./.grok, says it needs trust, and leaves the global entry', () => {
+ const grok = getTarget('grok')!;
+ grok.install('global', { autoAllow: false });
+
+ const result = grok.install('local', { autoAllow: false });
+
+ expect(fs.readFileSync(path.join(dirs.cwd(), '.grok', 'config.toml'), 'utf-8')).toBe(TABLE);
+ expect(fs.existsSync(path.join(dirs.cwd(), '.grok', 'rules', 'codegraph.md'))).toBe(true);
+ expect(result.notes?.join(' ')).toMatch(/trusted/);
+
+ grok.uninstall('local');
+ expect(grok.detect('local').alreadyConfigured).toBe(false);
+ expect(grok.detect('global').alreadyConfigured).toBe(true);
+ });
+
+ it('siblings survive, and uninstall gives the file back', () => {
+ const content = '[models]\ndefault = "grok-4"\n\n[mcp_servers.other]\ncommand = "other"\n';
+ seed(content);
+ const grok = getTarget('grok')!;
+
+ grok.install('global', { autoAllow: false });
+ expect(fs.readFileSync(tomlPath(), 'utf-8')).toBe(`${content}\n${TABLE}`);
+
+ grok.uninstall('global');
+ expect(fs.readFileSync(tomlPath(), 'utf-8')).toBe(content);
+ });
+
+ it('uninstall deletes a file holding only our table', () => {
+ const grok = getTarget('grok')!;
+ grok.install('global', { autoAllow: false });
+
+ grok.uninstall('global');
+
+ expect(fs.existsSync(tomlPath())).toBe(false);
+ expect(fs.existsSync(rulesPath())).toBe(false);
+ });
+
+ it('a table the user changed stays as it is through a re-install and a refresh', () => {
+ const content = '[mcp_servers.codegraph]\ncommand = "/opt/bin/codegraph"\nargs = ["serve", "--mcp"]\nenabled = false\nstartup_timeout_sec = 60\n';
+ seed(content);
+ const grok = getTarget('grok')!;
+
+ expect(grok.install('global', { autoAllow: false }).files[0]).toEqual({ path: tomlPath(), action: 'unchanged' });
+ refreshTargets([grok], 'global');
+
+ expect(fs.readFileSync(tomlPath(), 'utf-8')).toBe(content);
+ });
+
+ it('a table written with an indent or quoted keys is found, and never defined twice', () => {
+ const content = ' [ "mcp_servers" . codegraph ]\n command = "codegraph"\n args = ["serve", "--mcp"]\n';
+ seed(content);
+ const grok = getTarget('grok')!;
+
+ expect(grok.detect('global').alreadyConfigured).toBe(true);
+ grok.install('global', { autoAllow: false });
+
+ expect(fs.readFileSync(tomlPath(), 'utf-8')).toBe(content);
+ });
+
+ it('dotted keys or an inline table are left as written, with a note', () => {
+ for (const content of [
+ 'mcp_servers.codegraph.command = "codegraph"\n',
+ '[mcp_servers]\ncodegraph = { command = "codegraph", args = ["serve", "--mcp"] }\n',
+ ]) {
+ seed(content);
+ const grok = getTarget('grok')!;
+
+ const result = grok.install('global', { autoAllow: false });
+
+ expect(result.files[0]).toEqual({ path: tomlPath(), action: 'unchanged' });
+ expect(result.notes?.join(' ')).toMatch(/Left the mcp_servers\.codegraph entry/);
+ expect(fs.readFileSync(tomlPath(), 'utf-8')).toBe(content);
+ }
+ });
+
+ it('an inline mcp_servers stops the install with nothing changed', () => {
+ const content = 'mcp_servers = { other = { command = "x" } }\n';
+ seed(content);
+
+ expect(() => getTarget('grok')!.install('global', { autoAllow: false })).toThrow(/Nothing was changed/);
+ expect(fs.readFileSync(tomlPath(), 'utf-8')).toBe(content);
+ });
+
+ it('uninstall reports an entry inside an inline mcp_servers instead of touching it', () => {
+ const content = 'mcp_servers = { codegraph = { command = "codegraph" } }\n';
+ seed(content);
+ const grok = getTarget('grok')!;
+
+ const result = grok.uninstall('global');
+
+ expect(result.files[0]).toEqual({ path: tomlPath(), action: 'not-found' });
+ expect(result.notes?.join(' ')).toMatch(/Remove it from there yourself/);
+ expect(fs.readFileSync(tomlPath(), 'utf-8')).toBe(content);
+ });
+
+ it('the rules file keeps what the user wrote around the block', () => {
+ fs.mkdirSync(path.dirname(rulesPath()), { recursive: true });
+ fs.writeFileSync(rulesPath(), `# My notes\n\nBe terse.\n\n${LEGACY_BLOCK}\n`);
+ const grok = getTarget('grok')!;
+
+ grok.install('global', { autoAllow: false });
+ expect(fs.readFileSync(rulesPath(), 'utf-8')).toBe(`# My notes\n\nBe terse.\n\n${CODEGRAPH_INSTRUCTIONS_BLOCK}\n`);
+
+ grok.uninstall('global');
+ expect(fs.readFileSync(rulesPath(), 'utf-8')).toBe('# My notes\n\nBe terse.\n');
+ });
+});
+
+describe('Installer targets — DeepSeek Harness', () => {
+ const dirs = useTmpDirs('dsh');
+ const dsh = () => getTarget('dsh')!;
+ const patchPath = () => path.join(dirs.home(), '.dsh', 'cordis.patch.yml');
+ const agentsPath = () => path.join(dirs.home(), '.dsh', 'AGENTS.md');
+ const seed = (content: string, file = patchPath()) => {
+ fs.mkdirSync(path.dirname(file), { recursive: true });
+ fs.writeFileSync(file, content);
+ };
+ const read = (file = patchPath()) => fs.readFileSync(file, 'utf-8');
+ const ENTRY = [
+ '- insert:',
+ ' - id: mcp-codegraph',
+ " name: '@deepseek-ai/dsh-mcp-client'",
+ ' config:',
+ ' serverName: codegraph',
+ ' transport: stdio',
+ ' command: codegraph',
+ ' args:',
+ ' - serve',
+ ' - --mcp',
+ '',
+ ].join('\n');
+ const USER_ROW = [
+ ' - id: mcp-github',
+ " name: '@deepseek-ai/dsh-mcp-client'",
+ ' config:',
+ ' serverName: github',
+ ' transport: stdio',
+ ' command: npx',
+ " args: ['-y', '@modelcontextprotocol/server-github']",
+ '',
+ ].join('\n');
+ const profilePatch = (name: string) => path.join(dirs.home(), '.dsh', 'profiles', name, 'cordis.patch.yml');
+
+ it('a fresh install writes the patch and the AGENTS.md block; uninstall removes both', () => {
+ const result = dsh().install('global', { autoAllow: true });
+
+ expect(read()).toBe(ENTRY);
+ expect(read(agentsPath())).toBe(`${CODEGRAPH_INSTRUCTIONS_BLOCK}\n`);
+ expect(result.files.map((f) => f.path)).toEqual([patchPath(), agentsPath()]);
+ expect(dsh().detect('global').alreadyConfigured).toBe(true);
+ for (const f of dsh().install('global', { autoAllow: true }).files) expect(f.action, f.path).toBe('unchanged');
+
+ dsh().uninstall('global');
+ expect(fs.existsSync(patchPath())).toBe(false);
+ expect(fs.existsSync(agentsPath())).toBe(false);
+ });
+
+ it('the documented empty list is replaced, and comes back on uninstall', () => {
+ const content = '# my patches\n[]\n# the end\n';
+ seed(content);
+
+ dsh().install('global', { autoAllow: false });
+ expect(read()).toBe(`# my patches\n${ENTRY}# the end\n`);
+
+ dsh().uninstall('global');
+ expect(read()).toBe('# my patches\n# the end\n[]\n');
+ });
+
+ it('a comments-only file, which stops dsh from starting, is repaired, and uninstall keeps it valid', () => {
+ seed('# nothing here yet\n');
+
+ dsh().install('global', { autoAllow: false });
+ expect(read()).toBe(`# nothing here yet\n${ENTRY}`);
+
+ dsh().uninstall('global');
+ expect(read()).toBe('# nothing here yet\n[]\n');
+ });
+
+ it('an empty file gets the patch, and goes away with it', () => {
+ seed('');
+ dsh().install('global', { autoAllow: false });
+ expect(read()).toBe(ENTRY);
+ dsh().uninstall('global');
+ expect(fs.existsSync(patchPath())).toBe(false);
+ });
+
+ it('the patch goes after the user\'s own, and uninstall gives the file back byte for byte', () => {
+ const content = `# overrides\n- id: tool-bash\n disabled: true\n\n- insert:\n${USER_ROW}`;
+ seed(content);
+
+ dsh().install('global', { autoAllow: false });
+ expect(read()).toBe(`${content}\n${ENTRY}`);
+
+ dsh().uninstall('global');
+ expect(read()).toBe(content);
+ });
+
+ it('a row the user wrote stays as it is through a re-install and a refresh: a pinned cwd is not reset', () => {
+ const row = [
+ '- insert:',
+ ' - id: my-codegraph',
+ ' name: "@deepseek-ai/dsh-mcp-client"',
+ ' config:',
+ ' serverName: "codegraph"',
+ ' transport: stdio',
+ ' command: /opt/bin/codegraph',
+ ' args: [serve, --mcp, --path, /work]',
+ ' cwd: /work',
+ '',
+ ].join('\n');
+ seed(row);
+
+ expect(dsh().detect('global').alreadyConfigured).toBe(true);
+ expect(dsh().install('global', { autoAllow: false }).files[0]).toEqual({ path: patchPath(), action: 'unchanged' });
+ refreshTargets([dsh()], 'global');
+
+ expect(read()).toBe(row);
+ });
+
+ it('a row merged into the user\'s insert list is found, and uninstall takes out only that row', () => {
+ const ours = [
+ ' - id: mcp-codegraph',
+ " name: '@deepseek-ai/dsh-mcp-client'",
+ ' config:',
+ ' serverName: codegraph',
+ ' transport: stdio',
+ ' command: codegraph',
+ ' args: [serve, --mcp]',
+ '',
+ ].join('\n');
+ seed(`- insert:\n${USER_ROW}${ours}${USER_ROW.replace(/github/g, 'gitlab')}`);
+
+ dsh().install('global', { autoAllow: false });
+ expect(read().match(/serverName: codegraph/g)).toHaveLength(1);
+
+ dsh().uninstall('global');
+ expect(read()).toBe(`- insert:\n${USER_ROW}${USER_ROW.replace(/github/g, 'gitlab')}`);
+ });
+
+ it('a row written on one line is a row like any other', () => {
+ const flow = " - { id: mcp-codegraph, name: '@deepseek-ai/dsh-mcp-client', config: { serverName: codegraph, transport: stdio, command: codegraph, args: [serve, --mcp] } }\n";
+ seed(`- insert:\n${USER_ROW}${flow}`);
+
+ expect(dsh().detect('global').alreadyConfigured).toBe(true);
+ dsh().install('global', { autoAllow: false });
+ expect(read()).toBe(`- insert:\n${USER_ROW}${flow}`);
+
+ dsh().uninstall('global');
+ expect(read()).toBe(`- insert:\n${USER_ROW}`);
+ });
+
+ it('a comment that mentions the server is not an entry', () => {
+ const content = "# serverName: codegraph\n- id: tool-bash\n disabled: true\n";
+ seed(content);
+
+ expect(dsh().detect('global').alreadyConfigured).toBe(false);
+ dsh().install('global', { autoAllow: false });
+ expect(read()).toBe(`${content}\n${ENTRY}`);
+ });
+
+ it('a file that is not a block list stops the install with nothing changed', () => {
+ for (const content of ['[{ id: tool-bash, disabled: true }]\n', 'not: a list\n', ' - id: indented\n disabled: true\n']) {
+ seed(content);
+ expect(() => dsh().install('global', { autoAllow: false }), content).toThrow(/Nothing was changed/);
+ expect(read()).toBe(content);
+ }
+ });
+
+ it('an entry in a form it can\'t edit is left alone, and uninstall says so', () => {
+ const content = "[{ insert: [{ id: mcp-codegraph, name: '@deepseek-ai/dsh-mcp-client', config: { serverName: codegraph } }] }]\n";
+ seed(content);
+
+ expect(dsh().detect('global').alreadyConfigured).toBe(true);
+ expect(dsh().install('global', { autoAllow: false }).files[0]).toEqual({ path: patchPath(), action: 'unchanged' });
+
+ const result = dsh().uninstall('global');
+ expect(result.files[0]).toEqual({ path: patchPath(), action: 'not-found' });
+ expect(result.notes?.join(' ')).toMatch(/Remove it from there yourself/);
+ expect(read()).toBe(content);
+ });
+
+ it('a row nested in a group is not mistaken for the group, and the group stays', () => {
+ const content = [
+ '- insert:',
+ ' - id: my-group',
+ ' name: group',
+ ' config:',
+ ' - id: mcp-codegraph',
+ " name: '@deepseek-ai/dsh-mcp-client'",
+ ' config: { serverName: codegraph }',
+ '',
+ ].join('\n');
+ seed(content);
+
+ const result = dsh().uninstall('global');
+
+ expect(result.notes?.join(' ')).toMatch(/Remove it from there yourself/);
+ expect(read()).toBe(content);
+ });
+
+ it('an insert item with keys of its own stays when its only row is ours', () => {
+ const content = `${ENTRY} note: mine\n`;
+ seed(content);
+
+ const result = dsh().uninstall('global');
+
+ expect(result.notes?.join(' ')).toMatch(/Remove it from there yourself/);
+ expect(read()).toBe(content);
+ });
+
+ it('a profile\'s own entry counts: nothing is added beside it, and uninstall removes it, keeping the file', () => {
+ const profile = profilePatch('web');
+ seed(`# my profile\n${ENTRY}`, profile);
+
+ expect(dsh().detect('global').alreadyConfigured).toBe(true);
+ const result = dsh().install('global', { autoAllow: false });
+ expect(result.files[0]).toEqual({ path: patchPath(), action: 'unchanged' });
+ expect(result.notes?.join(' ')).toMatch(/Left the CodeGraph entry in .*web/);
+ expect(fs.existsSync(patchPath())).toBe(false);
+
+ const removed = dsh().uninstall('global');
+ expect(removed.files.map((f) => f.path)).toContain(profile);
+ expect(read(profile)).toBe('# my profile\n[]\n');
+ expect(dsh().detect('global').alreadyConfigured).toBe(false);
+ });
+
+ it('a profile that was only emptied by uninstall keeps a valid file', () => {
+ const profile = profilePatch('web');
+ seed(ENTRY, profile);
+
+ dsh().uninstall('global');
+
+ expect(read(profile)).toBe('[]\n');
+ });
+
+ it('CRLF is kept', () => {
+ const content = '# mine\r\n- id: tool-bash\r\n disabled: true\r\n';
+ seed(content);
+
+ dsh().install('global', { autoAllow: false });
+ expect(read()).toBe(`${content}\r\n${ENTRY.replace(/\n/g, '\r\n')}`);
+
+ dsh().uninstall('global');
+ expect(read()).toBe(content);
+ });
+
+ it('global only', () => {
+ expect(dsh().supportsLocation('local')).toBe(false);
+ expect(dsh().install('local', { autoAllow: false }).files).toEqual([]);
+ expect(dsh().uninstall('local').files).toEqual([]);
+ expect(dsh().describePaths('local')).toEqual([]);
+ });
+
+ it('printConfig prints the patch to paste', () => {
+ expect(dsh().printConfig('global')).toBe(`# Add to ${patchPath()}\n\n${ENTRY}`);
+ });
+});
+
describe('Antigravity macOS command persistence (#1443)', () => {
let tmpHome: string;
let homeRestore: { restore: () => void };
diff --git a/__tests__/setup-home-sandbox.ts b/__tests__/setup-home-sandbox.ts
index ba5c4fe3a..c40a59a92 100644
--- a/__tests__/setup-home-sandbox.ts
+++ b/__tests__/setup-home-sandbox.ts
@@ -54,7 +54,7 @@ if (process.platform === 'win32') {
delete process.env.LOCALAPPDATA;
}
process.env.XDG_CONFIG_HOME = path.join(home, '.config');
-for (const override of ['CLAUDE_CONFIG_DIR', 'CODEX_HOME', 'COPILOT_HOME', 'HERMES_HOME']) {
+for (const override of ['CLAUDE_CONFIG_DIR', 'CODEX_HOME', 'COPILOT_HOME', 'HERMES_HOME', 'QWEN_HOME', 'DSH_HOME', 'GROK_HOME', 'KIMI_CODE_HOME', 'QODER_CONFIG_DIR', 'PI_CODING_AGENT_DIR']) {
delete process.env[override];
}
diff --git a/src/bin/codegraph.ts b/src/bin/codegraph.ts
index 83194b4c8..7c53fa4b3 100644
--- a/src/bin/codegraph.ts
+++ b/src/bin/codegraph.ts
@@ -3076,12 +3076,12 @@ program
*/
program
.command('install')
- .description('Install codegraph MCP server into one or more agents (Claude Code, Cursor, Codex CLI, opencode, Hermes Agent, Gemini CLI, Antigravity IDE, Kiro, GitHub Copilot)')
+ .description('Install codegraph MCP server into one or more agents (Claude Code, Cursor, Codex CLI, opencode, Hermes Agent, Gemini CLI, Antigravity IDE, Kiro, GitHub Copilot, IBM Bob, Factory Droid, Windsurf, Qwen Code, DeepSeek Harness, Grok Build, Kimi Code, Qoder CLI, Pi)')
.option('-t, --target ', 'Target agent(s): comma-separated ids, or "auto"|"all"|"none". Default: prompt')
.option('-l, --location ', 'Install location: "global" or "local". Default: prompt')
.option('-y, --yes', 'Non-interactive: defaults to --location=global --target=auto, auto-allow on')
.option('-i, --init', 'After wiring agents, also run `codegraph init` in the current directory — builds this project’s index, so install + index is one command (combine with --yes for an unattended bootstrap)')
- .option('--no-permissions', 'Skip writing the auto-allow permissions list (Claude Code only)')
+ .option('--no-permissions', 'Skip writing the auto-allow permissions list (Claude Code and Qwen Code only)')
.option('--print-config ', 'Print MCP config snippet for the named agent and exit (no file writes)')
.option('--refresh', 'Rewrite what previous installs configured, for already-configured agents only (never adds new ones). Run automatically by `codegraph upgrade`')
.action(async (opts: {
@@ -3198,7 +3198,7 @@ program
*/
program
.command('uninstall')
- .description('Remove codegraph from your agents (Claude Code, Cursor, Codex CLI, opencode, Hermes Agent, Gemini CLI, Antigravity IDE, Kiro, GitHub Copilot)')
+ .description('Remove codegraph from your agents (Claude Code, Cursor, Codex CLI, opencode, Hermes Agent, Gemini CLI, Antigravity IDE, Kiro, GitHub Copilot, IBM Bob, Factory Droid, Windsurf, Qwen Code, DeepSeek Harness, Grok Build, Kimi Code, Qoder CLI, Pi)')
.option('-t, --target ', 'Target agent(s): comma-separated ids, or "all". Default: all')
.option('-l, --location ', 'Uninstall location: "global" or "local". Default: prompt')
.option('-y, --yes', 'Non-interactive: defaults to --location=global --target=all')
diff --git a/src/installer/index.ts b/src/installer/index.ts
index d995c84a6..9b866825d 100644
--- a/src/installer/index.ts
+++ b/src/installer/index.ts
@@ -3,8 +3,10 @@
*
* Multi-target: writes MCP server config + instructions for the
* agents the user picks (Claude Code, Cursor, Codex CLI, opencode,
- * Hermes Agent, Gemini CLI, Antigravity IDE, Kiro, and GitHub
- * Copilot in VS Code / the Copilot CLI / JetBrains IDEs).
+ * Hermes Agent, Gemini CLI, Antigravity IDE, Kiro, GitHub Copilot in
+ * VS Code / the Copilot CLI / JetBrains IDEs, IBM Bob, Factory Droid,
+ * Windsurf, Qwen Code, DeepSeek Harness, Grok Build, Kimi Code, the
+ * Qoder CLI, and Pi).
* Defaults to the Claude-only behavior for backwards compatibility
* when no targets are explicitly chosen and nothing else is detected.
*
@@ -161,16 +163,17 @@ export async function runInstallerWithOptions(opts: RunInstallerOptions): Promis
}
}
- // Step 4: auto-allow permissions (only meaningful for Claude;
- // skipped silently by other targets).
+ // Step 4: auto-allow permissions (only meaningful for Claude Code and
+ // Qwen Code; skipped silently by other targets).
+ const autoAllowAgents = targets.filter((t) => t.id === 'claude' || t.id === 'qwen').map((t) => t.displayName);
let autoAllow: boolean;
if (opts.autoAllow !== undefined) {
autoAllow = opts.autoAllow;
} else if (useDefaults) {
autoAllow = true;
- } else if (targets.some((t) => t.id === 'claude')) {
+ } else if (autoAllowAgents.length > 0) {
const ans = await clack.confirm({
- message: 'Auto-allow CodeGraph commands? (Skips permission prompts in Claude Code)',
+ message: `Auto-allow CodeGraph commands? (Skips permission prompts in ${autoAllowAgents.join(' and ')})`,
initialValue: true,
});
if (clack.isCancel(ans)) {
@@ -480,8 +483,8 @@ export async function runUninstaller(opts: RunUninstallerOptions): Promise
const sel = await clack.select({
message: 'Remove CodeGraph from all your projects, or just this one?',
options: [
- { value: 'global' as const, label: 'All projects (global)', hint: '~/.claude, ~/.cursor, ~/.codex, ~/.config/opencode, ~/.hermes, ~/.gemini, ~/.kiro, ~/.copilot, ~/.config/github-copilot' },
- { value: 'local' as const, label: 'Just this project (local)', hint: './.claude, ./.cursor, ./.vscode, ./opencode.jsonc, ./.gemini, ./.kiro' },
+ { value: 'global' as const, label: 'All projects (global)', hint: '~/.claude, ~/.cursor, ~/.codex, ~/.config/opencode, ~/.hermes, ~/.gemini, ~/.kiro, ~/.copilot, ~/.config/github-copilot, ~/.bob, ~/.factory, ~/.codeium/windsurf, ~/.qwen, ~/.dsh, ~/.grok, ~/.kimi-code, ~/.qoder, ~/.pi' },
+ { value: 'local' as const, label: 'Just this project (local)', hint: './.claude, ./.cursor, ./.vscode, ./opencode.jsonc, ./.gemini, ./.kiro, ./.bob, ./.factory, ./.qwen, ./.grok, ./.kimi-code, ./.qoder, ./.pi' },
],
initialValue: 'global' as const,
});
diff --git a/src/installer/instructions-template.ts b/src/installer/instructions-template.ts
index 924749190..df583879f 100644
--- a/src/installer/instructions-template.ts
+++ b/src/installer/instructions-template.ts
@@ -1,6 +1,6 @@
/**
* The marker-fenced agent-instructions block the installer writes into each
- * agent's instructions file (CLAUDE.md / AGENTS.md / GEMINI.md).
+ * agent's instructions file (CLAUDE.md / AGENTS.md / GEMINI.md / QWEN.md).
*
* History: pre-#529 the installer wrote a full usage playbook here, which
* duplicated the MCP `initialize` instructions for the main agent — so it
diff --git a/src/installer/targets/bob.ts b/src/installer/targets/bob.ts
new file mode 100644
index 000000000..d8da384f5
--- /dev/null
+++ b/src/installer/targets/bob.ts
@@ -0,0 +1,74 @@
+/**
+ * IBM Bob target (Bob IDE and Bob Shell, which read the same files). Writes
+ * the MCP server entry only:
+ *
+ * - global: `~/.bob/settings/mcp.json`
+ * - local: `./.bob/mcp.json`, which Bob means to be shared in the repo
+ *
+ * The entry is `{ command, args }` under `mcpServers`: Bob's stdio entry has
+ * no `type` key. IBM's watsonx.data guide calls the global file
+ * `mcp_settings.json`, so when only that file exists, it is the one used.
+ *
+ * No instructions file, and `autoAllow` is a no-op: Bob approves MCP tools one
+ * by one, behind its own global "Use MCP servers" switch.
+ *
+ * Docs: https://bob.ibm.com/docs/ide/configuration/mcp/mcp-in-bob
+ * https://bob.ibm.com/docs/shell/configuration/mcp/mcp-bobshell
+ */
+
+import * as fs from 'fs';
+import * as os from 'os';
+import * as path from 'path';
+import { AgentTarget, DetectionResult, InstallOptions, Location, WriteResult } from './types';
+import { hasCodegraphServer, removeCodegraphServer, upsertCodegraphServer } from './json-config';
+import { getStdioServerConfig } from './shared';
+
+function configDir(loc: Location): string {
+ return path.join(loc === 'global' ? os.homedir() : process.cwd(), '.bob');
+}
+
+function mcpJsonPath(loc: Location): string {
+ if (loc === 'local') return path.join(configDir('local'), 'mcp.json');
+ const settings = path.join(configDir('global'), 'settings');
+ const current = path.join(settings, 'mcp.json');
+ const earlier = path.join(settings, 'mcp_settings.json');
+ return !fs.existsSync(current) && fs.existsSync(earlier) ? earlier : current;
+}
+
+class BobTarget implements AgentTarget {
+ readonly id = 'bob' as const;
+ readonly displayName = 'IBM Bob';
+ readonly docsUrl = 'https://bob.ibm.com/docs/ide/configuration/mcp/mcp-in-bob';
+
+ supportsLocation(_loc: Location): boolean {
+ return true;
+ }
+
+ detect(loc: Location): DetectionResult {
+ const file = mcpJsonPath(loc);
+ return {
+ installed: fs.existsSync(configDir(loc)),
+ alreadyConfigured: hasCodegraphServer(file),
+ configPath: file,
+ };
+ }
+
+ install(loc: Location, _opts: InstallOptions): WriteResult {
+ return { files: [upsertCodegraphServer(mcpJsonPath(loc), getStdioServerConfig())] };
+ }
+
+ uninstall(loc: Location): WriteResult {
+ return { files: [removeCodegraphServer(mcpJsonPath(loc))] };
+ }
+
+ printConfig(loc: Location): string {
+ const snippet = JSON.stringify({ mcpServers: { codegraph: getStdioServerConfig() } }, null, 2);
+ return `# Add to ${mcpJsonPath(loc)}\n\n${snippet}\n`;
+ }
+
+ describePaths(loc: Location): string[] {
+ return [mcpJsonPath(loc)];
+ }
+}
+
+export const bobTarget: AgentTarget = new BobTarget();
diff --git a/src/installer/targets/codex.ts b/src/installer/targets/codex.ts
index 2f0843f9a..5de0fc1f1 100644
--- a/src/installer/targets/codex.ts
+++ b/src/installer/targets/codex.ts
@@ -40,17 +40,11 @@ import {
WriteResult,
} from './types';
import {
- atomicWriteFileSync,
getMcpServerConfig,
- removeFileUnlessLinked,
- removeMarkedSection,
+ removeInstructionsEntry,
upsertInstructionsEntry,
} from './shared';
-import {
- CODEGRAPH_SECTION_END,
- CODEGRAPH_SECTION_START,
-} from '../instructions-template';
-import { buildTomlTable, removeTomlTable, tomlDefinesTable, tomlLineBreak, upsertTomlTable } from './toml';
+import { buildTomlTable, deleteTomlTable, tomlDefinesTable, writeTomlTable } from './toml';
const TOML_HEADER = 'mcp_servers.codegraph';
@@ -119,7 +113,7 @@ class CodexTarget implements AgentTarget {
const files: WriteResult['files'] = [];
const notes: string[] = [];
- const mcp = writeMcpEntry(loc);
+ const mcp = writeTomlTable(tomlConfigPath(loc), TOML_HEADER, buildCodegraphBlock());
files.push(mcp.file);
if (mcp.note) notes.push(mcp.note);
@@ -136,29 +130,11 @@ class CodexTarget implements AgentTarget {
const files: WriteResult['files'] = [];
const notes: string[] = [];
- const tomlPath = tomlConfigPath(loc);
- if (fs.existsSync(tomlPath)) {
- const content = fs.readFileSync(tomlPath, 'utf-8');
- const result = removeTomlTable(content, TOML_HEADER);
- if (result.action === 'removed') {
- if (result.content.trim() === '') {
- removeFileUnlessLinked(tomlPath, '');
- } else {
- atomicWriteFileSync(tomlPath, result.content.trimEnd() + tomlLineBreak(content));
- }
- files.push({ path: tomlPath, action: 'removed' });
- } else {
- files.push({ path: tomlPath, action: 'not-found' });
- if (result.action === 'conflict') {
- const { conflict } = result;
- notes.push(`${tomlPath} sets ${TOML_HEADER} inside ${conflict.key} (${conflict.form}, line ${conflict.line}), which CodeGraph can't edit safely. Remove it from there yourself.`);
- }
- }
- } else {
- files.push({ path: tomlPath, action: 'not-found' });
- }
+ const mcp = deleteTomlTable(tomlConfigPath(loc), TOML_HEADER);
+ files.push(mcp.file);
+ if (mcp.note) notes.push(mcp.note);
- files.push(removeInstructionsEntry(loc));
+ files.push(removeInstructionsEntry(instructionsPath(loc)));
return notes.length > 0 ? { files, notes } : { files };
}
@@ -181,52 +157,4 @@ function buildCodegraphBlock(): string {
});
}
-function writeMcpEntry(loc: Location): { file: WriteResult['files'][number]; note?: string } {
- const file = tomlConfigPath(loc);
- const dir = path.dirname(file);
- if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
-
- const block = buildCodegraphBlock();
- // Single read — `existing === ''` derives both "is the file empty
- // or absent" and "what was its content," avoiding a TOCTOU window
- // between two `fs.existsSync` calls.
- const existing = fs.existsSync(file) ? fs.readFileSync(file, 'utf-8') : '';
- const created = existing.length === 0;
- const result = upsertTomlTable(existing, TOML_HEADER, block);
-
- if (result.action === 'conflict') {
- // Never append a second definition: a table defined twice makes the
- // whole file invalid TOML, and Codex then loads none of it (#2562).
- const { conflict } = result;
- if (conflict.kind === 'defined') {
- // The user's own entry, in a form CodeGraph doesn't rewrite: it
- // already configures Codex, so it is left exactly as written.
- return {
- file: { path: file, action: 'unchanged' },
- note: `Left the ${TOML_HEADER} entry in ${file} as you wrote it (${conflict.form}, line ${conflict.line}): CodeGraph only rewrites a [${TOML_HEADER}] table.`,
- };
- }
- throw new Error(
- `${file} sets ${conflict.key} as ${conflict.form} (line ${conflict.line}), so a [${TOML_HEADER}] table can't be added without breaking the file. ` +
- `Nothing was changed: add CodeGraph's server to ${conflict.key} yourself, or rewrite it as [${conflict.key}.] tables, then run this again.`,
- );
- }
- if (result.action === 'unchanged') {
- return { file: { path: file, action: 'unchanged' } };
- }
- atomicWriteFileSync(file, result.content);
- return { file: { path: file, action: created ? 'created' : 'updated' } };
-}
-
-/**
- * Strip the marker-delimited CodeGraph block from this location's
- * AGENTS.md if a prior install wrote one. Used by both install
- * (self-heal on upgrade) and uninstall — see issue #529.
- */
-function removeInstructionsEntry(loc: Location): WriteResult['files'][number] {
- const file = instructionsPath(loc);
- const action = removeMarkedSection(file, CODEGRAPH_SECTION_START, CODEGRAPH_SECTION_END);
- return { path: file, action };
-}
-
export const codexTarget: AgentTarget = new CodexTarget();
diff --git a/src/installer/targets/droid.ts b/src/installer/targets/droid.ts
new file mode 100644
index 000000000..ad43ade70
--- /dev/null
+++ b/src/installer/targets/droid.ts
@@ -0,0 +1,82 @@
+/**
+ * Factory Droid target (the `droid` CLI).
+ *
+ * - MCP server entry under `mcpServers` in `~/.factory/mcp.json` (global) or
+ * `./.factory/mcp.json` (local). The project file is meant to be committed,
+ * so a local install says so; our stdio entry carries no secrets.
+ * - Instructions to `~/.factory/AGENTS.md` (global) or `./AGENTS.md` (local).
+ *
+ * Droid reloads when an `mcp.json` changes, so there is no restart note, and
+ * it remembers MCP tool approvals per server instead of keeping an allowlist in
+ * a file, so `autoAllow` is a no-op.
+ *
+ * Docs: https://docs.factory.com/cli/configuration/mcp
+ * https://docs.factory.com/cli/configuration/agents-md
+ */
+
+import * as fs from 'fs';
+import * as os from 'os';
+import * as path from 'path';
+import { AgentTarget, DetectionResult, InstallOptions, Location, WriteResult } from './types';
+import { hasCodegraphServer, removeCodegraphServer, upsertCodegraphServer } from './json-config';
+import { getMcpServerConfig, removeInstructionsEntry, upsertInstructionsEntry } from './shared';
+
+function configDir(loc: Location): string {
+ return path.join(loc === 'global' ? os.homedir() : process.cwd(), '.factory');
+}
+
+function mcpJsonPath(loc: Location): string {
+ return path.join(configDir(loc), 'mcp.json');
+}
+
+function instructionsPath(loc: Location): string {
+ return loc === 'global' ? path.join(configDir('global'), 'AGENTS.md') : path.join(process.cwd(), 'AGENTS.md');
+}
+
+class DroidTarget implements AgentTarget {
+ readonly id = 'droid' as const;
+ readonly displayName = 'Factory Droid';
+ readonly docsUrl = 'https://docs.factory.com/cli/configuration/mcp';
+
+ supportsLocation(_loc: Location): boolean {
+ return true;
+ }
+
+ detect(loc: Location): DetectionResult {
+ const file = mcpJsonPath(loc);
+ return {
+ installed: fs.existsSync(configDir(loc)),
+ alreadyConfigured: hasCodegraphServer(file),
+ configPath: file,
+ };
+ }
+
+ install(loc: Location, _opts: InstallOptions): WriteResult {
+ const files = [
+ upsertCodegraphServer(mcpJsonPath(loc), getMcpServerConfig()),
+ upsertInstructionsEntry(instructionsPath(loc)),
+ ];
+ if (loc === 'global') return { files };
+ return { files, notes: [`Droid shares ${mcpJsonPath('local')} with your team: it is meant to be committed.`] };
+ }
+
+ uninstall(loc: Location): WriteResult {
+ return {
+ files: [
+ removeCodegraphServer(mcpJsonPath(loc)),
+ removeInstructionsEntry(instructionsPath(loc)),
+ ],
+ };
+ }
+
+ printConfig(loc: Location): string {
+ const snippet = JSON.stringify({ mcpServers: { codegraph: getMcpServerConfig() } }, null, 2);
+ return `# Add to ${mcpJsonPath(loc)}\n\n${snippet}\n`;
+ }
+
+ describePaths(loc: Location): string[] {
+ return [mcpJsonPath(loc), instructionsPath(loc)];
+ }
+}
+
+export const droidTarget: AgentTarget = new DroidTarget();
diff --git a/src/installer/targets/dsh.ts b/src/installer/targets/dsh.ts
new file mode 100644
index 000000000..972c6ca60
--- /dev/null
+++ b/src/installer/targets/dsh.ts
@@ -0,0 +1,321 @@
+/**
+ * DeepSeek Harness (`dsh`) target, global only.
+ *
+ * dsh has no `mcpServers` file: an MCP server is one row for the
+ * `@deepseek-ai/dsh-mcp-client` plugin in a Cordis patch file. We add one to
+ * `$DSH_HOME/cordis.patch.yml` (default `~/.dsh`), the home-level layer that
+ * applies to every profile, as a `- insert:` patch of its own:
+ *
+ * - insert:
+ * - id: mcp-codegraph
+ * name: '@deepseek-ai/dsh-mcp-client'
+ * config: { serverName: codegraph, transport: stdio, command, args }
+ *
+ * and the instructions block to `$DSH_HOME/AGENTS.md`, the start of dsh's
+ * user-global instruction chain. `autoAllow` is a no-op.
+ *
+ * The patch file is the user's, so it is edited as text, line by line, and
+ * comments and other patches survive. dsh parses it with js-yaml and refuses to
+ * start unless it is a top-level array, so a comments-only file is as fatal as a
+ * broken one: install repairs one by adding the patch, and an uninstall that
+ * would leave one writes the documented empty list, `[]`.
+ *
+ * A server is identified by `serverName: codegraph`: two rows with one name in
+ * the same scope make the later one fail to load. An entry already there, in
+ * this file or a profile's own `cordis.patch.yml`, is the user's. It may pin a
+ * `cwd` or a `command`, so install never rewrites it and never adds a second
+ * one; uninstall takes it out wherever it can be edited.
+ *
+ * Docs: https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/mcp/mcp-client/README.md
+ * https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/user/guide/mcp-memory.md
+ * https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/context/agent-instructions/README.md
+ */
+
+import * as fs from 'fs';
+import * as os from 'os';
+import * as path from 'path';
+import { AgentTarget, DetectionResult, InstallOptions, Location, WriteResult } from './types';
+import {
+ atomicWriteFileSync,
+ configDirFromEnv,
+ getMcpServerConfig,
+ joinText,
+ removeFileUnlessLinked,
+ removeInstructionsEntry,
+ splitText,
+ upsertInstructionsEntry,
+} from './shared';
+
+type FileResult = WriteResult['files'][number];
+
+/** A span of lines: `start` up to, not including, `end`. */
+interface Range {
+ start: number;
+ end: number;
+}
+
+const PATCH_FILE = 'cordis.patch.yml';
+
+/** `serverName: codegraph` in a block or a one-line `{ … }`, quoted or not. */
+const NAMES_SERVER = /(^|[\s{,])["']?serverName["']?\s*:\s*["']?codegraph["']?\s*([,}#]|$)/;
+const NAMES_CLIENT = /(^|[\s{,])["']?name["']?\s*:\s*["']?@deepseek-ai\/dsh-mcp-client["']?/;
+
+function dshHome(): string {
+ return configDirFromEnv('DSH_HOME', path.join(os.homedir(), '.dsh'));
+}
+
+function homePatchPath(): string {
+ return path.join(dshHome(), PATCH_FILE);
+}
+
+function instructionsPath(): string {
+ return path.join(dshHome(), 'AGENTS.md');
+}
+
+function profilePatchPaths(): string[] {
+ const profiles = path.join(dshHome(), 'profiles');
+ let names: string[];
+ try {
+ names = fs.readdirSync(profiles);
+ } catch {
+ return [];
+ }
+ return names.map((name) => path.join(profiles, name, PATCH_FILE)).filter((file) => fs.existsSync(file));
+}
+
+function readLines(file: string): ReturnType {
+ try {
+ return splitText(fs.readFileSync(file, 'utf-8'));
+ } catch {
+ return splitText('');
+ }
+}
+
+function renderEntry(): string[] {
+ const { command, args } = getMcpServerConfig();
+ return [
+ '- insert:',
+ ' - id: mcp-codegraph',
+ " name: '@deepseek-ai/dsh-mcp-client'",
+ ' config:',
+ ' serverName: codegraph',
+ ' transport: stdio',
+ ` command: ${command}`,
+ ' args:',
+ ...args.map((arg) => ` - ${arg}`),
+ ];
+}
+
+// ---------------------------------------------------------------------------
+// Line-level YAML: a top-level block list and the rows of its `- insert:` items.
+// ---------------------------------------------------------------------------
+
+function indentOf(line: string): number {
+ return /^ */.exec(line)![0].length;
+}
+
+/** A line that holds no value: blank, a comment, or the `---` that opens a document. */
+function isTrivia(line: string): boolean {
+ const t = line.trim();
+ return t === '' || t === '---' || t.startsWith('#');
+}
+
+function isTopLevelItem(line: string): boolean {
+ return /^-(\s|$)/.test(line);
+}
+
+/** Where a span ends, before the blank and comment lines leading up to what follows it. */
+function trimmedEnd(lines: string[], start: number, end: number): number {
+ while (end > start + 1 && isTrivia(lines[end - 1]!)) end--;
+ return end;
+}
+
+function topLevelItems(lines: string[]): Range[] {
+ const starts = lines.flatMap((line, i) => (isTopLevelItem(line) ? [i] : []));
+ return starts.map((start, n) => ({ start, end: trimmedEnd(lines, start, starts[n + 1] ?? lines.length) }));
+}
+
+/** The rows of a `- insert:` item: its list items, at the indent of the first. */
+function insertRows(lines: string[], item: Range): Range[] {
+ if (!/^-\s+insert\s*:\s*(#.*)?$/.test(lines[item.start]!)) return [];
+ const body = lines.slice(item.start + 1, item.end);
+ const firstAt = body.findIndex((line) => !isTrivia(line));
+ if (firstAt === -1 || !/^ +-(\s|$)/.test(body[firstAt]!)) return [];
+
+ const indent = indentOf(body[firstAt]!);
+ const starts: number[] = [];
+ let stop = item.end;
+ for (let i = item.start + 1 + firstAt; i < item.end; i++) {
+ const line = lines[i]!;
+ if (isTrivia(line)) continue;
+ const at = indentOf(line);
+ if (at < indent) {
+ stop = i;
+ break;
+ }
+ if (at === indent) starts.push(i);
+ }
+ return starts.map((start, n) => ({ start, end: trimmedEnd(lines, start, starts[n + 1] ?? stop) }));
+}
+
+/** Whether a row is CodeGraph's server, by its own `name:`, not one nested in a group's `config`. */
+function isCodeGraphRow(lines: string[], row: Range): boolean {
+ const body = lines.slice(row.start, row.end).filter((line) => !isTrivia(line));
+ if (!body.some((line) => NAMES_SERVER.test(line))) return false;
+ const keyIndent = indentOf(body[0]!.replace('-', ' '));
+ return body.some((line, i) => (i === 0 || indentOf(line) === keyIndent) && NAMES_CLIENT.test(line));
+}
+
+function hasServer(lines: string[]): boolean {
+ return lines.some((line) => !isTrivia(line) && NAMES_SERVER.test(line));
+}
+
+/**
+ * What the file holds, ignoring comments. `empty` has no patches yet, with
+ * `emptyList` the line of a `[]` if there is one. Anything but a block list at
+ * column 0 (a flow `[…]`, a mapping) is `other`: this editor doesn't change it.
+ */
+function layoutOf(lines: string[]): { kind: 'list' | 'other' } | { kind: 'empty'; emptyList: number } {
+ const content = lines.flatMap((line, i) => (isTrivia(line) ? [] : [i]));
+ const first = content[0];
+ if (first === undefined) return { kind: 'empty', emptyList: -1 };
+ if (content.length === 1 && /^\[\s*\]\s*(#.*)?$/.test(lines[first]!)) return { kind: 'empty', emptyList: first };
+ const allItems = content.every((i) => /^\s/.test(lines[i]!) || isTopLevelItem(lines[i]!));
+ return { kind: isTopLevelItem(lines[first]!) && allItems ? 'list' : 'other' };
+}
+
+/**
+ * Take CodeGraph's rows out of `lines`. A `- insert:` holding nothing else goes
+ * with them, and the blank line above it. True when anything was removed.
+ */
+function removeRows(lines: string[]): boolean {
+ let removed = false;
+ for (const item of topLevelItems(lines).reverse()) {
+ const rows = insertRows(lines, item);
+ const ours = rows.filter((row) => isCodeGraphRow(lines, row));
+ if (ours.length === 0) continue;
+ if (ours.length < rows.length) {
+ for (const row of [...ours].reverse()) lines.splice(row.start, row.end - row.start);
+ } else if (ours[ours.length - 1]!.end === item.end) {
+ const start = item.start > 0 && lines[item.start - 1]!.trim() === '' ? item.start - 1 : item.start;
+ lines.splice(start, item.end - start);
+ } else {
+ continue;
+ }
+ removed = true;
+ }
+ return removed;
+}
+
+// ---------------------------------------------------------------------------
+// The patch files
+// ---------------------------------------------------------------------------
+
+function writePatch(): { file: FileResult; note?: string } {
+ const file = homePatchPath();
+ const existed = fs.existsSync(file);
+ const text = readLines(file);
+ if (hasServer(text.lines)) return { file: { path: file, action: 'unchanged' } };
+
+ const profile = profilePatchPaths().find((p) => hasServer(readLines(p).lines));
+ if (profile) {
+ return {
+ file: { path: file, action: 'unchanged' },
+ note: `Left the CodeGraph entry in ${profile} as you wrote it: a second one in ${file} would fail to load. Move it there to use CodeGraph in every profile.`,
+ };
+ }
+
+ const layout = layoutOf(text.lines);
+ if (layout.kind === 'other') {
+ throw new Error(
+ `${file} is not a block-style YAML list, so CodeGraph can't add its server. ` +
+ 'Nothing was changed: add the entry from `codegraph install --print-config dsh` yourself, then run this again.',
+ );
+ }
+ if (layout.kind === 'empty' && layout.emptyList !== -1) {
+ text.lines.splice(layout.emptyList, 1, ...renderEntry());
+ } else {
+ const separate = layout.kind === 'list';
+ while (text.lines.length > 0 && text.lines[text.lines.length - 1]!.trim() === '') text.lines.pop();
+ text.lines.push(...(separate ? [''] : []), ...renderEntry());
+ }
+ atomicWriteFileSync(file, joinText(text));
+ return { file: { path: file, action: existed ? 'updated' : 'created' } };
+}
+
+/**
+ * Take CodeGraph's server out of a patch file. One left with nothing in it is
+ * deleted when `mayDelete` (dsh runs without a home-level file, and creates each
+ * profile's own), or emptied through a symbolic link. With comments left, it
+ * becomes the empty list, because a comments-only file stops dsh from starting.
+ */
+function removeFromPatch(file: string, mayDelete: boolean): { file: FileResult; note?: string } {
+ const text = readLines(file);
+ if (!hasServer(text.lines)) return { file: { path: file, action: 'not-found' } };
+
+ const removed = removeRows(text.lines);
+ const note = hasServer(text.lines)
+ ? `${file} sets CodeGraph's server in a form CodeGraph can't edit safely. Remove it from there yourself.`
+ : undefined;
+ if (!removed) return { file: { path: file, action: 'not-found' }, note };
+
+ if (layoutOf(text.lines).kind === 'empty') {
+ if (mayDelete && text.lines.every((line) => line.trim() === '')) {
+ removeFileUnlessLinked(file, `[]${text.eol}`);
+ return { file: { path: file, action: 'removed' }, note };
+ }
+ text.lines.push('[]');
+ }
+ atomicWriteFileSync(file, joinText(text));
+ return { file: { path: file, action: 'removed' }, note };
+}
+
+class DshTarget implements AgentTarget {
+ readonly id = 'dsh' as const;
+ readonly displayName = 'DeepSeek Harness';
+ readonly docsUrl = 'https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/mcp/mcp-client/README.md';
+
+ supportsLocation(loc: Location): boolean {
+ return loc === 'global';
+ }
+
+ detect(loc: Location): DetectionResult {
+ if (loc !== 'global') return { installed: false, alreadyConfigured: false };
+ const file = homePatchPath();
+ return {
+ installed: fs.existsSync(dshHome()),
+ alreadyConfigured: [file, ...profilePatchPaths()].some((p) => hasServer(readLines(p).lines)),
+ configPath: file,
+ };
+ }
+
+ install(loc: Location, _opts: InstallOptions): WriteResult {
+ if (loc !== 'global') {
+ return { files: [], notes: ['DeepSeek Harness keeps its config in $DSH_HOME; re-run with --location=global.'] };
+ }
+ const patch = writePatch();
+ const notes = ['Start a new dsh session to pick up the MCP server, after a restart if dsh-hmr is off.'];
+ if (patch.note) notes.unshift(patch.note);
+ return { files: [patch.file, upsertInstructionsEntry(instructionsPath())], notes };
+ }
+
+ uninstall(loc: Location): WriteResult {
+ if (loc !== 'global') return { files: [] };
+ const profiles = profilePatchPaths().map((file) => removeFromPatch(file, false));
+ const results = [removeFromPatch(homePatchPath(), true), ...profiles.filter((r) => r.file.action === 'removed' || r.note)];
+ const notes = results.flatMap((r) => (r.note ? [r.note] : []));
+ const files = [...results.map((r) => r.file), removeInstructionsEntry(instructionsPath())];
+ return notes.length > 0 ? { files, notes } : { files };
+ }
+
+ printConfig(loc: Location): string {
+ if (loc !== 'global') return '# DeepSeek Harness keeps its config in $DSH_HOME; use --location=global.\n';
+ return [`# Add to ${homePatchPath()}`, '', ...renderEntry(), ''].join('\n');
+ }
+
+ describePaths(loc: Location): string[] {
+ return loc === 'global' ? [homePatchPath(), instructionsPath()] : [];
+ }
+}
+
+export const dshTarget: AgentTarget = new DshTarget();
diff --git a/src/installer/targets/grok.ts b/src/installer/targets/grok.ts
new file mode 100644
index 000000000..0ab8d110a
--- /dev/null
+++ b/src/installer/targets/grok.ts
@@ -0,0 +1,98 @@
+/**
+ * Grok Build target (the `grok` CLI).
+ *
+ * - MCP server entry as the `[mcp_servers.codegraph]` table of `config.toml`:
+ * `$GROK_HOME` (default `~/.grok`) when global, `./.grok` when local.
+ * Written with the same helpers as Codex (`./toml.ts`), so a table in any
+ * other form or a parent that rules it out is never defined twice (#2562).
+ * - Instructions to `rules/codegraph.md` in the same folder. Grok loads every
+ * `*.md` in `$GROK_HOME/rules/` for all projects, and every `*.md` in a
+ * project's `.grok/rules/`.
+ *
+ * A table already there is the user's. `grok mcp disable` writes `enabled` into
+ * it, and users add `env` or timeouts, so a refresh leaves it alone.
+ *
+ * A project's config applies only once the folder is trusted, so a local
+ * install says so. `autoAllow` is a no-op: Grok's `[permission]` rules are the
+ * user's own.
+ *
+ * Docs: https://docs.x.ai/build/features/mcp-servers
+ * https://docs.x.ai/build/features/project-rules
+ * https://docs.x.ai/build/features/hooks (folder trust)
+ */
+
+import * as fs from 'fs';
+import * as os from 'os';
+import * as path from 'path';
+import { AgentTarget, DetectionResult, InstallOptions, Location, WriteResult } from './types';
+import {
+ configDirFromEnv,
+ getMcpServerConfig,
+ removeInstructionsEntry,
+ upsertInstructionsEntry,
+} from './shared';
+import { buildTomlTable, deleteTomlTable, tomlDefinesTable, writeTomlTable } from './toml';
+
+const TOML_HEADER = 'mcp_servers.codegraph';
+
+function configDir(loc: Location): string {
+ return loc === 'global'
+ ? configDirFromEnv('GROK_HOME', path.join(os.homedir(), '.grok'))
+ : path.join(process.cwd(), '.grok');
+}
+
+function tomlConfigPath(loc: Location): string {
+ return path.join(configDir(loc), 'config.toml');
+}
+
+function instructionsPath(loc: Location): string {
+ return path.join(configDir(loc), 'rules', 'codegraph.md');
+}
+
+function buildCodegraphBlock(): string {
+ const { command, args } = getMcpServerConfig();
+ return buildTomlTable(TOML_HEADER, { command, args });
+}
+
+class GrokTarget implements AgentTarget {
+ readonly id = 'grok' as const;
+ readonly displayName = 'Grok Build';
+ readonly docsUrl = 'https://docs.x.ai/build/features/mcp-servers';
+
+ supportsLocation(_loc: Location): boolean {
+ return true;
+ }
+
+ detect(loc: Location): DetectionResult {
+ const file = tomlConfigPath(loc);
+ let alreadyConfigured = false;
+ try {
+ alreadyConfigured = tomlDefinesTable(fs.readFileSync(file, 'utf-8'), TOML_HEADER);
+ } catch { /* no config yet */ }
+ return { installed: fs.existsSync(configDir(loc)), alreadyConfigured, configPath: file };
+ }
+
+ install(loc: Location, _opts: InstallOptions): WriteResult {
+ const mcp = writeTomlTable(tomlConfigPath(loc), TOML_HEADER, buildCodegraphBlock(), { keepExisting: true });
+ const notes = ['Start a new Grok session, or press r in /mcps, to pick up the MCP server.'];
+ if (mcp.note) notes.unshift(mcp.note);
+ if (loc === 'local') notes.push(`Grok applies ${tomlConfigPath('local')} only in a trusted folder: run /hooks-trust there, or start grok with --trust.`);
+ return { files: [mcp.file, upsertInstructionsEntry(instructionsPath(loc))], notes };
+ }
+
+ uninstall(loc: Location): WriteResult {
+ const mcp = deleteTomlTable(tomlConfigPath(loc), TOML_HEADER);
+ const files = [mcp.file, removeInstructionsEntry(instructionsPath(loc))];
+ return mcp.note ? { files, notes: [mcp.note] } : { files };
+ }
+
+ printConfig(loc: Location): string {
+ return `# Add to ${tomlConfigPath(loc)}\n\n${buildCodegraphBlock()}\n`;
+ }
+
+ describePaths(loc: Location): string[] {
+ return [tomlConfigPath(loc), instructionsPath(loc)];
+ }
+}
+
+export const grokTarget: AgentTarget = new GrokTarget();
diff --git a/src/installer/targets/hermes.ts b/src/installer/targets/hermes.ts
index cf32f2d7c..4a205fcd9 100644
--- a/src/installer/targets/hermes.ts
+++ b/src/installer/targets/hermes.ts
@@ -36,7 +36,7 @@ import {
Location,
WriteResult,
} from './types';
-import { atomicWriteFileSync } from './shared';
+import { atomicWriteFileSync, joinText, splitText } from './shared';
class HermesTarget implements AgentTarget {
readonly id = 'hermes' as const;
@@ -168,25 +168,6 @@ function writeHermesConfig(): { file: WriteResult['files'][number]; notes: strin
// Line-level YAML: just the shapes Hermes and its users write for these keys.
// ---------------------------------------------------------------------------
-interface Text {
- lines: string[];
- eol: '\n' | '\r\n';
-}
-
-function splitText(content: string): Text {
- const first = /\r\n|\r|\n/.exec(content);
- const lines = content.length === 0 ? [] : content.split(/\r\n|\r|\n/);
- if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop();
- return { lines, eol: first?.[0] === '\r\n' ? '\r\n' : '\n' };
-}
-
-/** The lines joined with the file's own line break, ending with one; no trailing blank lines. */
-function joinText(text: Text): string {
- const lines = [...text.lines];
- while (lines.length > 0 && (lines[lines.length - 1] ?? '').trim() === '') lines.pop();
- return lines.length === 0 ? '' : lines.join(text.eol) + text.eol;
-}
-
function indentOf(line: string): number {
return /^ */.exec(line)![0].length;
}
diff --git a/src/installer/targets/json-config.ts b/src/installer/targets/json-config.ts
new file mode 100644
index 000000000..c35b4ea00
--- /dev/null
+++ b/src/installer/targets/json-config.ts
@@ -0,0 +1,136 @@
+/**
+ * `mcpServers.codegraph` in an agent's JSON config, for the targets whose file
+ * has that shape.
+ *
+ * The file is edited in place with `jsonc-parser`, not rewritten: it keeps its
+ * indent and line breaks, and comments survive in the agents that allow them
+ * (Qwen Code, Qoder). A file that can't be parsed is backed up before it is
+ * replaced, as `writeJsonFile` does (#1870).
+ *
+ * An entry already in the file is the user's. Agents write into it themselves:
+ * a disabled switch, an `alwaysAllow` list, Pi's `exposure`. Users pin a
+ * `command` or a `cwd` there. So a re-install or the `install --refresh` that
+ * `codegraph upgrade` runs only fills in a key that is missing, and never
+ * changes one that is set.
+ */
+
+import * as fs from 'fs';
+import { applyEdits, modify, parse, type ParseError } from 'jsonc-parser';
+import type { WriteResult } from './types';
+import { atomicWriteFileSync, backupUnparseableJson, removeFileUnlessLinked } from './shared';
+
+type FileResult = WriteResult['files'][number];
+type Edit = (text: string) => string;
+
+const SERVER_PATH = ['mcpServers', 'codegraph'];
+const noEdit: Edit = (text) => text;
+
+function isObject(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
+}
+
+function parseConfig(text: string): { config: Record; valid: boolean } {
+ const body = text.replace(/^\uFEFF/, '');
+ if (body.trim() === '') return { config: {}, valid: true };
+ const errors: ParseError[] = [];
+ const value: unknown = parse(body, errors, { allowTrailingComma: true });
+ return isObject(value) && errors.length === 0 ? { config: value, valid: true } : { config: {}, valid: false };
+}
+
+/** The object in `text`, or `{}` when it holds none. */
+export function parseJsonConfig(text: string): Record {
+ return parseConfig(text).config;
+}
+
+/** `text` with the value at `jsonPath` set, or removed when `value` is undefined. Everything else stays. */
+export function editJson(
+ text: string,
+ jsonPath: Array,
+ value: unknown,
+ options: { isArrayInsertion?: boolean } = {},
+): string {
+ const eol = text.includes('\r\n') ? '\r\n' : '\n';
+ const indent = /^([ \t]+)\S/m.exec(text)?.[1] ?? ' ';
+ const insertSpaces = !indent.startsWith('\t');
+ return applyEdits(text, modify(text, jsonPath, value, {
+ ...options,
+ formattingOptions: { tabSize: insertSpaces ? indent.length : 1, insertSpaces, eol },
+ }));
+}
+
+/**
+ * Whether `file` defines CodeGraph's server. Read-only and quiet: `detect()`
+ * runs this for every agent on every install (#1870).
+ */
+export function hasCodegraphServer(file: string): boolean {
+ try {
+ return !!parseConfig(fs.readFileSync(file, 'utf-8')).config.mcpServers?.codegraph;
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Add CodeGraph's server to `file`, or the keys of `entry` an existing one is
+ * missing. `alsoEdit` runs on the same text, for a target with more to write in
+ * this file, so the file is written once and reported once.
+ */
+export function upsertCodegraphServer(
+ file: string,
+ entry: Record,
+ alsoEdit: Edit = noEdit,
+): FileResult {
+ const existed = fs.existsSync(file);
+ const before = existed ? fs.readFileSync(file, 'utf-8') : '';
+ const { config, valid } = parseConfig(before);
+ const servers: unknown = config.mcpServers;
+ if (servers !== undefined && !isObject(servers)) {
+ throw new Error(
+ `${file} sets mcpServers to something other than an object, so CodeGraph can't add its server. ` +
+ 'Nothing was changed: make mcpServers an object, then run this again.',
+ );
+ }
+ if (!valid) backupUnparseableJson(file);
+
+ let text = valid && before.trim() !== '' ? before : '{\n}\n';
+ const current: unknown = servers?.codegraph;
+ if (isObject(current)) {
+ for (const [key, value] of Object.entries(entry)) {
+ if (!(key in current)) text = editJson(text, [...SERVER_PATH, key], value);
+ }
+ } else {
+ text = editJson(text, SERVER_PATH, entry);
+ }
+ text = alsoEdit(text);
+
+ if (text === before) return { path: file, action: 'unchanged' };
+ atomicWriteFileSync(file, text);
+ return { path: file, action: existed ? 'updated' : 'created' };
+}
+
+/**
+ * Inverse of `upsertCodegraphServer`: take out CodeGraph's server, its
+ * `mcpServers` wrapper when that held nothing else, and whatever `alsoEdit`
+ * takes out. A file with nothing left in it is deleted, so uninstall reverses a
+ * from-scratch install. A symbolic link is the user's, so it is kept, with `{}`
+ * written through it (#2721).
+ */
+export function removeCodegraphServer(file: string, alsoEdit: Edit = noEdit): FileResult {
+ if (!fs.existsSync(file)) return { path: file, action: 'not-found' };
+ const before = fs.readFileSync(file, 'utf-8');
+ const { config, valid } = parseConfig(before);
+ if (!valid) return { path: file, action: 'not-found' };
+
+ let text = before;
+ const servers: unknown = config.mcpServers;
+ if (isObject(servers) && 'codegraph' in servers) {
+ text = editJson(text, SERVER_PATH, undefined);
+ if (Object.keys(servers).length === 1) text = editJson(text, ['mcpServers'], undefined);
+ }
+ text = alsoEdit(text);
+
+ if (text === before) return { path: file, action: 'not-found' };
+ if (/^\s*\{\s*\}\s*$/.test(text)) removeFileUnlessLinked(file, '{}\n');
+ else atomicWriteFileSync(file, text);
+ return { path: file, action: 'removed' };
+}
diff --git a/src/installer/targets/kimi.ts b/src/installer/targets/kimi.ts
new file mode 100644
index 000000000..f365b7183
--- /dev/null
+++ b/src/installer/targets/kimi.ts
@@ -0,0 +1,95 @@
+/**
+ * Kimi Code target.
+ *
+ * - MCP server entry under `mcpServers` in `mcp.json`: `$KIMI_CODE_HOME`
+ * (default `~/.kimi-code`) when global, `./.kimi-code` when local. Kimi
+ * reads an entry with `command` as a stdio server and documents no `type`
+ * key. A project-level server runs only once the folder is trusted, so a
+ * local install says so.
+ * - Instructions to `AGENTS.md`: in `$KIMI_CODE_HOME` when global, at the
+ * project root when local.
+ *
+ * `autoAllow` is a no-op: Kimi keeps its approval rules in its own
+ * `config.toml`, as `[[permission.rules]]`.
+ *
+ * Docs: https://moonshotai.github.io/kimi-code/en/customization/mcp.html
+ * https://moonshotai.github.io/kimi-code/en/configuration/data-locations.html
+ * https://moonshotai.github.io/kimi-code/en/customization/agents.html
+ */
+
+import * as fs from 'fs';
+import * as os from 'os';
+import * as path from 'path';
+import { AgentTarget, DetectionResult, InstallOptions, Location, WriteResult } from './types';
+import { hasCodegraphServer, removeCodegraphServer, upsertCodegraphServer } from './json-config';
+import {
+ configDirFromEnv,
+ getStdioServerConfig,
+ removeInstructionsEntry,
+ upsertInstructionsEntry,
+} from './shared';
+
+function configDir(loc: Location): string {
+ return loc === 'global'
+ ? configDirFromEnv('KIMI_CODE_HOME', path.join(os.homedir(), '.kimi-code'))
+ : path.join(process.cwd(), '.kimi-code');
+}
+
+function mcpJsonPath(loc: Location): string {
+ return path.join(configDir(loc), 'mcp.json');
+}
+
+function instructionsPath(loc: Location): string {
+ return loc === 'global' ? path.join(configDir('global'), 'AGENTS.md') : path.join(process.cwd(), 'AGENTS.md');
+}
+
+class KimiTarget implements AgentTarget {
+ readonly id = 'kimi' as const;
+ readonly displayName = 'Kimi Code';
+ readonly docsUrl = 'https://moonshotai.github.io/kimi-code/en/customization/mcp.html';
+
+ supportsLocation(_loc: Location): boolean {
+ return true;
+ }
+
+ detect(loc: Location): DetectionResult {
+ const file = mcpJsonPath(loc);
+ return {
+ installed: fs.existsSync(configDir(loc)),
+ alreadyConfigured: hasCodegraphServer(file),
+ configPath: file,
+ };
+ }
+
+ install(loc: Location, _opts: InstallOptions): WriteResult {
+ const notes = ['Start a new Kimi Code session: a server added mid-session only joins later ones.'];
+ if (loc === 'local') notes.push(`Kimi Code runs ${mcpJsonPath('local')} only in a trusted folder: trust this folder when Kimi Code asks.`);
+ return {
+ files: [
+ upsertCodegraphServer(mcpJsonPath(loc), getStdioServerConfig()),
+ upsertInstructionsEntry(instructionsPath(loc)),
+ ],
+ notes,
+ };
+ }
+
+ uninstall(loc: Location): WriteResult {
+ return {
+ files: [
+ removeCodegraphServer(mcpJsonPath(loc)),
+ removeInstructionsEntry(instructionsPath(loc)),
+ ],
+ };
+ }
+
+ printConfig(loc: Location): string {
+ const snippet = JSON.stringify({ mcpServers: { codegraph: getStdioServerConfig() } }, null, 2);
+ return `# Add to ${mcpJsonPath(loc)}\n\n${snippet}\n`;
+ }
+
+ describePaths(loc: Location): string[] {
+ return [mcpJsonPath(loc), instructionsPath(loc)];
+ }
+}
+
+export const kimiTarget: AgentTarget = new KimiTarget();
diff --git a/src/installer/targets/pi.ts b/src/installer/targets/pi.ts
new file mode 100644
index 000000000..3c8949e40
--- /dev/null
+++ b/src/installer/targets/pi.ts
@@ -0,0 +1,102 @@
+/**
+ * Pi (pi-coding-agent) target.
+ *
+ * - MCP server entry under `mcpServers` in `mcp.json`: the agent directory
+ * when global (`$PI_CODING_AGENT_DIR`, default `~/.pi/agent`), `./.pi` when
+ * local. Pi reads a project's file only after the project is trusted, so a
+ * local install says so.
+ * - Instructions to `AGENTS.md`: in the agent directory when global, at the
+ * project root when local. Context files need no project trust.
+ *
+ * The entry carries `exposure: "direct"`. Pi's default, `codemode`, makes a
+ * server's tools callable from scripts but never declares them to the model, so
+ * `codegraph_explore` would be a name the model can't call as a tool. CodeGraph
+ * lists one tool by default, which is what `direct` is for.
+ *
+ * `autoAllow` is a no-op: Pi has no allowlist file, and permission gates are
+ * extensions.
+ *
+ * Docs: https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/mcp.md
+ * https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/configuration.md
+ */
+
+import * as fs from 'fs';
+import * as os from 'os';
+import * as path from 'path';
+import { AgentTarget, DetectionResult, InstallOptions, Location, WriteResult } from './types';
+import { hasCodegraphServer, removeCodegraphServer, upsertCodegraphServer } from './json-config';
+import {
+ configDirFromEnv,
+ getStdioServerConfig,
+ removeInstructionsEntry,
+ upsertInstructionsEntry,
+} from './shared';
+
+function configDir(loc: Location): string {
+ return loc === 'global'
+ ? configDirFromEnv('PI_CODING_AGENT_DIR', path.join(os.homedir(), '.pi', 'agent'))
+ : path.join(process.cwd(), '.pi');
+}
+
+function mcpJsonPath(loc: Location): string {
+ return path.join(configDir(loc), 'mcp.json');
+}
+
+function instructionsPath(loc: Location): string {
+ return loc === 'global' ? path.join(configDir('global'), 'AGENTS.md') : path.join(process.cwd(), 'AGENTS.md');
+}
+
+function buildPiEntry(): { command: string; args: string[]; exposure: string } {
+ return { ...getStdioServerConfig(), exposure: 'direct' };
+}
+
+class PiTarget implements AgentTarget {
+ readonly id = 'pi' as const;
+ readonly displayName = 'Pi';
+ readonly docsUrl = 'https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/mcp.md';
+
+ supportsLocation(_loc: Location): boolean {
+ return true;
+ }
+
+ detect(loc: Location): DetectionResult {
+ const file = mcpJsonPath(loc);
+ return {
+ installed: fs.existsSync(configDir(loc)),
+ alreadyConfigured: hasCodegraphServer(file),
+ configPath: file,
+ };
+ }
+
+ install(loc: Location, _opts: InstallOptions): WriteResult {
+ const notes = ['Run /reload in Pi, or restart it, to pick up the MCP server.'];
+ if (loc === 'local') notes.push(`Pi reads ${mcpJsonPath('local')} only after you trust this project.`);
+ return {
+ files: [
+ upsertCodegraphServer(mcpJsonPath(loc), buildPiEntry()),
+ upsertInstructionsEntry(instructionsPath(loc)),
+ ],
+ notes,
+ };
+ }
+
+ uninstall(loc: Location): WriteResult {
+ return {
+ files: [
+ removeCodegraphServer(mcpJsonPath(loc)),
+ removeInstructionsEntry(instructionsPath(loc)),
+ ],
+ };
+ }
+
+ printConfig(loc: Location): string {
+ const snippet = JSON.stringify({ mcpServers: { codegraph: buildPiEntry() } }, null, 2);
+ return `# Add to ${mcpJsonPath(loc)}\n\n${snippet}\n`;
+ }
+
+ describePaths(loc: Location): string[] {
+ return [mcpJsonPath(loc), instructionsPath(loc)];
+ }
+}
+
+export const piTarget: AgentTarget = new PiTarget();
diff --git a/src/installer/targets/qoder.ts b/src/installer/targets/qoder.ts
new file mode 100644
index 000000000..7ab46467d
--- /dev/null
+++ b/src/installer/targets/qoder.ts
@@ -0,0 +1,77 @@
+/**
+ * Qoder CLI target. Writes the MCP server entry only, under `mcpServers` in:
+ *
+ * - global: `settings.json` in `$QODER_CONFIG_DIR` (default `~/.qoder`)
+ * - local: `./.qoder/settings.local.json`, the scope `qoder mcp add` writes
+ * by default. It stays on this machine, and Qoder loads it only once the
+ * folder is trusted.
+ *
+ * Qoder also reads a project `.mcp.json`, but that file is Claude Code's local
+ * config too, so an uninstall here would take the server away from Claude.
+ * Settings files may hold comments, and the edits keep them (`./json-config.ts`).
+ * The entry is `{ command, args }`; a server with no `type` is a stdio server.
+ *
+ * The Qoder IDE is not covered: its docs show an in-app editor and name no file
+ * on disk. No instructions file, and `autoAllow` is a no-op.
+ *
+ * Docs: https://docs.qoder.com/cli/mcp-reference
+ * https://docs.qoder.com/cli/mcp-servers
+ * https://docs.qoder.com/cli/settings
+ */
+
+import * as fs from 'fs';
+import * as os from 'os';
+import * as path from 'path';
+import { AgentTarget, DetectionResult, InstallOptions, Location, WriteResult } from './types';
+import { hasCodegraphServer, removeCodegraphServer, upsertCodegraphServer } from './json-config';
+import { configDirFromEnv, getStdioServerConfig } from './shared';
+
+function configDir(loc: Location): string {
+ return loc === 'global'
+ ? configDirFromEnv('QODER_CONFIG_DIR', path.join(os.homedir(), '.qoder'))
+ : path.join(process.cwd(), '.qoder');
+}
+
+function settingsPath(loc: Location): string {
+ return path.join(configDir(loc), loc === 'global' ? 'settings.json' : 'settings.local.json');
+}
+
+class QoderTarget implements AgentTarget {
+ readonly id = 'qoder' as const;
+ readonly displayName = 'Qoder CLI';
+ readonly docsUrl = 'https://docs.qoder.com/cli/mcp-reference';
+
+ supportsLocation(_loc: Location): boolean {
+ return true;
+ }
+
+ detect(loc: Location): DetectionResult {
+ const file = settingsPath(loc);
+ return {
+ installed: fs.existsSync(configDir(loc)),
+ alreadyConfigured: hasCodegraphServer(file),
+ configPath: file,
+ };
+ }
+
+ install(loc: Location, _opts: InstallOptions): WriteResult {
+ const notes = ['This sets up the Qoder CLI. In the Qoder IDE, add the server under Settings > MCP.'];
+ if (loc === 'local') notes.push(`Qoder loads ${settingsPath('local')} only in a trusted folder.`);
+ return { files: [upsertCodegraphServer(settingsPath(loc), getStdioServerConfig())], notes };
+ }
+
+ uninstall(loc: Location): WriteResult {
+ return { files: [removeCodegraphServer(settingsPath(loc))] };
+ }
+
+ printConfig(loc: Location): string {
+ const snippet = JSON.stringify({ mcpServers: { codegraph: getStdioServerConfig() } }, null, 2);
+ return `# Add to ${settingsPath(loc)}\n\n${snippet}\n`;
+ }
+
+ describePaths(loc: Location): string[] {
+ return [settingsPath(loc)];
+ }
+}
+
+export const qoderTarget: AgentTarget = new QoderTarget();
diff --git a/src/installer/targets/qwen.ts b/src/installer/targets/qwen.ts
new file mode 100644
index 000000000..5a3d543f1
--- /dev/null
+++ b/src/installer/targets/qwen.ts
@@ -0,0 +1,123 @@
+/**
+ * Qwen Code target.
+ *
+ * - MCP server entry under `mcpServers` in `settings.json`: `$QWEN_HOME`
+ * (default `~/.qwen`) when global, `./.qwen` when local. Qwen documents a
+ * stdio entry as `{ command, args }`.
+ * - Instructions to `QWEN.md`: in `$QWEN_HOME` when global, at the project
+ * root when local.
+ * - With `autoAllow`, `permissions.allow` gains `mcp__codegraph`, Qwen's rule
+ * for every tool of one server. It is not Claude's `mcp__codegraph__*`.
+ *
+ * Qwen reads `settings.json` with comments allowed and keeps them when it saves
+ * the file, so this target's edits keep them too (see `./json-config.ts`).
+ *
+ * Docs: https://qwenlm.github.io/qwen-code-docs/en/users/features/mcp/
+ * https://qwenlm.github.io/qwen-code-docs/en/users/configuration/settings/
+ * https://qwenlm.github.io/qwen-code-docs/en/users/features/memory/
+ */
+
+import * as fs from 'fs';
+import * as os from 'os';
+import * as path from 'path';
+import { AgentTarget, DetectionResult, InstallOptions, Location, WriteResult } from './types';
+import {
+ editJson,
+ hasCodegraphServer,
+ parseJsonConfig,
+ removeCodegraphServer,
+ upsertCodegraphServer,
+} from './json-config';
+import {
+ configDirFromEnv,
+ getStdioServerConfig,
+ removeInstructionsEntry,
+ upsertInstructionsEntry,
+} from './shared';
+
+const ALLOW_RULE = 'mcp__codegraph';
+
+function configDir(loc: Location): string {
+ return loc === 'global'
+ ? configDirFromEnv('QWEN_HOME', path.join(os.homedir(), '.qwen'))
+ : path.join(process.cwd(), '.qwen');
+}
+
+function settingsJsonPath(loc: Location): string {
+ return path.join(configDir(loc), 'settings.json');
+}
+
+function instructionsPath(loc: Location): string {
+ return loc === 'global' ? path.join(configDir('global'), 'QWEN.md') : path.join(process.cwd(), 'QWEN.md');
+}
+
+/** Adds the rule, unless `permissions` or its `allow` is something this can't add to. */
+function addAllowRule(text: string): string {
+ const { permissions } = parseJsonConfig(text);
+ if (permissions === undefined) return editJson(text, ['permissions', 'allow'], [ALLOW_RULE]);
+ if (permissions === null || typeof permissions !== 'object' || Array.isArray(permissions)) return text;
+ if (permissions.allow === undefined) return editJson(text, ['permissions', 'allow'], [ALLOW_RULE]);
+ if (!Array.isArray(permissions.allow) || permissions.allow.includes(ALLOW_RULE)) return text;
+ return editJson(text, ['permissions', 'allow', -1], ALLOW_RULE, { isArrayInsertion: true });
+}
+
+/** Takes the rule out, with an `allow` or `permissions` left empty by that. */
+function removeAllowRule(text: string): string {
+ const { permissions } = parseJsonConfig(text);
+ const at = Array.isArray(permissions?.allow) ? permissions.allow.indexOf(ALLOW_RULE) : -1;
+ if (at === -1) return text;
+ if (permissions.allow.length > 1) return editJson(text, ['permissions', 'allow', at], undefined);
+ const rest = editJson(text, ['permissions', 'allow'], undefined);
+ return Object.keys(permissions).length === 1 ? editJson(rest, ['permissions'], undefined) : rest;
+}
+
+class QwenTarget implements AgentTarget {
+ readonly id = 'qwen' as const;
+ readonly displayName = 'Qwen Code';
+ readonly docsUrl = 'https://qwenlm.github.io/qwen-code-docs/en/users/features/mcp/';
+
+ supportsLocation(_loc: Location): boolean {
+ return true;
+ }
+
+ detect(loc: Location): DetectionResult {
+ const file = settingsJsonPath(loc);
+ return {
+ installed: fs.existsSync(configDir(loc)),
+ alreadyConfigured: hasCodegraphServer(file),
+ configPath: file,
+ };
+ }
+
+ install(loc: Location, opts: InstallOptions): WriteResult {
+ const settings = upsertCodegraphServer(
+ settingsJsonPath(loc),
+ getStdioServerConfig(),
+ opts.autoAllow ? addAllowRule : undefined,
+ );
+ return {
+ files: [settings, upsertInstructionsEntry(instructionsPath(loc))],
+ notes: ['Restart any running Qwen Code session to pick up the MCP server.'],
+ };
+ }
+
+ uninstall(loc: Location): WriteResult {
+ return {
+ files: [
+ removeCodegraphServer(settingsJsonPath(loc), removeAllowRule),
+ removeInstructionsEntry(instructionsPath(loc)),
+ ],
+ };
+ }
+
+ printConfig(loc: Location): string {
+ const snippet = JSON.stringify({ mcpServers: { codegraph: getStdioServerConfig() } }, null, 2);
+ return `# Add to ${settingsJsonPath(loc)}\n\n${snippet}\n`;
+ }
+
+ describePaths(loc: Location): string[] {
+ return [settingsJsonPath(loc), instructionsPath(loc)];
+ }
+}
+
+export const qwenTarget: AgentTarget = new QwenTarget();
diff --git a/src/installer/targets/registry.ts b/src/installer/targets/registry.ts
index 3798b39ad..ae2273755 100644
--- a/src/installer/targets/registry.ts
+++ b/src/installer/targets/registry.ts
@@ -19,6 +19,15 @@ import { kiroTarget } from './kiro';
import { copilotVscodeTarget } from './copilot-vscode';
import { copilotCliTarget } from './copilot-cli';
import { copilotJetbrainsTarget } from './copilot-jetbrains';
+import { bobTarget } from './bob';
+import { droidTarget } from './droid';
+import { windsurfTarget } from './windsurf';
+import { qwenTarget } from './qwen';
+import { dshTarget } from './dsh';
+import { grokTarget } from './grok';
+import { kimiTarget } from './kimi';
+import { qoderTarget } from './qoder';
+import { piTarget } from './pi';
export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([
claudeTarget,
@@ -32,6 +41,15 @@ export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([
copilotVscodeTarget,
copilotCliTarget,
copilotJetbrainsTarget,
+ bobTarget,
+ droidTarget,
+ windsurfTarget,
+ qwenTarget,
+ dshTarget,
+ grokTarget,
+ kimiTarget,
+ qoderTarget,
+ piTarget,
]);
export function getTarget(id: string): AgentTarget | undefined {
diff --git a/src/installer/targets/shared.ts b/src/installer/targets/shared.ts
index 050ba068a..c8a665cc5 100644
--- a/src/installer/targets/shared.ts
+++ b/src/installer/targets/shared.ts
@@ -9,6 +9,7 @@
*/
import * as fs from 'fs';
+import * as os from 'os';
import * as path from 'path';
import {
CODEGRAPH_INSTRUCTIONS_BLOCK,
@@ -29,6 +30,29 @@ export function getMcpServerConfig(): { type: string; command: string; args: str
};
}
+/**
+ * The same server as a bare `{ command, args }`, for agents whose documented
+ * stdio entry has no `type` key.
+ */
+export function getStdioServerConfig(): { command: string; args: string[] } {
+ const { command, args } = getMcpServerConfig();
+ return { command, args };
+}
+
+/**
+ * The folder an agent's `*_HOME`-style variable names, or `fallback` when it
+ * is unset or blank. A leading `~` is the user's home and a relative path is
+ * taken from the working directory, so the installer writes where the agent
+ * reads. Installing to the fallback while the agent reads the override is a
+ * silent no-op: the files are written, and nothing ever looks at them (#1627).
+ */
+export function configDirFromEnv(name: string, fallback: string): string {
+ const value = process.env[name]?.trim();
+ if (!value) return fallback;
+ const inHome = value === '~' || value.startsWith('~/') || value.startsWith('~\\');
+ return path.resolve(inHome ? path.join(os.homedir(), value.slice(1)) : value);
+}
+
/**
* Permissions list for Claude `settings.json`. Other targets that
* have a permissions concept can compose this list directly.
@@ -184,7 +208,7 @@ export function removeFileUnlessLinked(filePath: string, emptied: string): void
* write is a replacement, not an edit, and without this the user's
* config would be gone. No-op when the file is absent or parses fine.
*/
-function backupUnparseableJson(filePath: string): void {
+export function backupUnparseableJson(filePath: string): void {
if (!fs.existsSync(filePath)) return;
try {
JSON.parse(fs.readFileSync(filePath, 'utf-8'));
@@ -238,6 +262,26 @@ export function jsonDeepEqual(a: unknown, b: unknown): boolean {
return ak.every((k) => jsonDeepEqual(ao[k], bo[k]));
}
+/** A text file as lines, without their line breaks, and the line break it uses. */
+export interface TextLines {
+ lines: string[];
+ eol: '\n' | '\r\n';
+}
+
+export function splitText(content: string): TextLines {
+ const first = /\r\n|\r|\n/.exec(content);
+ const lines = content.length === 0 ? [] : content.split(/\r\n|\r|\n/);
+ if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop();
+ return { lines, eol: first?.[0] === '\r\n' ? '\r\n' : '\n' };
+}
+
+/** The lines joined with the file's own line break, ending with one; no trailing blank lines. */
+export function joinText(text: TextLines): string {
+ const lines = [...text.lines];
+ while (lines.length > 0 && (lines[lines.length - 1] ?? '').trim() === '') lines.pop();
+ return lines.length === 0 ? '' : lines.join(text.eol) + text.eol;
+}
+
/** One marker-delimited block: `start` is the start marker's offset, `end` is just past the end marker. */
interface MarkedSection {
start: number;
@@ -413,6 +457,11 @@ export function upsertInstructionsEntry(file: string): { path: string; action: '
return { path: file, action: action === 'appended' ? 'updated' : action };
}
+/** Inverse of `upsertInstructionsEntry`: strip the CodeGraph block from `file`, if it has one. */
+export function removeInstructionsEntry(file: string): { path: string; action: 'removed' | 'not-found' | 'kept' } {
+ return { path: file, action: removeMarkedSection(file, CODEGRAPH_SECTION_START, CODEGRAPH_SECTION_END) };
+}
+
/**
* `content` with every well-formed marker block removed, or `null` when it
* has none. A stray start or end marker is left alone (see
diff --git a/src/installer/targets/toml.ts b/src/installer/targets/toml.ts
index 763fca0df..50a426f56 100644
--- a/src/installer/targets/toml.ts
+++ b/src/installer/targets/toml.ts
@@ -24,6 +24,10 @@
* inline table): those are reported as a conflict instead (#2562).
*/
+import * as fs from 'fs';
+import { atomicWriteFileSync, removeFileUnlessLinked } from './shared';
+import type { WriteResult } from './types';
+
/**
* Serialize a record into the body lines of a TOML table. Values
* supported: string, string[]. Other types throw — the codex MCP
@@ -172,6 +176,82 @@ export function tomlDefinesTable(fileContent: string, header: string): boolean {
return layout.tables.length > 0 || layout.subTables.length > 0 || layout.defined !== null;
}
+/** What a write or removal did to the file, and what the user should know about it. */
+interface TomlFileResult {
+ file: WriteResult['files'][number];
+ note?: string;
+}
+
+/**
+ * Write the `[header]` table `block` into `file`, creating the file if needed.
+ * With `keepExisting`, a `[header]` table already there is left as it is: it's
+ * the user's, however it differs from `block`.
+ *
+ * Never appends a second definition: a table defined twice makes the whole
+ * file invalid TOML, and the agent then loads none of it (#2562). A definition
+ * in another form is left as written, with a note. A parent that rules the
+ * table out throws, with nothing changed.
+ */
+export function writeTomlTable(
+ file: string,
+ header: string,
+ block: string,
+ options: { keepExisting?: boolean } = {},
+): TomlFileResult {
+ // Single read — `existing === ''` derives both "is the file empty or absent"
+ // and "what was its content," avoiding a TOCTOU window between two
+ // `fs.existsSync` calls.
+ const existing = fs.existsSync(file) ? fs.readFileSync(file, 'utf-8') : '';
+ const created = existing.length === 0;
+ const result = upsertTomlTable(existing, header, block);
+
+ if (result.action === 'conflict') {
+ const { conflict } = result;
+ if (conflict.kind === 'defined') {
+ // The user's own entry, in a form CodeGraph doesn't rewrite: it already
+ // configures the agent, so it is left exactly as written.
+ return {
+ file: { path: file, action: 'unchanged' },
+ note: `Left the ${header} entry in ${file} as you wrote it (${conflict.form}, line ${conflict.line}): CodeGraph only rewrites a [${header}] table.`,
+ };
+ }
+ throw new Error(
+ `${file} sets ${conflict.key} as ${conflict.form} (line ${conflict.line}), so a [${header}] table can't be added without breaking the file. ` +
+ `Nothing was changed: add CodeGraph's server to ${conflict.key} yourself, or rewrite it as [${conflict.key}.] tables, then run this again.`,
+ );
+ }
+ if (result.action === 'unchanged' || (result.action === 'replaced' && options.keepExisting)) {
+ return { file: { path: file, action: 'unchanged' } };
+ }
+ atomicWriteFileSync(file, result.content);
+ return { file: { path: file, action: created ? 'created' : 'updated' } };
+}
+
+/**
+ * Take every definition of the table out of `file`. A file left with nothing
+ * in it is deleted, or emptied through a symbolic link (#2721). A definition
+ * that can't be edited stays, with a note saying so.
+ */
+export function deleteTomlTable(file: string, header: string): TomlFileResult {
+ if (!fs.existsSync(file)) return { file: { path: file, action: 'not-found' } };
+ const content = fs.readFileSync(file, 'utf-8');
+ const result = removeTomlTable(content, header);
+ if (result.action === 'conflict') {
+ const { conflict } = result;
+ return {
+ file: { path: file, action: 'not-found' },
+ note: `${file} sets ${header} inside ${conflict.key} (${conflict.form}, line ${conflict.line}), which CodeGraph can't edit safely. Remove it from there yourself.`,
+ };
+ }
+ if (result.action === 'not-found') return { file: { path: file, action: 'not-found' } };
+ if (result.content.trim() === '') {
+ removeFileUnlessLinked(file, '');
+ } else {
+ atomicWriteFileSync(file, result.content.trimEnd() + tomlLineBreak(content));
+ }
+ return { file: { path: file, action: 'removed' } };
+}
+
/** The line break `content` uses (its first one); `\n` when it has none. */
export function tomlLineBreak(content: string): '\n' | '\r\n' {
const nl = content.indexOf('\n');
diff --git a/src/installer/targets/types.ts b/src/installer/targets/types.ts
index 746f96473..5d20233fa 100644
--- a/src/installer/targets/types.ts
+++ b/src/installer/targets/types.ts
@@ -19,7 +19,7 @@ export type Location = 'global' | 'local';
* lookup. New targets add a value here when they're added to the
* registry. Keep these short and lowercase.
*/
-export type TargetId = 'claude' | 'cursor' | 'codex' | 'opencode' | 'hermes' | 'gemini' | 'antigravity' | 'kiro' | 'copilot-vscode' | 'copilot-cli' | 'copilot-jetbrains';
+export type TargetId = 'claude' | 'cursor' | 'codex' | 'opencode' | 'hermes' | 'gemini' | 'antigravity' | 'kiro' | 'copilot-vscode' | 'copilot-cli' | 'copilot-jetbrains' | 'bob' | 'droid' | 'windsurf' | 'qwen' | 'dsh' | 'grok' | 'kimi' | 'qoder' | 'pi';
/**
* Result of `target.detect(location)`.
diff --git a/src/installer/targets/windsurf.ts b/src/installer/targets/windsurf.ts
new file mode 100644
index 000000000..f8cd1362c
--- /dev/null
+++ b/src/installer/targets/windsurf.ts
@@ -0,0 +1,81 @@
+/**
+ * Windsurf target, global only: MCP server entry under `mcpServers` in
+ * `~/.codeium/windsurf/mcp_config.json`.
+ *
+ * Windsurf became Devin Desktop in June 2026. That file is the stable channel's
+ * MCP config, and Devin imports it: `read_config_from.windsurf` is on unless the
+ * user turns it off. Devin's own `~/.config/devin/mcp_config.json` belongs to
+ * a Devin target, not this one. Windsurf documents no project-local MCP file, so
+ * `supportsLocation('local')` is false.
+ *
+ * The entry is `{ command, args }`, with no `type` key, as in Windsurf's docs.
+ * No instructions file: the only per-user rules file it documented,
+ * `global_rules.md`, belonged to Cascade, which Devin Desktop removed in
+ * September 2026. `autoAllow` is a no-op: tools are approved in the app.
+ *
+ * Docs: https://docs.devin.ai/cli/reference/configuration/read-config-from
+ * https://docs.devin.ai/windsurf/plugins/cascade/mcp
+ * https://docs.devin.ai/desktop/devin-desktop-faq
+ */
+
+import * as fs from 'fs';
+import * as os from 'os';
+import * as path from 'path';
+import { AgentTarget, DetectionResult, InstallOptions, Location, WriteResult } from './types';
+import { hasCodegraphServer, removeCodegraphServer, upsertCodegraphServer } from './json-config';
+import { getStdioServerConfig } from './shared';
+
+function configDir(): string {
+ return path.join(os.homedir(), '.codeium', 'windsurf');
+}
+
+function mcpConfigPath(): string {
+ return path.join(configDir(), 'mcp_config.json');
+}
+
+class WindsurfTarget implements AgentTarget {
+ readonly id = 'windsurf' as const;
+ readonly displayName = 'Windsurf';
+ readonly docsUrl = 'https://docs.windsurf.com/windsurf/cascade/mcp';
+
+ supportsLocation(loc: Location): boolean {
+ return loc === 'global';
+ }
+
+ detect(loc: Location): DetectionResult {
+ if (loc !== 'global') return { installed: false, alreadyConfigured: false };
+ const file = mcpConfigPath();
+ return {
+ installed: fs.existsSync(configDir()),
+ alreadyConfigured: hasCodegraphServer(file),
+ configPath: file,
+ };
+ }
+
+ install(loc: Location, _opts: InstallOptions): WriteResult {
+ if (loc !== 'global') {
+ return { files: [], notes: ['Windsurf has no project-local MCP config — re-run with --location=global.'] };
+ }
+ return {
+ files: [upsertCodegraphServer(mcpConfigPath(), getStdioServerConfig())],
+ notes: ['Restart Windsurf, or refresh its MCP list, to load CodeGraph.'],
+ };
+ }
+
+ uninstall(loc: Location): WriteResult {
+ if (loc !== 'global') return { files: [] };
+ return { files: [removeCodegraphServer(mcpConfigPath())] };
+ }
+
+ printConfig(loc: Location): string {
+ if (loc !== 'global') return '# Windsurf has no project-local MCP config — use --location=global.\n';
+ const snippet = JSON.stringify({ mcpServers: { codegraph: getStdioServerConfig() } }, null, 2);
+ return `# Add to ${mcpConfigPath()}\n\n${snippet}\n`;
+ }
+
+ describePaths(loc: Location): string[] {
+ return loc === 'global' ? [mcpConfigPath()] : [];
+ }
+}
+
+export const windsurfTarget: AgentTarget = new WindsurfTarget();