Skip to content

Commit c5165e4

Browse files
andreinknvclaude
andcommitted
feat(installer): multi-target — Claude Code, Cursor, Codex CLI, opencode
Closes the Claude-locked installer behind upstream issue colbymchenry#137 ("Codex / Cursor support — needs global support for the agent whatever the agent is"). The runtime MCP server was already agent-agnostic (stdio); only the installer was locked. After this refactor, `codegraph install` can write per-agent MCP config + instructions for any combination of supported agents. ## What ships Four agent targets, each implementing the new `AgentTarget` interface: - **Claude Code** — `~/.claude.json`, `~/.claude/settings.json`, `~/.claude/CLAUDE.md` (or local equivalents). Behaviour preserved from the original installer; existing installs upgrade in place. - **Cursor** — `~/.cursor/mcp.json` (g) or `./.cursor/mcp.json` (l) + project-local `./.cursor/rules/codegraph.mdc` with the always-on frontmatter Cursor expects. No global rules surface exists yet. - **Codex CLI** — `~/.codex/config.toml` with the dotted-key table `[mcp_servers.codegraph]` + `~/.codex/AGENTS.md`. Global only — Codex has no project-local config concept as of 2026-05. TOML is hand-rolled in `src/installer/targets/toml.ts` (no dependency added — the surface is one table block). - **opencode** — `~/.config/opencode/opencode.json` (XDG-aware) or `./opencode.json`. Different config shape (`mcp.<name>` instead of `mcpServers`), command + args as a single string array. Adding a 5th agent (Continue, Zed, Windsurf, ...) is now a single new file in `src/installer/targets/` plus one entry in `registry.ts`. No other changes needed. ## CLI changes `codegraph install` is no longer Claude-only: ``` codegraph install # interactive multi-select codegraph install --yes # auto-detect, install global codegraph install --target=cursor,claude --yes # explicit list codegraph install --target=auto --location=local # detected, project-local codegraph install --target=none # skip agent writes entirely codegraph install --print-config codex # dump snippet, no writes ``` | Flag | Values | Default | |---|---|---| | `--target` | `auto`, `all`, `none`, csv | prompt | | `--location` | `global`, `local` | prompt | | `--yes` | (boolean) | prompt | | `--no-permissions` | (boolean) — Claude only | permissions on | | `--print-config <id>` | dump snippet for one agent | — | Bare `codegraph install` (TTY) still works — interactive prompt now includes a multi-select for agents, defaulting to detected. Bare behavior with nothing detected = `['claude']` so existing users see no surprise. ## Architecture ``` src/installer/ ├── instructions-template.ts # neutral name; legacy claude-md-template re-exports ├── targets/ │ ├── types.ts # AgentTarget, Location, WriteResult │ ├── shared.ts # readJsonFile/writeJsonFile/atomicWrite/markers │ ├── toml.ts # narrow [mcp_servers.codegraph] serializer │ ├── claude.ts # extracted from old config-writer.ts │ ├── cursor.ts │ ├── codex.ts │ ├── opencode.ts │ └── registry.ts # ALL_TARGETS, getTarget, detectAll, resolveTargetFlag ├── index.ts # runInstallerWithOptions(opts) orchestrator ├── config-writer.ts # @deprecated shim — re-exports per-file helpers └── claude-md-template.ts # @deprecated shim — re-exports the template module ``` Backwards compat: every export from the old `config-writer.ts` (`writeMcpConfig`, `writePermissions`, `writeClaudeMd`, `hasMcpConfig`, `hasPermissions`, `hasClaudeMdSection`) is preserved as an `@deprecated` shim that delegates to the per-file helpers in `targets/claude.ts`. Each shim writes ONLY the named file (no silent multi-file side effects). ## Reviewer-driven fixes (caught + landed in this commit) - **`--no-permissions` defaulting bug**: commander's negate-form leaves `opts.permissions = true` by default, so the original `autoAllow: opts.permissions` always passed a boolean and silently bypassed the interactive prompt. Now mapped explicitly: `opts.permissions === false ? false : opts.yes ? true : undefined`. - **Shim semantic drift**: refactored to expose `writeMcpEntry / writePermissionsEntry / writeInstructionsEntry` from `targets/claude.ts` so each `config-writer.ts` shim calls only the per-file helper it names. - **Codex TOCTOU**: `writeMcpEntry` was calling `fs.existsSync(file)` twice across a `readFileSync`. Now uses `existing.length === 0` derived from the single read. ## Tests (+47, suite 1117 -> 1164) - `__tests__/installer-targets.test.ts` (47 tests) - Parameterized contract test across all 4 targets x supported locations: install writes files, second install is byte-identical (all-`unchanged`), pre-existing sibling MCP servers preserved, uninstall reverses install, printConfig writes nothing. - Partial-state idempotency for Codex: AGENTS.md wiped after first install -> second pass restores only the missing file, third pass is fully unchanged. - User-added key inside `[mcp_servers.codegraph]` overwritten on re-install (locks in the "we own the codegraph block exclusively" contract). - Standalone TOML serializer tests: empty insert, idempotent re-insert, replace-in-place preserving siblings, removal preserving siblings, `[[array_of_tables]]` preserved. - Registry: getTarget by id, resolveTargetFlag handling auto/all/none/csv, unknown-id rejection. - `__tests__/installer.test.ts`: one assertion relaxed (the new code correctly returns `unchanged` for byte-identical re-runs instead of `updated`); the test's actual contract — surrounding custom content preserved — is unchanged. `os.homedir` is non-configurable so the test mocks home via `process.env.HOME` / `process.env.USERPROFILE` (matches what `os.homedir()` reads internally). ## What this does NOT do - **Continue, Zed, Windsurf** — deliberately deferred. Each is a new file in `targets/`; the next person to want one writes one. - **Per-target instructions formats beyond Cursor's `.mdc`** — Codex/Claude both use the marker-delimited markdown body unchanged. opencode has no built-in instructions surface, so nothing is written for it. - **Migration of existing global Claude installs** — the on-disk layout is byte-identical to what the original installer wrote, so no migration needed. Detection sees existing installs as `alreadyConfigured: true` and re-running is a no-op. ## Uninstall behavior change (worth a release note) `bin/uninstall.ts` now loops `ALL_TARGETS.uninstall('global')` on `npm uninstall -g`. Previously only Claude paths were cleaned. A user who manually configured `~/.codex/config.toml` with our block will have it silently removed on package uninstall — acceptable trade-off since we only target the dotted-key table we own, not the whole file. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 56569cb commit c5165e4

18 files changed

Lines changed: 2079 additions & 527 deletions

README.md

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -121,14 +121,31 @@ npx @colbymchenry/codegraph
121121

122122
The installer will:
123123
- Prompt to install `codegraph` globally (needed for the MCP server)
124-
- Configure the MCP server in `~/.claude.json`
125-
- Set up auto-allow permissions for CodeGraph tools
126-
- Add global instructions to `~/.claude/CLAUDE.md`
124+
- Ask which agent(s) to configure — auto-detects installed ones from: **Claude Code**, **Cursor**, **Codex CLI**, **opencode**
125+
- Write each chosen agent's MCP server config + an instructions file (e.g. `CLAUDE.md`, `.cursor/rules/codegraph.mdc`, `~/.codex/AGENTS.md`)
126+
- Set up auto-allow permissions for the chosen agent (Claude Code only)
127127
- Optionally initialize your current project
128128

129-
### 2. Restart Claude Code
129+
**Non-interactive (scripting / CI):**
130130

131-
Restart Claude Code for the MCP server to load.
131+
```bash
132+
codegraph install --yes # auto-detect agents, install global
133+
codegraph install --target=cursor,claude --yes # explicit target list
134+
codegraph install --target=auto --location=local # detected agents, project-local
135+
codegraph install --print-config codex # print snippet, no file writes
136+
```
137+
138+
| Flag | Values | Default |
139+
|---|---|---|
140+
| `--target` | `auto`, `all`, `none`, or csv (`claude,cursor,...`) | prompt |
141+
| `--location` | `global`, `local` | prompt |
142+
| `--yes` | (boolean) | prompt every step |
143+
| `--no-permissions` | (boolean) skip Claude auto-allow list | permissions on |
144+
| `--print-config <id>` | dump snippet for one agent and exit ||
145+
146+
### 2. Restart Your Agent
147+
148+
Restart your agent (Claude Code / Cursor / Codex CLI / opencode) for the MCP server to load.
132149

133150
### 3. Initialize Projects
134151

Lines changed: 332 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,332 @@
1+
/**
2+
* Multi-target installer tests.
3+
*
4+
* Each `AgentTarget` is exercised against the same contract:
5+
* - `install` writes the expected files
6+
* - re-running `install` is byte-identical (idempotent)
7+
* - sibling MCP servers / unrelated config is preserved
8+
* - `uninstall` reverses `install`
9+
* - `printConfig` returns parseable, non-empty content
10+
*
11+
* For agent-config destinations we redirect HOME to a tmpdir via
12+
* `os.homedir` spying, and CWD via `process.chdir` — same pattern as
13+
* the legacy `installer.test.ts`. No real `~/.claude/` etc. ever
14+
* touched.
15+
*/
16+
17+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
18+
import * as fs from 'fs';
19+
import * as path from 'path';
20+
import * as os from 'os';
21+
import { ALL_TARGETS, getTarget, resolveTargetFlag } from '../src/installer/targets/registry';
22+
import { upsertTomlTable, removeTomlTable, buildTomlTable } from '../src/installer/targets/toml';
23+
24+
function mkTmpDir(label: string): string {
25+
return fs.mkdtempSync(path.join(os.tmpdir(), `cg-targets-${label}-`));
26+
}
27+
28+
// `os.homedir` is non-configurable on Node, so we redirect it via the
29+
// `$HOME` (POSIX) / `$USERPROFILE` (Windows) env vars that
30+
// `os.homedir()` reads first. Same trick the rest of the suite uses
31+
// when it needs a mock home.
32+
function setHome(dir: string): { restore: () => void } {
33+
const prev = { HOME: process.env.HOME, USERPROFILE: process.env.USERPROFILE };
34+
process.env.HOME = dir;
35+
process.env.USERPROFILE = dir;
36+
return {
37+
restore() {
38+
if (prev.HOME === undefined) delete process.env.HOME; else process.env.HOME = prev.HOME;
39+
if (prev.USERPROFILE === undefined) delete process.env.USERPROFILE; else process.env.USERPROFILE = prev.USERPROFILE;
40+
},
41+
};
42+
}
43+
44+
describe('Installer targets — contract', () => {
45+
let tmpHome: string;
46+
let tmpCwd: string;
47+
let origCwd: string;
48+
let homeRestore: { restore: () => void };
49+
50+
beforeEach(() => {
51+
tmpHome = mkTmpDir('home');
52+
tmpCwd = mkTmpDir('cwd');
53+
origCwd = process.cwd();
54+
process.chdir(tmpCwd);
55+
homeRestore = setHome(tmpHome);
56+
});
57+
58+
afterEach(() => {
59+
homeRestore.restore();
60+
process.chdir(origCwd);
61+
fs.rmSync(tmpHome, { recursive: true, force: true });
62+
fs.rmSync(tmpCwd, { recursive: true, force: true });
63+
});
64+
65+
for (const target of ALL_TARGETS) {
66+
describe(target.id, () => {
67+
const supportedLocations = (['global', 'local'] as const).filter((l) =>
68+
target.supportsLocation(l),
69+
);
70+
71+
for (const location of supportedLocations) {
72+
describe(`location=${location}`, () => {
73+
it('install writes files; detect.alreadyConfigured becomes true', () => {
74+
expect(target.detect(location).alreadyConfigured).toBe(false);
75+
76+
const result = target.install(location, { autoAllow: true });
77+
expect(result.files.length).toBeGreaterThan(0);
78+
for (const file of result.files) {
79+
if (file.action !== 'unchanged') {
80+
expect(fs.existsSync(file.path)).toBe(true);
81+
}
82+
}
83+
84+
expect(target.detect(location).alreadyConfigured).toBe(true);
85+
});
86+
87+
it('re-running install is idempotent (no actions other than unchanged)', () => {
88+
target.install(location, { autoAllow: true });
89+
const second = target.install(location, { autoAllow: true });
90+
for (const file of second.files) {
91+
expect(file.action).toBe('unchanged');
92+
}
93+
});
94+
95+
it('install preserves a pre-existing sibling MCP server (where applicable)', () => {
96+
// Plant a sibling entry in the same JSON config, install,
97+
// and verify the sibling survives. Skip for Codex (TOML)
98+
// and any target with no JSON config — they get covered
99+
// by their own dedicated tests below.
100+
const paths = target.describePaths(location);
101+
const jsonPath = paths.find((p) => p.endsWith('.json'));
102+
if (!jsonPath) return;
103+
104+
// Seed pre-existing config.
105+
fs.mkdirSync(path.dirname(jsonPath), { recursive: true });
106+
const seed: Record<string, any> = { mcpServers: { other: { command: 'x' } } };
107+
// opencode uses `mcp` not `mcpServers`. Match its shape too.
108+
if (target.id === 'opencode') {
109+
delete seed.mcpServers;
110+
seed.mcp = { other: { type: 'local', command: ['x'], enabled: true } };
111+
}
112+
fs.writeFileSync(jsonPath, JSON.stringify(seed, null, 2) + '\n');
113+
114+
target.install(location, { autoAllow: true });
115+
116+
const after = JSON.parse(fs.readFileSync(jsonPath, 'utf-8'));
117+
if (target.id === 'opencode') {
118+
expect(after.mcp.other).toBeDefined();
119+
expect(after.mcp.codegraph).toBeDefined();
120+
} else {
121+
expect(after.mcpServers.other).toBeDefined();
122+
expect(after.mcpServers.codegraph).toBeDefined();
123+
}
124+
});
125+
126+
it('uninstall reverses install (alreadyConfigured returns to false)', () => {
127+
target.install(location, { autoAllow: true });
128+
expect(target.detect(location).alreadyConfigured).toBe(true);
129+
130+
target.uninstall(location);
131+
expect(target.detect(location).alreadyConfigured).toBe(false);
132+
});
133+
134+
it('printConfig returns non-empty output without writing anything', () => {
135+
const before = listAllFiles(tmpHome).concat(listAllFiles(tmpCwd));
136+
const out = target.printConfig(location);
137+
expect(out.length).toBeGreaterThan(0);
138+
const after = listAllFiles(tmpHome).concat(listAllFiles(tmpCwd));
139+
expect(after.sort()).toEqual(before.sort());
140+
});
141+
});
142+
}
143+
});
144+
}
145+
});
146+
147+
describe('Installer targets — partial-state idempotency', () => {
148+
let tmpHome: string;
149+
let tmpCwd: string;
150+
let origCwd: string;
151+
let homeRestore: { restore: () => void };
152+
153+
beforeEach(() => {
154+
tmpHome = mkTmpDir('home');
155+
tmpCwd = mkTmpDir('cwd');
156+
origCwd = process.cwd();
157+
process.chdir(tmpCwd);
158+
homeRestore = setHome(tmpHome);
159+
});
160+
161+
afterEach(() => {
162+
homeRestore.restore();
163+
process.chdir(origCwd);
164+
fs.rmSync(tmpHome, { recursive: true, force: true });
165+
fs.rmSync(tmpCwd, { recursive: true, force: true });
166+
});
167+
168+
it('codex: install after only config.toml exists — second pass is fully unchanged', () => {
169+
const codex = getTarget('codex')!;
170+
// First install creates both files.
171+
codex.install('global', { autoAllow: false });
172+
// Delete the AGENTS.md to simulate partial state (user wiped one file).
173+
const agentsMd = path.join(tmpHome, '.codex', 'AGENTS.md');
174+
expect(fs.existsSync(agentsMd)).toBe(true);
175+
fs.unlinkSync(agentsMd);
176+
// Reinstall — TOML stays unchanged, AGENTS.md is recreated.
177+
const second = codex.install('global', { autoAllow: false });
178+
const tomlEntry = second.files.find((f) => f.path.endsWith('config.toml'))!;
179+
const mdEntry = second.files.find((f) => f.path.endsWith('AGENTS.md'))!;
180+
expect(tomlEntry.action).toBe('unchanged');
181+
expect(mdEntry.action).toBe('created');
182+
// Third install — both unchanged (full idempotency restored).
183+
const third = codex.install('global', { autoAllow: false });
184+
for (const f of third.files) expect(f.action).toBe('unchanged');
185+
});
186+
187+
it('codex: user-added key inside [mcp_servers.codegraph] survives idempotent re-install', () => {
188+
const codex = getTarget('codex')!;
189+
codex.install('global', { autoAllow: false });
190+
const tomlPath = path.join(tmpHome, '.codex', 'config.toml');
191+
const original = fs.readFileSync(tomlPath, 'utf-8');
192+
// User edits the block to add a custom key.
193+
const edited = original.replace(
194+
'args = ["serve", "--mcp"]',
195+
'args = ["serve", "--mcp"]\nenabled = true',
196+
);
197+
fs.writeFileSync(tomlPath, edited);
198+
// Re-install: our serializer doesn't know `enabled = true`, so
199+
// the block no longer matches the canonical form — we'll
200+
// overwrite it. This is the documented contract: we own the
201+
// codegraph block exclusively.
202+
const second = codex.install('global', { autoAllow: false });
203+
const tomlEntry = second.files.find((f) => f.path.endsWith('config.toml'))!;
204+
expect(tomlEntry.action).toBe('updated');
205+
const after = fs.readFileSync(tomlPath, 'utf-8');
206+
expect(after).not.toContain('enabled = true');
207+
});
208+
});
209+
210+
describe('Installer targets — registry', () => {
211+
it('getTarget returns the right target for each id', () => {
212+
expect(getTarget('claude')?.id).toBe('claude');
213+
expect(getTarget('cursor')?.id).toBe('cursor');
214+
expect(getTarget('codex')?.id).toBe('codex');
215+
expect(getTarget('opencode')?.id).toBe('opencode');
216+
expect(getTarget('not-a-real-target')).toBeUndefined();
217+
});
218+
219+
it('resolveTargetFlag handles auto/all/none/csv', () => {
220+
expect(resolveTargetFlag('none', 'global')).toEqual([]);
221+
expect(resolveTargetFlag('all', 'global').length).toBe(ALL_TARGETS.length);
222+
const csv = resolveTargetFlag('claude,cursor', 'global');
223+
expect(csv.map((t) => t.id)).toEqual(['claude', 'cursor']);
224+
});
225+
226+
it('resolveTargetFlag throws on unknown id', () => {
227+
expect(() => resolveTargetFlag('claude,bogus', 'global')).toThrow(/Unknown --target/);
228+
});
229+
});
230+
231+
describe('Installer targets — TOML serializer (Codex backbone)', () => {
232+
it('builds a [mcp_servers.codegraph] block with command + args', () => {
233+
const block = buildTomlTable('mcp_servers.codegraph', {
234+
command: 'codegraph',
235+
args: ['serve', '--mcp'],
236+
});
237+
expect(block).toContain('[mcp_servers.codegraph]');
238+
expect(block).toContain('command = "codegraph"');
239+
expect(block).toContain('args = ["serve", "--mcp"]');
240+
});
241+
242+
it('upsert inserts into empty content', () => {
243+
const block = buildTomlTable('mcp_servers.codegraph', { command: 'codegraph', args: ['serve'] });
244+
const { content, action } = upsertTomlTable('', 'mcp_servers.codegraph', block);
245+
expect(action).toBe('inserted');
246+
expect(content.startsWith('[mcp_servers.codegraph]')).toBe(true);
247+
});
248+
249+
it('upsert is idempotent — second call returns unchanged', () => {
250+
const block = buildTomlTable('mcp_servers.codegraph', { command: 'codegraph', args: ['serve'] });
251+
const first = upsertTomlTable('', 'mcp_servers.codegraph', block);
252+
const second = upsertTomlTable(first.content, 'mcp_servers.codegraph', block);
253+
expect(second.action).toBe('unchanged');
254+
expect(second.content).toBe(first.content);
255+
});
256+
257+
it('upsert replaces an existing block in place, preserving sibling tables', () => {
258+
const existing = [
259+
'[other_table]',
260+
'foo = "bar"',
261+
'',
262+
'[mcp_servers.codegraph]',
263+
'command = "old-codegraph"',
264+
'args = ["old"]',
265+
'',
266+
'[zzz]',
267+
'baz = "qux"',
268+
'',
269+
].join('\n');
270+
const newBlock = buildTomlTable('mcp_servers.codegraph', {
271+
command: 'codegraph',
272+
args: ['serve', '--mcp'],
273+
});
274+
const { content, action } = upsertTomlTable(existing, 'mcp_servers.codegraph', newBlock);
275+
expect(action).toBe('replaced');
276+
expect(content).toContain('[other_table]');
277+
expect(content).toContain('foo = "bar"');
278+
expect(content).toContain('[zzz]');
279+
expect(content).toContain('baz = "qux"');
280+
expect(content).toContain('command = "codegraph"');
281+
expect(content).not.toContain('old-codegraph');
282+
});
283+
284+
it('removeTomlTable strips the block and preserves siblings', () => {
285+
const existing = [
286+
'[other_table]',
287+
'foo = "bar"',
288+
'',
289+
'[mcp_servers.codegraph]',
290+
'command = "codegraph"',
291+
'args = ["serve"]',
292+
].join('\n');
293+
const { content, action } = removeTomlTable(existing, 'mcp_servers.codegraph');
294+
expect(action).toBe('removed');
295+
expect(content).toContain('[other_table]');
296+
expect(content).toContain('foo = "bar"');
297+
expect(content).not.toContain('mcp_servers.codegraph');
298+
});
299+
300+
it('removeTomlTable on missing table returns not-found, no content change', () => {
301+
const existing = '[other]\nfoo = "bar"\n';
302+
const { content, action } = removeTomlTable(existing, 'mcp_servers.codegraph');
303+
expect(action).toBe('not-found');
304+
expect(content).toBe(existing);
305+
});
306+
307+
it('upsert preserves an array-of-tables sibling [[foo]]', () => {
308+
const existing = [
309+
'[[foo]]',
310+
'name = "a"',
311+
'',
312+
'[[foo]]',
313+
'name = "b"',
314+
'',
315+
].join('\n');
316+
const block = buildTomlTable('mcp_servers.codegraph', { command: 'codegraph', args: ['serve'] });
317+
const { content } = upsertTomlTable(existing, 'mcp_servers.codegraph', block);
318+
expect(content.match(/\[\[foo\]\]/g)?.length).toBe(2);
319+
expect(content).toContain('[mcp_servers.codegraph]');
320+
});
321+
});
322+
323+
function listAllFiles(dir: string): string[] {
324+
if (!fs.existsSync(dir)) return [];
325+
const out: string[] = [];
326+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
327+
const full = path.join(dir, entry.name);
328+
if (entry.isDirectory()) out.push(...listAllFiles(full));
329+
else out.push(full);
330+
}
331+
return out;
332+
}

__tests__/installer.test.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -125,9 +125,10 @@ describe('Installer Config Writer', () => {
125125
const modified = '## My Custom Section\n\nCustom content\n\n' + original + '\n\n## Another Section\n\nMore content\n';
126126
fs.writeFileSync(claudeMdPath, modified);
127127

128-
// Second write should replace only the marked section
129-
const result = writeClaudeMd('local');
130-
expect(result.updated).toBe(true);
128+
// Second write should leave the marked block as-is (byte-identical
129+
// body, so result is `created:false, updated:false` — both flags
130+
// are off but the surrounding custom content must survive).
131+
writeClaudeMd('local');
131132

132133
const final = fs.readFileSync(claudeMdPath, 'utf-8');
133134
expect(final).toContain('## My Custom Section');

0 commit comments

Comments
 (0)