diff --git a/README.md b/README.md
index f8f39e9..caffccd 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
# CodeGraph
-### Supercharge Claude Code with Semantic Code Intelligence
+### Supercharge Claude Code, Cursor & Codex with Semantic Code Intelligence
**94% fewer tool calls · 77% faster exploration · 100% local**
@@ -14,6 +14,11 @@
[](#)
[](#)
+[](#)
+[](#)
+[](#)
+[](#)
+
### Get Started
@@ -22,7 +27,7 @@
npx @colbymchenry/codegraph
```
-Interactive installer configures Claude Code automatically
+Interactive installer auto-configures your agent(s) — Claude Code, Cursor, Codex CLI, opencode
#### Initialize Projects
@@ -149,15 +154,33 @@ npx @colbymchenry/codegraph
```
The installer will:
-- Prompt to install `codegraph` globally (needed for the MCP server)
-- Configure the MCP server in `~/.claude.json`
-- Set up auto-allow permissions for CodeGraph tools
-- Add global instructions to `~/.claude/CLAUDE.md`
-- Optionally initialize your current project
+- Ask which agent(s) to configure — auto-detects installed ones from: **Claude Code**, **Cursor**, **Codex CLI**, **opencode**
+- Prompt to install `codegraph` on your PATH (so agents can launch the MCP server)
+- Ask whether configs apply to all your projects or just this one
+- Write each chosen agent's MCP server config + an instructions file (e.g. `CLAUDE.md`, `.cursor/rules/codegraph.mdc`, `~/.codex/AGENTS.md`)
+- Set up auto-allow permissions when Claude Code is one of the targets
+- Initialize your current project (local installs only)
-### 2. Restart Claude Code
+**Non-interactive (scripting / CI):**
-Restart Claude Code for the MCP server to load.
+```bash
+codegraph install --yes # auto-detect agents, install global
+codegraph install --target=cursor,claude --yes # explicit target list
+codegraph install --target=auto --location=local # detected agents, project-local
+codegraph install --print-config codex # print snippet, no file writes
+```
+
+| Flag | Values | Default |
+|---|---|---|
+| `--target` | `auto`, `all`, `none`, or csv (`claude,cursor,...`) | prompt |
+| `--location` | `global`, `local` | prompt |
+| `--yes` | (boolean) | prompt every step |
+| `--no-permissions` | (boolean) skip Claude auto-allow list | permissions on |
+| `--print-config ` | dump snippet for one agent and exit | — |
+
+### 2. Restart Your Agent
+
+Restart your agent (Claude Code / Cursor / Codex CLI / opencode) for the MCP server to load.
### 3. Initialize Projects
@@ -166,7 +189,9 @@ cd your-project
codegraph init -i
```
-That's it! Claude Code will use CodeGraph tools automatically when a `.codegraph/` directory exists.
+Builds the per-project knowledge graph index. Also wires up any project-local agent surfaces (e.g. Cursor's `.cursor/rules/codegraph.mdc`) so a single global `codegraph install` works in every project you open — no need to re-run the installer per project.
+
+That's it — your agent will use CodeGraph tools automatically when a `.codegraph/` directory exists.
Manual Setup (Alternative)
diff --git a/__tests__/installer-targets.test.ts b/__tests__/installer-targets.test.ts
new file mode 100644
index 0000000..ec437f9
--- /dev/null
+++ b/__tests__/installer-targets.test.ts
@@ -0,0 +1,332 @@
+/**
+ * Multi-target installer tests.
+ *
+ * Each `AgentTarget` is exercised against the same contract:
+ * - `install` writes the expected files
+ * - re-running `install` is byte-identical (idempotent)
+ * - sibling MCP servers / unrelated config is preserved
+ * - `uninstall` reverses `install`
+ * - `printConfig` returns parseable, non-empty content
+ *
+ * For agent-config destinations we redirect HOME to a tmpdir via
+ * `os.homedir` spying, and CWD via `process.chdir` — same pattern as
+ * the legacy `installer.test.ts`. No real `~/.claude/` etc. ever
+ * touched.
+ */
+
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import * as fs from 'fs';
+import * as path from 'path';
+import * as os from 'os';
+import { ALL_TARGETS, getTarget, resolveTargetFlag } from '../src/installer/targets/registry';
+import { upsertTomlTable, removeTomlTable, buildTomlTable } from '../src/installer/targets/toml';
+
+function mkTmpDir(label: string): string {
+ return fs.mkdtempSync(path.join(os.tmpdir(), `cg-targets-${label}-`));
+}
+
+// `os.homedir` is non-configurable on Node, so we redirect it via the
+// `$HOME` (POSIX) / `$USERPROFILE` (Windows) env vars that
+// `os.homedir()` reads first. Same trick the rest of the suite uses
+// when it needs a mock home.
+function setHome(dir: string): { restore: () => void } {
+ const prev = { HOME: process.env.HOME, USERPROFILE: process.env.USERPROFILE };
+ process.env.HOME = dir;
+ process.env.USERPROFILE = dir;
+ return {
+ restore() {
+ if (prev.HOME === undefined) delete process.env.HOME; else process.env.HOME = prev.HOME;
+ if (prev.USERPROFILE === undefined) delete process.env.USERPROFILE; else process.env.USERPROFILE = prev.USERPROFILE;
+ },
+ };
+}
+
+describe('Installer targets — contract', () => {
+ let tmpHome: string;
+ let tmpCwd: string;
+ let origCwd: string;
+ let homeRestore: { restore: () => void };
+
+ beforeEach(() => {
+ tmpHome = mkTmpDir('home');
+ tmpCwd = mkTmpDir('cwd');
+ origCwd = process.cwd();
+ process.chdir(tmpCwd);
+ homeRestore = setHome(tmpHome);
+ });
+
+ afterEach(() => {
+ homeRestore.restore();
+ process.chdir(origCwd);
+ fs.rmSync(tmpHome, { recursive: true, force: true });
+ fs.rmSync(tmpCwd, { recursive: true, force: true });
+ });
+
+ for (const target of ALL_TARGETS) {
+ describe(target.id, () => {
+ const supportedLocations = (['global', 'local'] as const).filter((l) =>
+ target.supportsLocation(l),
+ );
+
+ for (const location of supportedLocations) {
+ describe(`location=${location}`, () => {
+ it('install writes files; detect.alreadyConfigured becomes true', () => {
+ expect(target.detect(location).alreadyConfigured).toBe(false);
+
+ const result = target.install(location, { autoAllow: true });
+ expect(result.files.length).toBeGreaterThan(0);
+ for (const file of result.files) {
+ if (file.action !== 'unchanged') {
+ expect(fs.existsSync(file.path)).toBe(true);
+ }
+ }
+
+ expect(target.detect(location).alreadyConfigured).toBe(true);
+ });
+
+ it('re-running install is idempotent (no actions other than unchanged)', () => {
+ target.install(location, { autoAllow: true });
+ const second = target.install(location, { autoAllow: true });
+ for (const file of second.files) {
+ expect(file.action).toBe('unchanged');
+ }
+ });
+
+ it('install preserves a pre-existing sibling MCP server (where applicable)', () => {
+ // Plant a sibling entry in the same JSON config, install,
+ // and verify the sibling survives. Skip for Codex (TOML)
+ // and any target with no JSON config — they get covered
+ // by their own dedicated tests below.
+ const paths = target.describePaths(location);
+ const jsonPath = paths.find((p) => p.endsWith('.json'));
+ if (!jsonPath) return;
+
+ // Seed pre-existing config.
+ fs.mkdirSync(path.dirname(jsonPath), { recursive: true });
+ const seed: Record = { mcpServers: { other: { command: 'x' } } };
+ // opencode uses `mcp` not `mcpServers`. Match its shape too.
+ if (target.id === 'opencode') {
+ delete seed.mcpServers;
+ seed.mcp = { other: { type: 'local', command: ['x'], enabled: true } };
+ }
+ fs.writeFileSync(jsonPath, JSON.stringify(seed, null, 2) + '\n');
+
+ target.install(location, { autoAllow: true });
+
+ const after = JSON.parse(fs.readFileSync(jsonPath, 'utf-8'));
+ if (target.id === 'opencode') {
+ expect(after.mcp.other).toBeDefined();
+ expect(after.mcp.codegraph).toBeDefined();
+ } else {
+ expect(after.mcpServers.other).toBeDefined();
+ expect(after.mcpServers.codegraph).toBeDefined();
+ }
+ });
+
+ it('uninstall reverses install (alreadyConfigured returns to false)', () => {
+ target.install(location, { autoAllow: true });
+ expect(target.detect(location).alreadyConfigured).toBe(true);
+
+ target.uninstall(location);
+ expect(target.detect(location).alreadyConfigured).toBe(false);
+ });
+
+ it('printConfig returns non-empty output without writing anything', () => {
+ const before = listAllFiles(tmpHome).concat(listAllFiles(tmpCwd));
+ const out = target.printConfig(location);
+ expect(out.length).toBeGreaterThan(0);
+ const after = listAllFiles(tmpHome).concat(listAllFiles(tmpCwd));
+ expect(after.sort()).toEqual(before.sort());
+ });
+ });
+ }
+ });
+ }
+});
+
+describe('Installer targets — partial-state idempotency', () => {
+ let tmpHome: string;
+ let tmpCwd: string;
+ let origCwd: string;
+ let homeRestore: { restore: () => void };
+
+ beforeEach(() => {
+ tmpHome = mkTmpDir('home');
+ tmpCwd = mkTmpDir('cwd');
+ origCwd = process.cwd();
+ process.chdir(tmpCwd);
+ homeRestore = setHome(tmpHome);
+ });
+
+ afterEach(() => {
+ homeRestore.restore();
+ process.chdir(origCwd);
+ fs.rmSync(tmpHome, { recursive: true, force: true });
+ fs.rmSync(tmpCwd, { recursive: true, force: true });
+ });
+
+ it('codex: install after only config.toml exists — second pass is fully unchanged', () => {
+ const codex = getTarget('codex')!;
+ // First install creates both files.
+ codex.install('global', { autoAllow: false });
+ // Delete the AGENTS.md to simulate partial state (user wiped one file).
+ const agentsMd = path.join(tmpHome, '.codex', 'AGENTS.md');
+ expect(fs.existsSync(agentsMd)).toBe(true);
+ fs.unlinkSync(agentsMd);
+ // Reinstall — TOML stays unchanged, AGENTS.md is recreated.
+ const second = codex.install('global', { autoAllow: false });
+ const tomlEntry = second.files.find((f) => f.path.endsWith('config.toml'))!;
+ const mdEntry = second.files.find((f) => f.path.endsWith('AGENTS.md'))!;
+ expect(tomlEntry.action).toBe('unchanged');
+ expect(mdEntry.action).toBe('created');
+ // Third install — both unchanged (full idempotency restored).
+ const third = codex.install('global', { autoAllow: false });
+ for (const f of third.files) expect(f.action).toBe('unchanged');
+ });
+
+ it('codex: user-added key inside [mcp_servers.codegraph] survives idempotent re-install', () => {
+ const codex = getTarget('codex')!;
+ codex.install('global', { autoAllow: false });
+ const tomlPath = path.join(tmpHome, '.codex', 'config.toml');
+ const original = fs.readFileSync(tomlPath, 'utf-8');
+ // User edits the block to add a custom key.
+ const edited = original.replace(
+ 'args = ["serve", "--mcp"]',
+ 'args = ["serve", "--mcp"]\nenabled = true',
+ );
+ fs.writeFileSync(tomlPath, edited);
+ // Re-install: our serializer doesn't know `enabled = true`, so
+ // the block no longer matches the canonical form — we'll
+ // overwrite it. This is the documented contract: we own the
+ // codegraph block exclusively.
+ const second = codex.install('global', { autoAllow: false });
+ const tomlEntry = second.files.find((f) => f.path.endsWith('config.toml'))!;
+ expect(tomlEntry.action).toBe('updated');
+ const after = fs.readFileSync(tomlPath, 'utf-8');
+ expect(after).not.toContain('enabled = true');
+ });
+});
+
+describe('Installer targets — registry', () => {
+ it('getTarget returns the right target for each id', () => {
+ expect(getTarget('claude')?.id).toBe('claude');
+ expect(getTarget('cursor')?.id).toBe('cursor');
+ expect(getTarget('codex')?.id).toBe('codex');
+ expect(getTarget('opencode')?.id).toBe('opencode');
+ expect(getTarget('not-a-real-target')).toBeUndefined();
+ });
+
+ it('resolveTargetFlag handles auto/all/none/csv', () => {
+ expect(resolveTargetFlag('none', 'global')).toEqual([]);
+ expect(resolveTargetFlag('all', 'global').length).toBe(ALL_TARGETS.length);
+ const csv = resolveTargetFlag('claude,cursor', 'global');
+ expect(csv.map((t) => t.id)).toEqual(['claude', 'cursor']);
+ });
+
+ it('resolveTargetFlag throws on unknown id', () => {
+ expect(() => resolveTargetFlag('claude,bogus', 'global')).toThrow(/Unknown --target/);
+ });
+});
+
+describe('Installer targets — TOML serializer (Codex backbone)', () => {
+ it('builds a [mcp_servers.codegraph] block with command + args', () => {
+ const block = buildTomlTable('mcp_servers.codegraph', {
+ command: 'codegraph',
+ args: ['serve', '--mcp'],
+ });
+ expect(block).toContain('[mcp_servers.codegraph]');
+ expect(block).toContain('command = "codegraph"');
+ expect(block).toContain('args = ["serve", "--mcp"]');
+ });
+
+ it('upsert inserts into empty content', () => {
+ const block = buildTomlTable('mcp_servers.codegraph', { command: 'codegraph', args: ['serve'] });
+ const { content, action } = upsertTomlTable('', 'mcp_servers.codegraph', block);
+ expect(action).toBe('inserted');
+ expect(content.startsWith('[mcp_servers.codegraph]')).toBe(true);
+ });
+
+ it('upsert is idempotent — second call returns unchanged', () => {
+ const block = buildTomlTable('mcp_servers.codegraph', { command: 'codegraph', args: ['serve'] });
+ const first = upsertTomlTable('', 'mcp_servers.codegraph', block);
+ const second = upsertTomlTable(first.content, 'mcp_servers.codegraph', block);
+ expect(second.action).toBe('unchanged');
+ expect(second.content).toBe(first.content);
+ });
+
+ it('upsert replaces an existing block in place, preserving sibling tables', () => {
+ const existing = [
+ '[other_table]',
+ 'foo = "bar"',
+ '',
+ '[mcp_servers.codegraph]',
+ 'command = "old-codegraph"',
+ 'args = ["old"]',
+ '',
+ '[zzz]',
+ 'baz = "qux"',
+ '',
+ ].join('\n');
+ const newBlock = buildTomlTable('mcp_servers.codegraph', {
+ command: 'codegraph',
+ args: ['serve', '--mcp'],
+ });
+ const { content, action } = upsertTomlTable(existing, 'mcp_servers.codegraph', newBlock);
+ expect(action).toBe('replaced');
+ expect(content).toContain('[other_table]');
+ expect(content).toContain('foo = "bar"');
+ expect(content).toContain('[zzz]');
+ expect(content).toContain('baz = "qux"');
+ expect(content).toContain('command = "codegraph"');
+ expect(content).not.toContain('old-codegraph');
+ });
+
+ it('removeTomlTable strips the block and preserves siblings', () => {
+ const existing = [
+ '[other_table]',
+ 'foo = "bar"',
+ '',
+ '[mcp_servers.codegraph]',
+ 'command = "codegraph"',
+ 'args = ["serve"]',
+ ].join('\n');
+ const { content, action } = removeTomlTable(existing, 'mcp_servers.codegraph');
+ expect(action).toBe('removed');
+ expect(content).toContain('[other_table]');
+ expect(content).toContain('foo = "bar"');
+ expect(content).not.toContain('mcp_servers.codegraph');
+ });
+
+ it('removeTomlTable on missing table returns not-found, no content change', () => {
+ const existing = '[other]\nfoo = "bar"\n';
+ const { content, action } = removeTomlTable(existing, 'mcp_servers.codegraph');
+ expect(action).toBe('not-found');
+ expect(content).toBe(existing);
+ });
+
+ it('upsert preserves an array-of-tables sibling [[foo]]', () => {
+ const existing = [
+ '[[foo]]',
+ 'name = "a"',
+ '',
+ '[[foo]]',
+ 'name = "b"',
+ '',
+ ].join('\n');
+ const block = buildTomlTable('mcp_servers.codegraph', { command: 'codegraph', args: ['serve'] });
+ const { content } = upsertTomlTable(existing, 'mcp_servers.codegraph', block);
+ expect(content.match(/\[\[foo\]\]/g)?.length).toBe(2);
+ expect(content).toContain('[mcp_servers.codegraph]');
+ });
+});
+
+function listAllFiles(dir: string): string[] {
+ if (!fs.existsSync(dir)) return [];
+ const out: string[] = [];
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
+ const full = path.join(dir, entry.name);
+ if (entry.isDirectory()) out.push(...listAllFiles(full));
+ else out.push(full);
+ }
+ return out;
+}
diff --git a/__tests__/installer.test.ts b/__tests__/installer.test.ts
index e2e24d1..1e0a90e 100644
--- a/__tests__/installer.test.ts
+++ b/__tests__/installer.test.ts
@@ -125,9 +125,10 @@ describe('Installer Config Writer', () => {
const modified = '## My Custom Section\n\nCustom content\n\n' + original + '\n\n## Another Section\n\nMore content\n';
fs.writeFileSync(claudeMdPath, modified);
- // Second write should replace only the marked section
- const result = writeClaudeMd('local');
- expect(result.updated).toBe(true);
+ // Second write should leave the marked block as-is (byte-identical
+ // body, so result is `created:false, updated:false` — both flags
+ // are off but the surrounding custom content must survive).
+ writeClaudeMd('local');
const final = fs.readFileSync(claudeMdPath, 'utf-8');
expect(final).toContain('## My Custom Section');
diff --git a/scripts/local-install.sh b/scripts/local-install.sh
new file mode 100755
index 0000000..8479669
--- /dev/null
+++ b/scripts/local-install.sh
@@ -0,0 +1,41 @@
+#!/usr/bin/env bash
+# Build the current branch and link it as the global `codegraph` for
+# hands-on testing. Replaces any existing global install for as long
+# as the symlink is in place.
+#
+# Usage:
+# ./scripts/local-install.sh # build + link
+# ./scripts/local-install.sh --undo # unlink + restore the published version
+
+set -euo pipefail
+
+cd "$(dirname "$0")/.."
+
+PKG=$(node -p "require('./package.json').name")
+VERSION=$(node -p "require('./package.json').version")
+BRANCH=$(git rev-parse --abbrev-ref HEAD)
+
+if [ "${1:-}" = "--undo" ]; then
+ echo "→ unlinking ${PKG}"
+ npm unlink -g "${PKG}" >/dev/null 2>&1 || true
+ echo "→ reinstalling published ${PKG}"
+ npm install -g "${PKG}"
+ echo "done: global codegraph -> $(command -v codegraph)"
+ exit 0
+fi
+
+echo "→ building ${PKG} ${VERSION} (${BRANCH})"
+npm run build
+
+echo "→ linking globally"
+npm link
+
+LINKED=$(command -v codegraph || echo "(not on PATH)")
+echo
+echo "✓ global codegraph now points to this branch"
+echo " binary: ${LINKED}"
+echo " branch: ${BRANCH}"
+echo " version: ${VERSION}"
+echo
+echo "To restore the published version:"
+echo " ./scripts/local-install.sh --undo"
diff --git a/src/bin/codegraph.ts b/src/bin/codegraph.ts
index 8ee7a6f..f9b00bd 100644
--- a/src/bin/codegraph.ts
+++ b/src/bin/codegraph.ts
@@ -405,6 +405,15 @@ program
if (isInitialized(projectPath)) {
clack.log.warn(`Already initialized in ${projectPath}`);
clack.log.info('Use "codegraph index" to re-index or "codegraph sync" to update');
+ // Re-run agent surface wiring so re-running `init` is the
+ // documented way to recover a project that's missing its
+ // Cursor rules file (or future per-agent project surfaces).
+ try {
+ const { wireProjectSurfacesForGlobalAgents } = await import('../installer');
+ for (const { target, file } of wireProjectSurfacesForGlobalAgents()) {
+ clack.log.success(`${target.displayName}: ${file.action} ${file.path}`);
+ }
+ } catch { /* non-fatal */ }
clack.outro('');
return;
}
@@ -413,6 +422,20 @@ program
const cg = await CodeGraph.init(projectPath, { index: false });
clack.log.success(`Initialized in ${projectPath}`);
+ // Bootstrap project-local surfaces for any agent that's
+ // configured globally (Cursor needs ./.cursor/rules/codegraph.mdc
+ // to actually prefer codegraph over native grep). Silent when
+ // there's nothing to write.
+ try {
+ const { wireProjectSurfacesForGlobalAgents } = await import('../installer');
+ for (const { target, file } of wireProjectSurfacesForGlobalAgents()) {
+ clack.log.success(`${target.displayName}: ${file.action} ${file.path}`);
+ }
+ } catch (err) {
+ const msg = err instanceof Error ? err.message : String(err);
+ clack.log.warn(`Skipped wiring project-local agent surfaces: ${msg}`);
+ }
+
if (options.index) {
let result: IndexResult;
@@ -1275,10 +1298,61 @@ program
*/
program
.command('install')
- .description('Run interactive installer for Claude Code integration')
- .action(async () => {
- const { runInstaller } = await import('../installer');
- await runInstaller();
+ .description('Install codegraph MCP server into one or more agents (Claude Code, Cursor, Codex CLI, opencode)')
+ .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('--no-permissions', 'Skip writing the auto-allow permissions list (Claude Code only)')
+ .option('--print-config ', 'Print MCP config snippet for the named agent and exit (no file writes)')
+ .action(async (opts: {
+ target?: string;
+ location?: string;
+ yes?: boolean;
+ permissions?: boolean;
+ printConfig?: string;
+ }) => {
+ if (opts.printConfig) {
+ const { getTarget, listTargetIds } = await import('../installer/targets/registry');
+ const target = getTarget(opts.printConfig);
+ if (!target) {
+ const known = listTargetIds().join(', ');
+ error(`Unknown target "${opts.printConfig}". Known: ${known}.`);
+ process.exit(1);
+ }
+ const loc = (opts.location === 'local' ? 'local' : 'global') as 'global' | 'local';
+ process.stdout.write(target.printConfig(loc));
+ return;
+ }
+
+ const { runInstallerWithOptions } = await import('../installer');
+ if (opts.location && opts.location !== 'global' && opts.location !== 'local') {
+ error(`--location must be "global" or "local" (got "${opts.location}").`);
+ process.exit(1);
+ }
+ try {
+ // Commander's `--no-permissions` makes `opts.permissions === false`;
+ // omitting the flag leaves it `true` (the positive-form default).
+ // We MUST treat the default-true as "user did not override — let
+ // the orchestrator prompt" and only forward an explicit `false`
+ // (or `true` when --yes implies it). Otherwise the auto-allow
+ // prompt is silently skipped on every interactive run.
+ const explicitNoPermissions = opts.permissions === false;
+ const autoAllow: boolean | undefined = explicitNoPermissions
+ ? false
+ : opts.yes
+ ? true
+ : undefined;
+
+ await runInstallerWithOptions({
+ target: opts.target,
+ location: opts.location as 'global' | 'local' | undefined,
+ autoAllow,
+ yes: opts.yes,
+ });
+ } catch (err) {
+ error(err instanceof Error ? err.message : String(err));
+ process.exit(1);
+ }
});
// Parse and run
diff --git a/src/bin/uninstall.ts b/src/bin/uninstall.ts
index 4344a04..a168d80 100644
--- a/src/bin/uninstall.ts
+++ b/src/bin/uninstall.ts
@@ -2,121 +2,33 @@
/**
* CodeGraph preuninstall cleanup script
*
- * Runs automatically when `npm uninstall -g @colbymchenry/codegraph` is called.
- * Removes all CodeGraph configuration from Claude Code:
- * - MCP server entry from ~/.claude.json
- * - Permissions from ~/.claude/settings.json
- * - CodeGraph section from ~/.claude/CLAUDE.md
+ * Runs automatically when `npm uninstall -g @colbymchenry/codegraph`
+ * is called. Loops over every known agent target's `uninstall(loc)`
+ * for the global location only — local-location entries live inside
+ * project working trees and aren't ours to nuke at npm-uninstall
+ * time.
*
- * This script must never throw — a failed cleanup must not block uninstall.
+ * This script must never throw — a failed cleanup must not block
+ * uninstall.
*/
-import * as fs from 'fs';
-import * as path from 'path';
-import * as os from 'os';
-
-const CODEGRAPH_SECTION_START = '';
-const CODEGRAPH_SECTION_END = '';
-
-function readJson(filePath: string): Record | null {
- try {
- if (!fs.existsSync(filePath)) return null;
- return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
- } catch {
- return null;
- }
-}
-
-function writeJson(filePath: string, data: Record): void {
- fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n');
-}
-
-/**
- * Remove CodeGraph MCP server from ~/.claude.json
- */
-function removeMcpConfig(): void {
- const filePath = path.join(os.homedir(), '.claude.json');
- const config = readJson(filePath);
- if (!config?.mcpServers?.codegraph) return;
-
- delete config.mcpServers.codegraph;
-
- // Clean up empty mcpServers object
- if (Object.keys(config.mcpServers).length === 0) {
- delete config.mcpServers;
- }
-
- writeJson(filePath, config);
-}
-
-/**
- * Remove CodeGraph permissions from ~/.claude/settings.json
- */
-function removeSettings(): void {
- const filePath = path.join(os.homedir(), '.claude', 'settings.json');
- const settings = readJson(filePath);
- if (!settings) return;
-
- // Remove codegraph permissions
- if (Array.isArray(settings.permissions?.allow)) {
- const before = settings.permissions.allow.length;
- settings.permissions.allow = settings.permissions.allow.filter(
- (p: string) => !p.startsWith('mcp__codegraph__')
- );
- if (settings.permissions.allow.length === before) return;
-
- // Clean up empty allow array
- if (settings.permissions.allow.length === 0) {
- delete settings.permissions.allow;
- }
- // Clean up empty permissions object
- if (Object.keys(settings.permissions).length === 0) {
- delete settings.permissions;
- }
-
- writeJson(filePath, settings);
- }
-}
-
-/**
- * Remove CodeGraph section from ~/.claude/CLAUDE.md
- */
-function removeClaudeMd(): void {
- const filePath = path.join(os.homedir(), '.claude', 'CLAUDE.md');
- try {
- if (!fs.existsSync(filePath)) return;
- let content = fs.readFileSync(filePath, 'utf-8');
-
- // Remove marked section
- const startIdx = content.indexOf(CODEGRAPH_SECTION_START);
- const endIdx = content.indexOf(CODEGRAPH_SECTION_END);
-
- if (startIdx !== -1 && endIdx > startIdx) {
- const before = content.substring(0, startIdx).trimEnd();
- const after = content.substring(endIdx + CODEGRAPH_SECTION_END.length).trimStart();
- content = before + (before && after ? '\n\n' : '') + after;
-
- if (content.trim() === '') {
- // File is empty after removing section — delete it
- fs.unlinkSync(filePath);
- } else {
- fs.writeFileSync(filePath, content.trim() + '\n');
- }
- }
- } catch {
- // Never fail
- }
-}
-
-// Run cleanup — never throw
-try {
- removeMcpConfig();
-} catch { /* ignore */ }
-
try {
- removeSettings();
-} catch { /* ignore */ }
+ // Lazy require so any module-level error in the registry can't
+ // bubble out and abort the npm uninstall.
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
+ const { ALL_TARGETS } = require('../installer/targets/registry') as
+ typeof import('../installer/targets/registry');
-try {
- removeClaudeMd();
-} catch { /* ignore */ }
+ for (const target of ALL_TARGETS) {
+ if (!target.supportsLocation('global')) continue;
+ try {
+ target.uninstall('global');
+ } catch {
+ // Each target is independently safe-to-skip; per-target failure
+ // must not stop the loop.
+ }
+ }
+} catch {
+ // If the registry itself can't be loaded (e.g. partial install),
+ // we silently skip cleanup. Uninstall still completes.
+}
diff --git a/src/installer/clack.d.ts b/src/installer/clack.d.ts
index 08e9687..29d08ad 100644
--- a/src/installer/clack.d.ts
+++ b/src/installer/clack.d.ts
@@ -24,6 +24,13 @@ declare module '@clack/prompts' {
initialValue?: Value;
}): Promise;
+ export function multiselect(opts: {
+ message: string;
+ options: { value: Value; label: string; hint?: string }[];
+ initialValues?: Value[];
+ required?: boolean;
+ }): Promise;
+
export function spinner(): {
start(message?: string): void;
stop(message?: string): void;
diff --git a/src/installer/claude-md-template.ts b/src/installer/claude-md-template.ts
index 130d447..f1093b0 100644
--- a/src/installer/claude-md-template.ts
+++ b/src/installer/claude-md-template.ts
@@ -1,44 +1,19 @@
/**
- * CLAUDE.md template for CodeGraph instructions
+ * Backwards-compat re-export shim.
*
- * This template is injected into ~/.claude/CLAUDE.md (global) or ./.claude/CLAUDE.md (local)
- * Keep this in sync with the README.md "Recommended: Add Global Instructions" section
+ * The instructions template moved to `instructions-template.ts` so it
+ * can be shared across all agent targets (Claude Code, Cursor, Codex
+ * CLI, opencode). This file is preserved purely so existing imports
+ * (`@colbymchenry/codegraph` consumers, downstream tooling) keep
+ * working unchanged. New code should import from
+ * `./instructions-template` directly.
+ *
+ * @deprecated Import from `./instructions-template` instead.
*/
-// Markers to identify CodeGraph section for updates
-export const CODEGRAPH_SECTION_START = '';
-export const CODEGRAPH_SECTION_END = '';
-
-export const CLAUDE_MD_TEMPLATE = `${CODEGRAPH_SECTION_START}
-## CodeGraph
-
-CodeGraph builds a semantic knowledge graph of codebases for faster, smarter code exploration.
-
-### If \`.codegraph/\` exists in the project
-
-**NEVER call \`codegraph_explore\` or \`codegraph_context\` directly in the main session.** These tools return large amounts of source code that fills up main session context. Instead, ALWAYS spawn an Explore agent for any exploration question (e.g., "how does X work?", "explain the Y system", "where is Z implemented?").
-
-**When spawning Explore agents**, include this instruction in the prompt:
-
-> This project has CodeGraph initialized (.codegraph/ exists). Use \`codegraph_explore\` as your PRIMARY tool — it returns full source code sections from all relevant files in one call.
->
-> **Rules:**
-> 1. Follow the explore call budget in the \`codegraph_explore\` tool description — it scales automatically based on project size.
-> 2. Do NOT re-read files that codegraph_explore already returned source code for. The source sections are complete and authoritative.
-> 3. Only fall back to grep/glob/read for files listed under "Additional relevant files" if you need more detail, or if codegraph returned no results.
-
-**The main session may only use these lightweight tools directly** (for targeted lookups before making edits, not for exploration):
-
-| Tool | Use For |
-|------|---------|
-| \`codegraph_search\` | Find symbols by name |
-| \`codegraph_callers\` / \`codegraph_callees\` | Trace call flow |
-| \`codegraph_impact\` | Check what's affected before editing |
-| \`codegraph_node\` | Get a single symbol's details |
-
-### If \`.codegraph/\` does NOT exist
-
-At the start of a session, ask the user if they'd like to initialize CodeGraph:
-
-"I notice this project doesn't have CodeGraph initialized. Would you like me to run \`codegraph init -i\` to build a code knowledge graph?"
-${CODEGRAPH_SECTION_END}`;
+export {
+ CODEGRAPH_SECTION_START,
+ CODEGRAPH_SECTION_END,
+ CLAUDE_MD_TEMPLATE,
+ INSTRUCTIONS_TEMPLATE,
+} from './instructions-template';
diff --git a/src/installer/config-writer.ts b/src/installer/config-writer.ts
index 5e01990..c1f8abc 100644
--- a/src/installer/config-writer.ts
+++ b/src/installer/config-writer.ts
@@ -1,292 +1,77 @@
/**
- * Config file writing for the CodeGraph installer
- * Writes to claude.json, settings.json, and CLAUDE.md
+ * Backwards-compat shim — original Claude-only writer functions.
+ *
+ * The installer now uses the multi-target architecture in
+ * `./targets/`. This file is preserved so existing imports (the test
+ * suite, downstream tooling) keep working unchanged. Each function
+ * delegates to the Claude target. New code should import the target
+ * registry from `./targets/registry` directly.
+ *
+ * @deprecated Use `targets/registry.ts` and the `AgentTarget`
+ * abstraction instead.
*/
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
-export type InstallLocation = 'global' | 'local';
import {
- CLAUDE_MD_TEMPLATE,
- CODEGRAPH_SECTION_START,
- CODEGRAPH_SECTION_END,
-} from './claude-md-template';
+ writeMcpEntry,
+ writePermissionsEntry,
+ writeInstructionsEntry,
+} from './targets/claude';
+import { readJsonFile } from './targets/shared';
+
+export type InstallLocation = 'global' | 'local';
/**
- * Get the path to the Claude config directory
+ * Each shim calls ONLY the named per-file helper — writeMcpConfig
+ * writes only the MCP JSON, writePermissions only settings.json,
+ * writeClaudeMd only CLAUDE.md. The full multi-file install lives
+ * in `claudeTarget.install()` which the new orchestrator uses.
*/
-function getClaudeConfigDir(location: InstallLocation): string {
- if (location === 'global') {
- return path.join(os.homedir(), '.claude');
- }
- return path.join(process.cwd(), '.claude');
+export function writeMcpConfig(location: InstallLocation): void {
+ writeMcpEntry(location);
}
-/**
- * Get the path to the claude.json file
- * - Global: ~/.claude.json (root level)
- * - Local: ./.claude.json (project root)
- */
-function getClaudeJsonPath(location: InstallLocation): string {
- if (location === 'global') {
- return path.join(os.homedir(), '.claude.json');
- }
- return path.join(process.cwd(), '.claude.json');
+export function writePermissions(location: InstallLocation): void {
+ writePermissionsEntry(location);
}
-/**
- * Get the path to the settings.json file
- * - Global: ~/.claude/settings.json
- * - Local: ./.claude/settings.json
- */
-function getSettingsJsonPath(location: InstallLocation): string {
- const configDir = getClaudeConfigDir(location);
- return path.join(configDir, 'settings.json');
-}
-
-/**
- * Read a JSON file, returning an empty object if it doesn't exist.
- * Distinguishes between missing files (returns {}) and corrupted
- * files (logs warning, returns {}).
- */
-function readJsonFile(filePath: string): Record {
- if (!fs.existsSync(filePath)) {
- return {};
- }
- try {
- const content = fs.readFileSync(filePath, 'utf-8');
- return JSON.parse(content);
- } catch (err) {
- const msg = err instanceof Error ? err.message : String(err);
- console.warn(` Warning: Could not parse ${path.basename(filePath)}: ${msg}`);
- console.warn(` A backup will be created before overwriting.`);
- // Create a backup of the corrupted file
- try {
- const backupPath = filePath + '.backup';
- fs.copyFileSync(filePath, backupPath);
- } catch { /* ignore backup failure */ }
- return {};
- }
-}
-
-/**
- * Write a file atomically by writing to a temp file then renaming.
- * Prevents corruption if the process crashes mid-write.
- */
-function atomicWriteFileSync(filePath: string, content: string): void {
- const dir = path.dirname(filePath);
- if (!fs.existsSync(dir)) {
- fs.mkdirSync(dir, { recursive: true });
- }
- const tmpPath = filePath + '.tmp.' + process.pid;
- try {
- fs.writeFileSync(tmpPath, content);
- fs.renameSync(tmpPath, filePath);
- } catch (err) {
- // Clean up temp file on failure
- try { fs.unlinkSync(tmpPath); } catch { /* ignore */ }
- throw err;
- }
-}
-
-/**
- * Write a JSON file, creating parent directories if needed
- */
-function writeJsonFile(filePath: string, data: Record): void {
- atomicWriteFileSync(filePath, JSON.stringify(data, null, 2) + '\n');
-}
-
-/**
- * Get the MCP server configuration
- */
-function getMcpServerConfig(): Record {
+export function writeClaudeMd(location: InstallLocation): { created: boolean; updated: boolean } {
+ const file = writeInstructionsEntry(location);
return {
- type: 'stdio',
- command: 'codegraph',
- args: ['serve', '--mcp'],
+ created: file.action === 'created',
+ updated: file.action === 'updated',
};
}
-/**
- * Write the MCP server configuration to claude.json
- */
-export function writeMcpConfig(location: InstallLocation): void {
- const claudeJsonPath = getClaudeJsonPath(location);
- const config = readJsonFile(claudeJsonPath);
-
- // Ensure mcpServers object exists
- if (!config.mcpServers) {
- config.mcpServers = {};
- }
-
- // Add or update codegraph server
- config.mcpServers.codegraph = getMcpServerConfig();
-
- writeJsonFile(claudeJsonPath, config);
-}
-
-/**
- * Get the list of permissions for CodeGraph tools
- */
-function getCodeGraphPermissions(): string[] {
- return [
- 'mcp__codegraph__codegraph_search',
- 'mcp__codegraph__codegraph_context',
- 'mcp__codegraph__codegraph_callers',
- 'mcp__codegraph__codegraph_callees',
- 'mcp__codegraph__codegraph_impact',
- 'mcp__codegraph__codegraph_node',
- 'mcp__codegraph__codegraph_status',
- ];
-}
-
-/**
- * Write permissions to settings.json
- */
-export function writePermissions(location: InstallLocation): void {
- const settingsPath = getSettingsJsonPath(location);
- const settings = readJsonFile(settingsPath);
-
- // Ensure permissions object exists
- if (!settings.permissions) {
- settings.permissions = {};
- }
-
- // Ensure allow array exists
- if (!Array.isArray(settings.permissions.allow)) {
- settings.permissions.allow = [];
- }
-
- // Add CodeGraph permissions (avoiding duplicates)
- const codegraphPermissions = getCodeGraphPermissions();
- for (const permission of codegraphPermissions) {
- if (!settings.permissions.allow.includes(permission)) {
- settings.permissions.allow.push(permission);
- }
- }
-
- writeJsonFile(settingsPath, settings);
-}
-
-/**
- * Check if MCP config already exists for CodeGraph
- */
export function hasMcpConfig(location: InstallLocation): boolean {
- const claudeJsonPath = getClaudeJsonPath(location);
- const config = readJsonFile(claudeJsonPath);
+ const file = location === 'global'
+ ? path.join(os.homedir(), '.claude.json')
+ : path.join(process.cwd(), '.claude.json');
+ const config = readJsonFile(file);
return !!config.mcpServers?.codegraph;
}
-/**
- * Check if permissions already exist for CodeGraph
- */
export function hasPermissions(location: InstallLocation): boolean {
- const settingsPath = getSettingsJsonPath(location);
- const settings = readJsonFile(settingsPath);
- const permissions = settings.permissions?.allow;
- if (!Array.isArray(permissions)) {
+ const file = location === 'global'
+ ? path.join(os.homedir(), '.claude', 'settings.json')
+ : path.join(process.cwd(), '.claude', 'settings.json');
+ const settings = readJsonFile(file);
+ const allow = settings.permissions?.allow;
+ if (!Array.isArray(allow)) return false;
+ return allow.some((p: string) => p.startsWith('mcp__codegraph__'));
+}
+
+export function hasClaudeMdSection(location: InstallLocation): boolean {
+ const file = location === 'global'
+ ? path.join(os.homedir(), '.claude', 'CLAUDE.md')
+ : path.join(process.cwd(), '.claude', 'CLAUDE.md');
+ try {
+ if (!fs.existsSync(file)) return false;
+ const content = fs.readFileSync(file, 'utf-8');
+ return content.includes('') || content.includes('## CodeGraph');
+ } catch {
return false;
}
- // Check if at least one CodeGraph permission exists
- return permissions.some((p: string) => p.startsWith('mcp__codegraph__'));
-}
-
-/**
- * Get the path to CLAUDE.md
- * - Global: ~/.claude/CLAUDE.md
- * - Local: ./.claude/CLAUDE.md
- */
-function getClaudeMdPath(location: InstallLocation): string {
- const configDir = getClaudeConfigDir(location);
- return path.join(configDir, 'CLAUDE.md');
-}
-
-/**
- * Check if CLAUDE.md has CodeGraph section
- */
-export function hasClaudeMdSection(location: InstallLocation): boolean {
- const claudeMdPath = getClaudeMdPath(location);
- try {
- if (fs.existsSync(claudeMdPath)) {
- const content = fs.readFileSync(claudeMdPath, 'utf-8');
- return content.includes(CODEGRAPH_SECTION_START) || content.includes('## CodeGraph');
- }
- } catch {
- // Ignore errors
- }
- return false;
-}
-
-/**
- * Write or update CLAUDE.md with CodeGraph instructions
- *
- * If the file exists and has a CodeGraph section (marked or unmarked),
- * it will be replaced. Otherwise, the template is appended.
- */
-export function writeClaudeMd(location: InstallLocation): { created: boolean; updated: boolean } {
- const claudeMdPath = getClaudeMdPath(location);
- const configDir = getClaudeConfigDir(location);
-
- // Ensure directory exists
- if (!fs.existsSync(configDir)) {
- fs.mkdirSync(configDir, { recursive: true });
- }
-
- // Check if file exists
- if (!fs.existsSync(claudeMdPath)) {
- // Create new file with just the CodeGraph section
- atomicWriteFileSync(claudeMdPath, CLAUDE_MD_TEMPLATE + '\n');
- return { created: true, updated: false };
- }
-
- // Read existing content
- let content = fs.readFileSync(claudeMdPath, 'utf-8');
-
- // Check for marked section (from previous installer)
- if (content.includes(CODEGRAPH_SECTION_START)) {
- // Replace the marked section
- const startIdx = content.indexOf(CODEGRAPH_SECTION_START);
- const endIdx = content.indexOf(CODEGRAPH_SECTION_END);
-
- if (endIdx > startIdx) {
- // Replace existing marked section
- const before = content.substring(0, startIdx);
- const after = content.substring(endIdx + CODEGRAPH_SECTION_END.length);
- content = before + CLAUDE_MD_TEMPLATE + after;
- atomicWriteFileSync(claudeMdPath, content);
- return { created: false, updated: true };
- }
- }
-
- // Check for unmarked "## CodeGraph" section (from manual setup)
- const codegraphHeaderRegex = /\n## CodeGraph\n/;
- const match = content.match(codegraphHeaderRegex);
-
- if (match && match.index !== undefined) {
- // Find the end of the CodeGraph section (next h2 header or end of file)
- // Use negative lookahead (?!#) to match "## X" but not "### X"
- const sectionStart = match.index;
- const afterSection = content.substring(sectionStart + 1);
- const nextHeaderMatch = afterSection.match(/\n## (?!#)/);
-
- let sectionEnd: number;
- if (nextHeaderMatch && nextHeaderMatch.index !== undefined) {
- sectionEnd = sectionStart + 1 + nextHeaderMatch.index;
- } else {
- sectionEnd = content.length;
- }
-
- // Replace the section
- const before = content.substring(0, sectionStart);
- const after = content.substring(sectionEnd);
- content = before + '\n' + CLAUDE_MD_TEMPLATE + after;
- atomicWriteFileSync(claudeMdPath, content);
- return { created: false, updated: true };
- }
-
- // No existing section, append to end
- content = content.trimEnd() + '\n\n' + CLAUDE_MD_TEMPLATE + '\n';
- atomicWriteFileSync(claudeMdPath, content);
- return { created: false, updated: false };
}
diff --git a/src/installer/index.ts b/src/installer/index.ts
index 7d01af9..3277297 100644
--- a/src/installer/index.ts
+++ b/src/installer/index.ts
@@ -1,18 +1,39 @@
/**
* CodeGraph Interactive Installer
*
- * Uses @clack/prompts for a polished interactive CLI experience.
+ * Multi-target: writes MCP server config + instructions for the
+ * agents the user picks (Claude Code, Cursor, Codex CLI, opencode).
+ * Defaults to the Claude-only behavior for backwards compatibility
+ * when no targets are explicitly chosen and nothing else is detected.
+ *
+ * Uses @clack/prompts for the interactive UI; `runInstallerWithOptions`
+ * is the non-interactive entry point used by the `--target` /
+ * `--print-config` CLI flags.
*/
import { execSync } from 'child_process';
import * as path from 'path';
import * as fs from 'fs';
import {
- writeMcpConfig, writePermissions, writeClaudeMd,
- hasMcpConfig, hasPermissions,
-} from './config-writer';
+ ALL_TARGETS,
+ detectAll,
+ getTarget,
+ resolveTargetFlag,
+} from './targets/registry';
+import type { AgentTarget, Location, WriteResult } from './targets/types';
-import type { InstallLocation } from './config-writer';
+// Backwards-compat: keep these named exports — downstream code may
+// import them. The shim in `config-writer.ts` continues to re-export
+// them too.
+export {
+ writeMcpConfig,
+ writePermissions,
+ writeClaudeMd,
+ hasMcpConfig,
+ hasPermissions,
+ hasClaudeMdSection,
+} from './config-writer';
+export type { InstallLocation } from './config-writer';
// Dynamic import helper — tsc compiles import() to require() in CJS mode,
// which fails for ESM-only packages. This bypasses the transformation.
@@ -20,16 +41,10 @@ import type { InstallLocation } from './config-writer';
const importESM = new Function('specifier', 'return import(specifier)') as
(specifier: string) => Promise;
-/**
- * Format a number with commas
- */
function formatNumber(n: number): string {
return n.toLocaleString();
}
-/**
- * Get the package version
- */
function getVersion(): string {
try {
const packageJsonPath = path.join(__dirname, '..', '..', 'package.json');
@@ -40,125 +55,260 @@ function getVersion(): string {
}
}
+export interface RunInstallerOptions {
+ /** Comma-separated target list, or `auto` / `all` / `none`. */
+ target?: string;
+ /** Skip the location prompt; use this value directly. */
+ location?: Location;
+ /** Skip the auto-allow prompt; use this value directly. */
+ autoAllow?: boolean;
+ /**
+ * Skip every confirm and use defaults: location=global,
+ * autoAllow=true, target=auto. For scripting / CI.
+ */
+ yes?: boolean;
+}
+
/**
- * Run the interactive installer
+ * Interactive entry point — preserves the historical UX (`codegraph
+ * install` with no args goes through the prompts), but now starts
+ * the targets multi-select pre-populated with detected agents.
*/
export async function runInstaller(): Promise {
+ return runInstallerWithOptions({});
+}
+
+export async function runInstallerWithOptions(opts: RunInstallerOptions): Promise {
const clack = await importESM('@clack/prompts');
clack.intro(`CodeGraph v${getVersion()}`);
- // Step 1: Install globally
- const shouldInstallGlobally = await clack.confirm({
- message: 'Install codegraph globally? (Required for MCP server)',
- initialValue: true,
- });
+ // --yes implies all defaults; explicit flags still win.
+ const useDefaults = opts.yes === true;
- if (clack.isCancel(shouldInstallGlobally)) {
- clack.cancel('Installation cancelled.');
- process.exit(0);
+ // Step 1: which agent targets? Asked FIRST so the user knows what
+ // they're committing to before we touch npm or disk. Detection
+ // probes the user-provided location if known, else 'global' as the
+ // most common default — labels are a hint, not load-bearing.
+ const detectionLocation: Location = opts.location ?? 'global';
+ const targets = await resolveTargets(clack, opts, detectionLocation, useDefaults);
+ if (targets.length === 0) {
+ clack.outro('No agent targets selected — nothing to do.');
+ return;
}
- if (shouldInstallGlobally) {
- const s = clack.spinner();
- s.start('Installing codegraph globally...');
- try {
- execSync('npm install -g @colbymchenry/codegraph', { stdio: 'pipe' });
- s.stop('Installed codegraph globally');
- } catch {
- s.stop('Could not install globally (permission denied)');
- clack.log.warn('Try: sudo npm install -g @colbymchenry/codegraph');
+ // Step 2: install the codegraph npm package on PATH (always offered;
+ // matches existing behavior). Skipped when --yes (assume present).
+ if (!useDefaults) {
+ const shouldInstallGlobally = await clack.confirm({
+ message: 'Install the codegraph CLI on your PATH? (Required so agents can launch the MCP server)',
+ initialValue: true,
+ });
+ if (clack.isCancel(shouldInstallGlobally)) {
+ clack.cancel('Installation cancelled.');
+ process.exit(0);
}
+ if (shouldInstallGlobally) {
+ const s = clack.spinner();
+ s.start('Installing codegraph CLI...');
+ try {
+ execSync('npm install -g @colbymchenry/codegraph', { stdio: 'pipe' });
+ s.stop('Installed codegraph CLI on PATH');
+ } catch {
+ s.stop('Could not install (permission denied)');
+ clack.log.warn('Try: sudo npm install -g @colbymchenry/codegraph');
+ }
+ } else {
+ clack.log.info('Skipped CLI install — agents will not be able to launch the MCP server without it');
+ }
+ }
+
+ // Step 3: where the per-agent config files should land.
+ let location: Location;
+ if (opts.location) {
+ location = opts.location;
+ } else if (useDefaults) {
+ location = 'global';
} else {
- clack.log.info('Skipped global install — MCP server may not work without it');
+ // If every selected target is global-only (e.g. Codex), skip the
+ // prompt and force user-wide — project-local would just produce
+ // skip warnings.
+ const allGlobalOnly = targets.every((t) => !t.supportsLocation('local'));
+ if (allGlobalOnly) {
+ location = 'global';
+ clack.log.info('Writing user-wide configs (selected agents have no project-local config).');
+ } else {
+ const sel = await clack.select({
+ message: 'Apply agent configs to all your projects, or just this one?',
+ options: [
+ { value: 'global' as const, label: 'All projects', hint: '~/.claude, ~/.cursor, etc.' },
+ { value: 'local' as const, label: 'Just this project', hint: './.claude, ./.cursor, etc.' },
+ ],
+ initialValue: 'global' as const,
+ });
+ if (clack.isCancel(sel)) {
+ clack.cancel('Installation cancelled.');
+ process.exit(0);
+ }
+ location = sel;
+ }
}
- // Step 2: Installation location
- const location = await clack.select({
- message: 'Where would you like to install?',
- options: [
- { value: 'global' as const, label: 'Global', hint: '~/.claude — available in all projects' },
- { value: 'local' as const, label: 'Local', hint: './.claude — this project only' },
- ],
- initialValue: 'global' as const,
- });
-
- if (clack.isCancel(location)) {
- clack.cancel('Installation cancelled.');
- process.exit(0);
+ // Step 4: auto-allow permissions (only meaningful for Claude;
+ // skipped silently by other targets).
+ let autoAllow: boolean;
+ if (opts.autoAllow !== undefined) {
+ autoAllow = opts.autoAllow;
+ } else if (useDefaults) {
+ autoAllow = true;
+ } else if (targets.some((t) => t.id === 'claude')) {
+ const ans = await clack.confirm({
+ message: 'Auto-allow CodeGraph commands? (Skips permission prompts in Claude Code)',
+ initialValue: true,
+ });
+ if (clack.isCancel(ans)) {
+ clack.cancel('Installation cancelled.');
+ process.exit(0);
+ }
+ autoAllow = ans;
+ } else {
+ autoAllow = false;
}
- // Step 3: Auto-allow permissions
- const autoAllow = await clack.confirm({
- message: 'Auto-allow CodeGraph commands? (Skips permission prompts)',
- initialValue: true,
- });
-
- if (clack.isCancel(autoAllow)) {
- clack.cancel('Installation cancelled.');
- process.exit(0);
+ // Step 5: per-target install loop.
+ for (const target of targets) {
+ if (!target.supportsLocation(location)) {
+ clack.log.warn(
+ `${target.displayName}: skipped — does not support --location=${location}.`,
+ );
+ continue;
+ }
+ const result = target.install(location, { autoAllow });
+ for (const file of result.files) {
+ const verb = file.action === 'unchanged'
+ ? 'Unchanged'
+ : file.action === 'created' ? 'Created' : 'Updated';
+ clack.log.success(`${target.displayName}: ${verb} ${tildify(file.path)}`);
+ }
+ for (const note of result.notes ?? []) {
+ clack.log.info(`${target.displayName}: ${note}`);
+ }
}
- // Step 4: Write configuration files
- writeConfigs(clack, location, autoAllow);
-
- // Step 5: For local install, initialize the project
+ // Step 6: for local install, initialize the project.
if (location === 'local') {
await initializeLocalProject(clack);
}
- // Done
if (location === 'global') {
- clack.note(
- 'cd your-project\ncodegraph init -i',
- 'Quick start',
- );
+ clack.note('cd your-project\ncodegraph init -i', 'Quick start');
}
- clack.outro('Done! Restart Claude Code to use CodeGraph.');
+ const finalNote = targets.length > 0
+ ? `Done! Restart your agent${targets.length > 1 ? 's' : ''} to use CodeGraph.`
+ : 'Done!';
+ clack.outro(finalNote);
}
/**
- * Write all configuration files and log results
+ * For every target that has a global config and exposes
+ * `wireProjectSurfaces`, write its project-local surfaces (e.g.
+ * Cursor's `.cursor/rules/codegraph.mdc`). Idempotent — runs
+ * silently when there's nothing to write.
+ *
+ * Called by `codegraph init` so that a user who ran
+ * `codegraph install` once globally doesn't have to re-run it per
+ * project to get full agent support.
+ *
+ * Returns the list of `(target, file)` pairs that were created or
+ * updated — caller decides how to surface them.
*/
-function writeConfigs(
- clack: typeof import('@clack/prompts'),
- location: InstallLocation,
- autoAllow: boolean,
-): void {
- const locationLabel = location === 'global' ? '~/.claude' : './.claude';
-
- // MCP config
- const mcpAction = hasMcpConfig(location) ? 'Updated' : 'Added';
- writeMcpConfig(location);
- clack.log.success(`${mcpAction} MCP server in ${locationLabel}.json`);
-
- // Permissions
- if (autoAllow) {
- const permAction = hasPermissions(location) ? 'Updated' : 'Added';
- writePermissions(location);
- clack.log.success(`${permAction} permissions in ${locationLabel}/settings.json`);
- }
-
- // CLAUDE.md
- const claudeMdResult = writeClaudeMd(location);
- const claudeMdPath = `${locationLabel}/CLAUDE.md`;
- if (claudeMdResult.created) {
- clack.log.success(`Created ${claudeMdPath}`);
- } else if (claudeMdResult.updated) {
- clack.log.success(`Updated ${claudeMdPath}`);
- } else {
- clack.log.success(`Added CodeGraph instructions to ${claudeMdPath}`);
+export function wireProjectSurfacesForGlobalAgents(): Array<{
+ target: AgentTarget;
+ file: WriteResult['files'][number];
+}> {
+ const written: Array<{ target: AgentTarget; file: WriteResult['files'][number] }> = [];
+ for (const target of ALL_TARGETS) {
+ if (typeof target.wireProjectSurfaces !== 'function') continue;
+ const detection = target.detect('global');
+ if (!detection.alreadyConfigured) continue;
+ const result = target.wireProjectSurfaces();
+ for (const file of result.files) {
+ if (file.action === 'created' || file.action === 'updated') {
+ written.push({ target, file });
+ }
+ }
}
+ return written;
}
/**
- * Initialize CodeGraph in the current project (for local installs)
+ * Replace home-directory prefix in a path with `~/` for cleaner log
+ * lines. Pure cosmetic.
+ */
+function tildify(p: string): string {
+ const home = require('os').homedir();
+ if (p.startsWith(home + path.sep)) return '~' + p.substring(home.length);
+ return p;
+}
+
+async function resolveTargets(
+ clack: typeof import('@clack/prompts'),
+ opts: RunInstallerOptions,
+ location: Location,
+ useDefaults: boolean,
+): Promise {
+ // Explicit --target flag wins.
+ if (opts.target !== undefined) {
+ return resolveTargetFlag(opts.target, location);
+ }
+
+ // --yes implies auto-detect.
+ if (useDefaults) {
+ return resolveTargetFlag('auto', location);
+ }
+
+ // Interactive multi-select.
+ const detected = detectAll(location);
+ const initialValues = detected
+ .filter(({ detection }) => detection.installed)
+ .map(({ target }) => target.id);
+ // If nothing detected, default to Claude alone (matches the
+ // historical default and the smallest-surprise outcome).
+ const initial = initialValues.length > 0 ? initialValues : ['claude'];
+
+ const choice = await clack.multiselect({
+ message: 'Which agents should CodeGraph configure?',
+ options: ALL_TARGETS.map((t) => {
+ const det = detected.find(({ target }) => target.id === t.id)!.detection;
+ const flag = det.installed ? '(detected)' : '(not found)';
+ const globalOnly = !t.supportsLocation('local') ? ' — global only' : '';
+ return {
+ value: t.id,
+ label: `${t.displayName} ${flag}${globalOnly}`,
+ };
+ }),
+ initialValues: initial,
+ required: false,
+ });
+
+ if (clack.isCancel(choice)) {
+ clack.cancel('Installation cancelled.');
+ process.exit(0);
+ }
+
+ return choice
+ .map((id) => getTarget(id))
+ .filter((t): t is AgentTarget => t !== undefined);
+}
+
+/**
+ * Initialize CodeGraph in the current project (for local installs).
+ * Unchanged from the pre-refactor version — agent-agnostic by nature.
*/
async function initializeLocalProject(clack: typeof import('@clack/prompts')): Promise {
const projectPath = process.cwd();
- // Lazy-load CodeGraph (requires native modules)
let CodeGraph: typeof import('../index').default;
try {
CodeGraph = (await import('../index')).default;
@@ -198,6 +348,3 @@ async function initializeLocalProject(clack: typeof import('@clack/prompts')): P
cg.close();
}
-
-// Re-export for CLI
-export type { InstallLocation };
diff --git a/src/installer/instructions-template.ts b/src/installer/instructions-template.ts
new file mode 100644
index 0000000..e7e4cdd
--- /dev/null
+++ b/src/installer/instructions-template.ts
@@ -0,0 +1,62 @@
+/**
+ * Agent-instructions template — the markdown body each agent target
+ * writes into its conventional instructions file (CLAUDE.md /
+ * AGENTS.md / codegraph.mdc / etc.).
+ *
+ * The body content is identical across agents because the codegraph
+ * usage advice is agent-agnostic — only the destination filename and
+ * any optional frontmatter (Cursor `.mdc`) varies per target.
+ *
+ * The legacy `claude-md-template.ts` re-exports these names for
+ * backwards compatibility with downstream importers.
+ */
+
+/** Markers used by the marker-based section replacement. */
+export const CODEGRAPH_SECTION_START = '';
+export const CODEGRAPH_SECTION_END = '';
+
+/**
+ * The full marker-delimited block written into each agent's
+ * instructions file. Includes the start/end markers so the section
+ * can be detected and replaced on re-install.
+ */
+export const INSTRUCTIONS_TEMPLATE = `${CODEGRAPH_SECTION_START}
+## CodeGraph
+
+This project has a CodeGraph MCP server (\`codegraph_*\` tools) configured. CodeGraph is a tree-sitter-parsed knowledge graph of every symbol, edge, and file. Reads are sub-millisecond and return structural information grep cannot.
+
+### When to prefer codegraph over native search
+
+Use codegraph for **structural** questions — what calls what, what would break, where is X defined, what is X's signature. Use native grep/read only for **literal text** queries (string contents, comments, log messages) or after you already have a specific file open.
+
+| Question | Tool |
+|---|---|
+| "Where is X defined?" / "Find symbol named X" | \`codegraph_search\` |
+| "What calls function Y?" | \`codegraph_callers\` |
+| "What does Y call?" | \`codegraph_callees\` |
+| "What would break if I changed Z?" | \`codegraph_impact\` |
+| "Show me Y's signature / source / docstring" | \`codegraph_node\` |
+| "Give me focused context for a task/area" | \`codegraph_context\` |
+| "Survey an unfamiliar module/topic" | \`codegraph_explore\` |
+| "What files exist under path/" | \`codegraph_files\` |
+| "Is the index healthy?" | \`codegraph_status\` |
+
+### Rules of thumb
+
+- **Trust codegraph results.** They come from a full AST parse. Do NOT re-verify them with grep — that's slower, less accurate, and wastes context.
+- **Don't grep first** when looking up a symbol by name. \`codegraph_search\` is faster and returns kind + location + signature in one call.
+- **Don't chain \`codegraph_search\` + \`codegraph_node\`** when you just want context — \`codegraph_context\` is one call.
+- **\`codegraph_explore\` is the heavy hitter** for unfamiliar areas — it returns full source from all relevant files in one call, but is token-heavy. If your harness supports parallel subagents (e.g., Claude Code's Task tool), spawn one for explore-class questions to keep main session context clean.
+- **Index lag**: the file watcher debounces ~500ms behind writes; don't re-query immediately after editing a file in the same turn.
+
+### If \`.codegraph/\` doesn't exist
+
+The MCP server returns "not initialized." Ask the user: *"I notice this project doesn't have CodeGraph initialized. Want me to run \`codegraph init -i\` to build the index?"*
+${CODEGRAPH_SECTION_END}`;
+
+/**
+ * Backwards-compat alias. Existing downstream code may import
+ * `CLAUDE_MD_TEMPLATE` from this module via the re-export shim in
+ * `claude-md-template.ts`.
+ */
+export const CLAUDE_MD_TEMPLATE = INSTRUCTIONS_TEMPLATE;
diff --git a/src/installer/targets/claude.ts b/src/installer/targets/claude.ts
new file mode 100644
index 0000000..dcd5c8a
--- /dev/null
+++ b/src/installer/targets/claude.ts
@@ -0,0 +1,254 @@
+/**
+ * Claude Code target — the historical default. Writes:
+ *
+ * - MCP server entry to `~/.claude.json` (global) or
+ * `./.claude.json` (local).
+ * - Permissions to `~/.claude/settings.json` (global) or
+ * `./.claude/settings.json` (local), gated on `autoAllow`.
+ * - Instructions to `~/.claude/CLAUDE.md` (global) or
+ * `./.claude/CLAUDE.md` (local).
+ *
+ * All paths and shapes ported verbatim from the original
+ * `config-writer.ts` so existing Claude Code installs upgrade in
+ * place — no migration on disk required.
+ */
+
+import * as fs from 'fs';
+import * as path from 'path';
+import * as os from 'os';
+import {
+ AgentTarget,
+ DetectionResult,
+ InstallOptions,
+ Location,
+ WriteResult,
+} from './types';
+import {
+ atomicWriteFileSync,
+ getCodeGraphPermissions,
+ getMcpServerConfig,
+ jsonDeepEqual,
+ readJsonFile,
+ removeMarkedSection,
+ replaceOrAppendMarkedSection,
+ writeJsonFile,
+} from './shared';
+import {
+ CODEGRAPH_SECTION_END,
+ CODEGRAPH_SECTION_START,
+ INSTRUCTIONS_TEMPLATE,
+} from '../instructions-template';
+
+function configDir(loc: Location): string {
+ return loc === 'global'
+ ? path.join(os.homedir(), '.claude')
+ : path.join(process.cwd(), '.claude');
+}
+function mcpJsonPath(loc: Location): string {
+ return loc === 'global'
+ ? path.join(os.homedir(), '.claude.json')
+ : path.join(process.cwd(), '.claude.json');
+}
+function settingsJsonPath(loc: Location): string {
+ return path.join(configDir(loc), 'settings.json');
+}
+function instructionsPath(loc: Location): string {
+ return path.join(configDir(loc), 'CLAUDE.md');
+}
+
+class ClaudeCodeTarget implements AgentTarget {
+ readonly id = 'claude' as const;
+ readonly displayName = 'Claude Code';
+ readonly docsUrl = 'https://docs.claude.com/en/docs/claude-code';
+
+ supportsLocation(_loc: Location): boolean {
+ return true;
+ }
+
+ detect(loc: Location): DetectionResult {
+ const mcpPath = mcpJsonPath(loc);
+ const config = readJsonFile(mcpPath);
+ const alreadyConfigured = !!config.mcpServers?.codegraph;
+ // For "installed" we infer from the existence of either the dir
+ // (global) or the project marker file (local). Cheap and avoids
+ // shelling out to `claude --version`.
+ const installed = loc === 'global'
+ ? fs.existsSync(configDir(loc)) || fs.existsSync(mcpPath)
+ : fs.existsSync(mcpPath) || fs.existsSync(configDir(loc));
+ return { installed, alreadyConfigured, configPath: mcpPath };
+ }
+
+ install(loc: Location, opts: InstallOptions): WriteResult {
+ const files: WriteResult['files'] = [];
+
+ // 1. MCP server entry
+ files.push(writeMcpEntry(loc));
+
+ // 2. Permissions (only when autoAllow)
+ if (opts.autoAllow) {
+ files.push(writePermissionsEntry(loc));
+ }
+
+ // 3. CLAUDE.md instructions
+ files.push(writeInstructionsEntry(loc));
+
+ return { files };
+ }
+
+ uninstall(loc: Location): WriteResult {
+ const files: WriteResult['files'] = [];
+
+ // 1. MCP server entry
+ const mcpPath = mcpJsonPath(loc);
+ const config = readJsonFile(mcpPath);
+ if (config.mcpServers?.codegraph) {
+ delete config.mcpServers.codegraph;
+ if (Object.keys(config.mcpServers).length === 0) {
+ delete config.mcpServers;
+ }
+ writeJsonFile(mcpPath, config);
+ files.push({ path: mcpPath, action: 'removed' });
+ } else {
+ files.push({ path: mcpPath, action: 'not-found' });
+ }
+
+ // 2. Permissions
+ const settingsPath = settingsJsonPath(loc);
+ const settings = readJsonFile(settingsPath);
+ if (Array.isArray(settings.permissions?.allow)) {
+ const before = settings.permissions.allow.length;
+ settings.permissions.allow = settings.permissions.allow.filter(
+ (p: string) => !p.startsWith('mcp__codegraph__'),
+ );
+ if (settings.permissions.allow.length !== before) {
+ if (settings.permissions.allow.length === 0) {
+ delete settings.permissions.allow;
+ }
+ if (Object.keys(settings.permissions).length === 0) {
+ delete settings.permissions;
+ }
+ writeJsonFile(settingsPath, settings);
+ files.push({ path: settingsPath, action: 'removed' });
+ } else {
+ files.push({ path: settingsPath, action: 'not-found' });
+ }
+ } else {
+ files.push({ path: settingsPath, action: 'not-found' });
+ }
+
+ // 3. Instructions
+ const instr = instructionsPath(loc);
+ const action = removeMarkedSection(instr, CODEGRAPH_SECTION_START, CODEGRAPH_SECTION_END);
+ files.push({ path: instr, action });
+
+ return { files };
+ }
+
+ printConfig(loc: Location): string {
+ const target = mcpJsonPath(loc);
+ const snippet = JSON.stringify({ mcpServers: { codegraph: getMcpServerConfig() } }, null, 2);
+ return `# Add to ${target}\n\n${snippet}\n`;
+ }
+
+ describePaths(loc: Location): string[] {
+ return [mcpJsonPath(loc), settingsJsonPath(loc), instructionsPath(loc)];
+ }
+}
+
+/**
+ * Per-file write helpers, exported so the legacy `config-writer.ts`
+ * shim can call only the named operation (writeMcpConfig writes ONLY
+ * the MCP entry, etc.) instead of `claudeTarget.install()` which
+ * writes all three files. Without this split the shims silently
+ * cause side effects callers don't expect.
+ */
+export function writeMcpEntry(loc: Location): WriteResult['files'][number] {
+ const file = mcpJsonPath(loc);
+ const existing = readJsonFile(file);
+ const before = existing.mcpServers?.codegraph;
+ const after = getMcpServerConfig();
+
+ if (jsonDeepEqual(before, after)) {
+ // Already exactly what we'd write — preserve byte-identical file.
+ return { path: file, action: 'unchanged' };
+ }
+ // 'created' here means: the file itself did not exist before this
+ // write. A pre-existing `.claude.json` containing other MCP servers
+ // (no `codegraph` key) is 'updated', not 'created' — we're adding
+ // an entry to a file that was already there. Codex uses a different
+ // idiom (empty-content => 'created') because its config.toml is
+ // ours alone to manage.
+ const action: 'created' | 'updated' = before ? 'updated' : (fs.existsSync(file) ? 'updated' : 'created');
+ if (!existing.mcpServers) existing.mcpServers = {};
+ existing.mcpServers.codegraph = after;
+ writeJsonFile(file, existing);
+ return { path: file, action };
+}
+
+export function writePermissionsEntry(loc: Location): WriteResult['files'][number] {
+ const file = settingsJsonPath(loc);
+ const settings = readJsonFile(file);
+ const created = !fs.existsSync(file);
+
+ if (!settings.permissions) settings.permissions = {};
+ if (!Array.isArray(settings.permissions.allow)) settings.permissions.allow = [];
+
+ const want = getCodeGraphPermissions();
+ const before = [...settings.permissions.allow];
+ for (const perm of want) {
+ if (!settings.permissions.allow.includes(perm)) {
+ settings.permissions.allow.push(perm);
+ }
+ }
+ if (jsonDeepEqual(before, settings.permissions.allow) && !created) {
+ return { path: file, action: 'unchanged' };
+ }
+ writeJsonFile(file, settings);
+ return { path: file, action: created ? 'created' : 'updated' };
+}
+
+export function writeInstructionsEntry(loc: Location): WriteResult['files'][number] {
+ const file = instructionsPath(loc);
+ // Ensure config dir exists (for global ~/.claude/).
+ const dir = path.dirname(file);
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
+
+ // Honor the legacy "unmarked ## CodeGraph" rewrite path that the
+ // original installer supported (some users hand-pasted a section
+ // before markers existed). Detect first and migrate inline.
+ if (fs.existsSync(file)) {
+ const content = fs.readFileSync(file, 'utf-8');
+ if (!content.includes(CODEGRAPH_SECTION_START)) {
+ const headerMatch = content.match(/\n## CodeGraph\n/);
+ if (headerMatch && headerMatch.index !== undefined) {
+ const sectionStart = headerMatch.index;
+ const after = content.substring(sectionStart + 1);
+ const nextHeader = after.match(/\n## (?!#)/);
+ const sectionEnd = nextHeader && nextHeader.index !== undefined
+ ? sectionStart + 1 + nextHeader.index
+ : content.length;
+ const merged =
+ content.substring(0, sectionStart) +
+ '\n' + INSTRUCTIONS_TEMPLATE +
+ content.substring(sectionEnd);
+ atomicWriteFileSync(file, merged);
+ return { path: file, action: 'updated' };
+ }
+ }
+ }
+
+ const action = replaceOrAppendMarkedSection(
+ file,
+ INSTRUCTIONS_TEMPLATE,
+ CODEGRAPH_SECTION_START,
+ CODEGRAPH_SECTION_END,
+ );
+ // Map the four-state action to WriteResult's action vocabulary.
+ const mapped: 'created' | 'updated' | 'unchanged' =
+ action === 'created' ? 'created'
+ : action === 'unchanged' ? 'unchanged'
+ : 'updated';
+ return { path: file, action: mapped };
+}
+
+export const claudeTarget: AgentTarget = new ClaudeCodeTarget();
diff --git a/src/installer/targets/codex.ts b/src/installer/targets/codex.ts
new file mode 100644
index 0000000..f3af705
--- /dev/null
+++ b/src/installer/targets/codex.ts
@@ -0,0 +1,181 @@
+/**
+ * OpenAI Codex CLI target.
+ *
+ * - MCP server entry to `~/.codex/config.toml` as the dotted-key
+ * table `[mcp_servers.codegraph]`. TOML — not JSON — handled by
+ * the narrow serializer in `./toml.ts`.
+ * - Instructions to `~/.codex/AGENTS.md`.
+ *
+ * Codex CLI as of 2026-05 has no project-local config concept —
+ * everything lives under `~/.codex/`. `supportsLocation('local')`
+ * returns false; the orchestrator skips Codex when the user picks
+ * the local install location.
+ *
+ * No permissions concept.
+ */
+
+import * as fs from 'fs';
+import * as path from 'path';
+import * as os from 'os';
+import {
+ AgentTarget,
+ DetectionResult,
+ InstallOptions,
+ Location,
+ WriteResult,
+} from './types';
+import {
+ atomicWriteFileSync,
+ getMcpServerConfig,
+ removeMarkedSection,
+ replaceOrAppendMarkedSection,
+} from './shared';
+import {
+ CODEGRAPH_SECTION_END,
+ CODEGRAPH_SECTION_START,
+ INSTRUCTIONS_TEMPLATE,
+} from '../instructions-template';
+import { buildTomlTable, removeTomlTable, upsertTomlTable } from './toml';
+
+const TOML_HEADER = 'mcp_servers.codegraph';
+
+function configDir(): string {
+ return path.join(os.homedir(), '.codex');
+}
+function tomlConfigPath(): string {
+ return path.join(configDir(), 'config.toml');
+}
+function instructionsPath(): string {
+ return path.join(configDir(), 'AGENTS.md');
+}
+
+class CodexTarget implements AgentTarget {
+ readonly id = 'codex' as const;
+ readonly displayName = 'Codex CLI';
+ readonly docsUrl = 'https://github.com/openai/codex';
+
+ supportsLocation(loc: Location): boolean {
+ return loc === 'global';
+ }
+
+ detect(loc: Location): DetectionResult {
+ if (loc !== 'global') {
+ return { installed: false, alreadyConfigured: false };
+ }
+ const tomlPath = tomlConfigPath();
+ let alreadyConfigured = false;
+ if (fs.existsSync(tomlPath)) {
+ try {
+ const content = fs.readFileSync(tomlPath, 'utf-8');
+ alreadyConfigured = content.includes(`[${TOML_HEADER}]`);
+ } catch { /* ignore */ }
+ }
+ const installed = fs.existsSync(configDir());
+ return { installed, alreadyConfigured, configPath: tomlPath };
+ }
+
+ install(loc: Location, _opts: InstallOptions): WriteResult {
+ if (loc !== 'global') {
+ return {
+ files: [],
+ notes: ['Codex CLI has no project-local config — re-run with --location=global to install.'],
+ };
+ }
+ const files: WriteResult['files'] = [];
+
+ files.push(writeMcpEntry());
+ files.push(writeInstructionsEntry());
+
+ return { files };
+ }
+
+ uninstall(loc: Location): WriteResult {
+ if (loc !== 'global') return { files: [] };
+ const files: WriteResult['files'] = [];
+
+ const tomlPath = tomlConfigPath();
+ if (fs.existsSync(tomlPath)) {
+ const content = fs.readFileSync(tomlPath, 'utf-8');
+ const { content: nextContent, action } = removeTomlTable(content, TOML_HEADER);
+ if (action === 'removed') {
+ if (nextContent.trim() === '') {
+ try { fs.unlinkSync(tomlPath); } catch { /* ignore */ }
+ } else {
+ atomicWriteFileSync(tomlPath, nextContent.trimEnd() + '\n');
+ }
+ files.push({ path: tomlPath, action: 'removed' });
+ } else {
+ files.push({ path: tomlPath, action: 'not-found' });
+ }
+ } else {
+ files.push({ path: tomlPath, action: 'not-found' });
+ }
+
+ const instr = instructionsPath();
+ const instrAction = removeMarkedSection(instr, CODEGRAPH_SECTION_START, CODEGRAPH_SECTION_END);
+ files.push({ path: instr, action: instrAction });
+
+ return { files };
+ }
+
+ printConfig(loc: Location): string {
+ if (loc !== 'global') {
+ return '# Codex CLI has no project-local config — use --location=global.\n';
+ }
+ const block = buildCodegraphBlock();
+ return `# Add to ${tomlConfigPath()}\n\n${block}\n`;
+ }
+
+ describePaths(loc: Location): string[] {
+ if (loc !== 'global') return [];
+ return [tomlConfigPath(), instructionsPath()];
+ }
+}
+
+function buildCodegraphBlock(): string {
+ const mcp = getMcpServerConfig();
+ return buildTomlTable(TOML_HEADER, {
+ command: mcp.command,
+ args: mcp.args,
+ });
+}
+
+function writeMcpEntry(): WriteResult['files'][number] {
+ const file = tomlConfigPath();
+ 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 { content: nextContent, action } = upsertTomlTable(existing, TOML_HEADER, block);
+
+ if (action === 'unchanged') {
+ return { path: file, action: 'unchanged' };
+ }
+ atomicWriteFileSync(file, nextContent);
+ return { path: file, action: created ? 'created' : 'updated' };
+}
+
+function writeInstructionsEntry(): WriteResult['files'][number] {
+ const file = instructionsPath();
+ const dir = path.dirname(file);
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
+
+ const action = replaceOrAppendMarkedSection(
+ file,
+ INSTRUCTIONS_TEMPLATE,
+ CODEGRAPH_SECTION_START,
+ CODEGRAPH_SECTION_END,
+ );
+ const mapped: 'created' | 'updated' | 'unchanged' =
+ action === 'created' ? 'created'
+ : action === 'unchanged' ? 'unchanged'
+ : 'updated';
+ return { path: file, action: mapped };
+}
+
+export const codexTarget: AgentTarget = new CodexTarget();
diff --git a/src/installer/targets/cursor.ts b/src/installer/targets/cursor.ts
new file mode 100644
index 0000000..850b6fc
--- /dev/null
+++ b/src/installer/targets/cursor.ts
@@ -0,0 +1,240 @@
+/**
+ * Cursor target.
+ *
+ * - MCP server entry to `~/.cursor/mcp.json` (global) or
+ * `./.cursor/mcp.json` (local). Same `{mcpServers: {...}}` shape
+ * as Claude.
+ * - Instructions to `./.cursor/rules/codegraph.mdc` (project-local
+ * ONLY). Cursor's rules system is a project-scoped surface;
+ * global cursor rules aren't a stable convention as of 2026-05.
+ * For `--location=global`, only mcp.json is written.
+ *
+ * ## Why we hardcode `--path` for Cursor
+ *
+ * Cursor launches MCP-server subprocesses with a working directory
+ * that ISN'T the workspace root AND doesn't pass `rootUri` /
+ * `workspaceFolders` in the MCP initialize call. The codegraph MCP
+ * server's `process.cwd()` fallback therefore misses the workspace's
+ * `.codegraph/` and reports "not initialized" on every tool call.
+ *
+ * So we inject `--path` into the args ourselves:
+ *
+ * - `local` install: absolute path (we know it at install time).
+ * - `global` install: `${workspaceFolder}` — Cursor expands this to
+ * the open workspace's root, giving us per-workspace behavior
+ * from a single global config.
+ *
+ * Codex and Claude do not need this — they launch MCP servers with
+ * `cwd = workspace` and pass `rootUri`, respectively.
+ *
+ * No permissions concept — Cursor doesn't have an auto-allow list
+ * the installer can populate. `autoAllow` is silently ignored.
+ */
+
+import * as fs from 'fs';
+import * as path from 'path';
+import * as os from 'os';
+import {
+ AgentTarget,
+ DetectionResult,
+ InstallOptions,
+ Location,
+ WriteResult,
+} from './types';
+import {
+ atomicWriteFileSync,
+ getMcpServerConfig,
+ jsonDeepEqual,
+ readJsonFile,
+ removeMarkedSection,
+ replaceOrAppendMarkedSection,
+ writeJsonFile,
+} from './shared';
+import {
+ CODEGRAPH_SECTION_END,
+ CODEGRAPH_SECTION_START,
+ INSTRUCTIONS_TEMPLATE,
+} from '../instructions-template';
+
+function mcpJsonPath(loc: Location): string {
+ return loc === 'global'
+ ? path.join(os.homedir(), '.cursor', 'mcp.json')
+ : path.join(process.cwd(), '.cursor', 'mcp.json');
+}
+/**
+ * Cursor "rules" file. Only meaningful for the project-local
+ * location — Cursor reads `.cursor/rules/*.mdc` from the workspace
+ * root. There is no global equivalent.
+ */
+function rulesPath(): string {
+ return path.join(process.cwd(), '.cursor', 'rules', 'codegraph.mdc');
+}
+
+/**
+ * Cursor `.mdc` rules use YAML-ish frontmatter. `alwaysApply: true`
+ * makes the rule load on every conversation regardless of file
+ * patterns — appropriate for a tool-usage guide that's relevant
+ * whenever the user is asking the agent to navigate code.
+ */
+const MDC_FRONTMATTER = [
+ '---',
+ 'description: CodeGraph MCP usage guide — when to use which tool',
+ 'alwaysApply: true',
+ '---',
+ '',
+].join('\n');
+
+class CursorTarget implements AgentTarget {
+ readonly id = 'cursor' as const;
+ readonly displayName = 'Cursor';
+ readonly docsUrl = 'https://docs.cursor.com/context/model-context-protocol';
+
+ supportsLocation(_loc: Location): boolean {
+ // Both supported, but `local` writes more files (mcp.json + rules);
+ // `global` writes only mcp.json. The orchestrator surfaces the
+ // difference via describePaths.
+ return true;
+ }
+
+ detect(loc: Location): DetectionResult {
+ const mcpPath = mcpJsonPath(loc);
+ const config = readJsonFile(mcpPath);
+ const alreadyConfigured = !!config.mcpServers?.codegraph;
+ // "Installed" heuristic: does ~/.cursor exist (global) or has the
+ // user opted into a project-local cursor config dir?
+ const installed = loc === 'global'
+ ? fs.existsSync(path.join(os.homedir(), '.cursor'))
+ : fs.existsSync(path.join(process.cwd(), '.cursor'));
+ return { installed, alreadyConfigured, configPath: mcpPath };
+ }
+
+ install(loc: Location, _opts: InstallOptions): WriteResult {
+ const files: WriteResult['files'] = [];
+
+ files.push(writeMcpEntry(loc));
+
+ if (loc === 'local') {
+ files.push(writeRulesEntry());
+ }
+
+ return {
+ files,
+ notes: ['Restart Cursor for MCP changes to take effect.'],
+ };
+ }
+
+ uninstall(loc: Location): WriteResult {
+ const files: WriteResult['files'] = [];
+
+ const mcpPath = mcpJsonPath(loc);
+ const config = readJsonFile(mcpPath);
+ if (config.mcpServers?.codegraph) {
+ delete config.mcpServers.codegraph;
+ if (Object.keys(config.mcpServers).length === 0) {
+ delete config.mcpServers;
+ }
+ writeJsonFile(mcpPath, config);
+ files.push({ path: mcpPath, action: 'removed' });
+ } else {
+ files.push({ path: mcpPath, action: 'not-found' });
+ }
+
+ if (loc === 'local') {
+ const rules = rulesPath();
+ const action = removeMarkedSection(rules, CODEGRAPH_SECTION_START, CODEGRAPH_SECTION_END);
+ files.push({ path: rules, action });
+ }
+
+ return { files };
+ }
+
+ printConfig(loc: Location): string {
+ const target = mcpJsonPath(loc);
+ const snippet = JSON.stringify({ mcpServers: { codegraph: buildCursorMcpConfig(loc) } }, null, 2);
+ return `# Add to ${target}\n\n${snippet}\n`;
+ }
+
+ describePaths(loc: Location): string[] {
+ return loc === 'local'
+ ? [mcpJsonPath(loc), rulesPath()]
+ : [mcpJsonPath(loc)];
+ }
+
+ /**
+ * Write the project-local `.cursor/rules/codegraph.mdc` file. Used
+ * by `codegraph init` to bootstrap projects that have only the
+ * global `~/.cursor/mcp.json` — without the rules file, the Cursor
+ * agent has no signal to prefer codegraph over native grep.
+ */
+ wireProjectSurfaces(): WriteResult {
+ return { files: [writeRulesEntry()] };
+ }
+}
+
+/**
+ * Build the codegraph MCP-server config for Cursor at the given
+ * location. Inherits the shared shape ({type, command, args}) and
+ * appends `--path` so the spawned MCP server resolves the workspace
+ * correctly regardless of Cursor's launch cwd. See file header for
+ * the full rationale.
+ */
+function buildCursorMcpConfig(loc: Location): { type: string; command: string; args: string[] } {
+ const base = getMcpServerConfig();
+ const pathArg = loc === 'local' ? process.cwd() : '${workspaceFolder}';
+ return { ...base, args: [...base.args, '--path', pathArg] };
+}
+
+function writeMcpEntry(loc: Location): WriteResult['files'][number] {
+ const file = mcpJsonPath(loc);
+ const existing = readJsonFile(file);
+ const before = existing.mcpServers?.codegraph;
+ const after = buildCursorMcpConfig(loc);
+
+ if (jsonDeepEqual(before, after)) {
+ return { path: file, action: 'unchanged' };
+ }
+ const action: 'created' | 'updated' = before ? 'updated' : (fs.existsSync(file) ? 'updated' : 'created');
+ if (!existing.mcpServers) existing.mcpServers = {};
+ existing.mcpServers.codegraph = after;
+ writeJsonFile(file, existing);
+ return { path: file, action };
+}
+
+function writeRulesEntry(): WriteResult['files'][number] {
+ const file = rulesPath();
+ const dir = path.dirname(file);
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
+
+ // Body is frontmatter + the shared instructions block. The
+ // marker-based replacement targets only the marker block, so the
+ // frontmatter is preserved across re-runs.
+ const body = MDC_FRONTMATTER + INSTRUCTIONS_TEMPLATE;
+
+ if (!fs.existsSync(file)) {
+ atomicWriteFileSync(file, body + '\n');
+ return { path: file, action: 'created' };
+ }
+
+ // For .mdc files we own outright, do byte-equality first.
+ const existing = fs.readFileSync(file, 'utf-8');
+ const wantWithNL = body + '\n';
+ if (existing === wantWithNL) {
+ return { path: file, action: 'unchanged' };
+ }
+
+ // Otherwise, marker-based section swap (preserves any user-added
+ // content outside the markers).
+ const action = replaceOrAppendMarkedSection(
+ file,
+ INSTRUCTIONS_TEMPLATE,
+ CODEGRAPH_SECTION_START,
+ CODEGRAPH_SECTION_END,
+ );
+ const mapped: 'created' | 'updated' | 'unchanged' =
+ action === 'created' ? 'created'
+ : action === 'unchanged' ? 'unchanged'
+ : 'updated';
+ return { path: file, action: mapped };
+}
+
+export const cursorTarget: AgentTarget = new CursorTarget();
diff --git a/src/installer/targets/opencode.ts b/src/installer/targets/opencode.ts
new file mode 100644
index 0000000..ba6b8d3
--- /dev/null
+++ b/src/installer/targets/opencode.ts
@@ -0,0 +1,133 @@
+/**
+ * opencode target.
+ *
+ * - MCP server entry to `~/.config/opencode/opencode.json` (global,
+ * XDG-style; `%APPDATA%/opencode/opencode.json` on Windows) or
+ * `./opencode.json` (local).
+ * - No instructions file built in (opencode doesn't have a
+ * conventional agent-rules surface as of 2026-05).
+ * - No permissions concept.
+ *
+ * Config shape uses opencode's wrapper:
+ * {
+ * "$schema": "https://opencode.ai/config.json",
+ * "mcp": { "codegraph": { "type": "local", "command": [...], "enabled": true } }
+ * }
+ *
+ * The shape differs from Claude/Cursor — opencode uses `mcp.`
+ * (not `mcpServers`), takes `command` as a string array combining
+ * binary + args, and includes an explicit `enabled` flag.
+ */
+
+import * as fs from 'fs';
+import * as path from 'path';
+import * as os from 'os';
+import {
+ AgentTarget,
+ DetectionResult,
+ InstallOptions,
+ Location,
+ WriteResult,
+} from './types';
+import {
+ jsonDeepEqual,
+ readJsonFile,
+ writeJsonFile,
+} from './shared';
+
+function globalConfigDir(): string {
+ if (process.platform === 'win32') {
+ const appData = process.env.APPDATA ?? path.join(os.homedir(), 'AppData', 'Roaming');
+ return path.join(appData, 'opencode');
+ }
+ // XDG_CONFIG_HOME if set, else ~/.config — matches opencode's docs.
+ const xdg = process.env.XDG_CONFIG_HOME && process.env.XDG_CONFIG_HOME.trim().length > 0
+ ? process.env.XDG_CONFIG_HOME
+ : path.join(os.homedir(), '.config');
+ return path.join(xdg, 'opencode');
+}
+
+function configPath(loc: Location): string {
+ return loc === 'global'
+ ? path.join(globalConfigDir(), 'opencode.json')
+ : path.join(process.cwd(), 'opencode.json');
+}
+
+function getOpencodeServerEntry(): { type: string; command: string[]; enabled: boolean } {
+ return {
+ type: 'local',
+ command: ['codegraph', 'serve', '--mcp'],
+ enabled: true,
+ };
+}
+
+class OpencodeTarget implements AgentTarget {
+ readonly id = 'opencode' as const;
+ readonly displayName = 'opencode';
+ readonly docsUrl = 'https://opencode.ai/docs/config';
+
+ supportsLocation(_loc: Location): boolean {
+ return true;
+ }
+
+ detect(loc: Location): DetectionResult {
+ const file = configPath(loc);
+ const config = readJsonFile(file);
+ const alreadyConfigured = !!config.mcp?.codegraph;
+ const installed = loc === 'global'
+ ? fs.existsSync(globalConfigDir())
+ : fs.existsSync(file);
+ return { installed, alreadyConfigured, configPath: file };
+ }
+
+ install(loc: Location, _opts: InstallOptions): WriteResult {
+ const file = configPath(loc);
+ const existing = readJsonFile(file);
+ const before = existing.mcp?.codegraph;
+ const after = getOpencodeServerEntry();
+
+ if (jsonDeepEqual(before, after)) {
+ return { files: [{ path: file, action: 'unchanged' }] };
+ }
+
+ const created = !fs.existsSync(file);
+ if (!existing.$schema) existing.$schema = 'https://opencode.ai/config.json';
+ if (!existing.mcp) existing.mcp = {};
+ existing.mcp.codegraph = after;
+ writeJsonFile(file, existing);
+ return {
+ files: [{ path: file, action: created ? 'created' : 'updated' }],
+ };
+ }
+
+ uninstall(loc: Location): WriteResult {
+ const file = configPath(loc);
+ const config = readJsonFile(file);
+ if (!config.mcp?.codegraph) {
+ return { files: [{ path: file, action: 'not-found' }] };
+ }
+ delete config.mcp.codegraph;
+ if (Object.keys(config.mcp).length === 0) {
+ delete config.mcp;
+ }
+ // If the file is now degenerate (only $schema or empty), leave it
+ // — the user may have other config we shouldn't nuke.
+ writeJsonFile(file, config);
+ return { files: [{ path: file, action: 'removed' }] };
+ }
+
+ printConfig(loc: Location): string {
+ const target = configPath(loc);
+ const snippet = JSON.stringify({
+ $schema: 'https://opencode.ai/config.json',
+ mcp: { codegraph: getOpencodeServerEntry() },
+ }, null, 2);
+ return `# Add to ${target}\n\n${snippet}\n`;
+ }
+
+ describePaths(loc: Location): string[] {
+ return [configPath(loc)];
+ }
+}
+
+export const opencodeTarget: AgentTarget = new OpencodeTarget();
diff --git a/src/installer/targets/registry.ts b/src/installer/targets/registry.ts
new file mode 100644
index 0000000..e671fd1
--- /dev/null
+++ b/src/installer/targets/registry.ts
@@ -0,0 +1,83 @@
+/**
+ * Registry of all known agent targets.
+ *
+ * Adding a new target = create `targets/.ts` exporting an
+ * `AgentTarget`, then add it to the array below. Order here is the
+ * order they appear in the multiselect prompt, in `--target=all`,
+ * and in `--print-config`'s help listing — keep it stable.
+ */
+
+import { AgentTarget, Location, TargetId } from './types';
+import { claudeTarget } from './claude';
+import { cursorTarget } from './cursor';
+import { codexTarget } from './codex';
+import { opencodeTarget } from './opencode';
+
+export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([
+ claudeTarget,
+ cursorTarget,
+ codexTarget,
+ opencodeTarget,
+]);
+
+export function getTarget(id: string): AgentTarget | undefined {
+ return ALL_TARGETS.find((t) => t.id === id);
+}
+
+export function listTargetIds(): TargetId[] {
+ return ALL_TARGETS.map((t) => t.id);
+}
+
+/**
+ * Run `detect()` for every target at the given location. Returns the
+ * full registry zipped with detection results — orchestrator uses
+ * this to seed the multiselect prompt with installed agents
+ * pre-checked.
+ */
+export function detectAll(loc: Location): Array<{
+ target: AgentTarget;
+ detection: ReturnType;
+}> {
+ return ALL_TARGETS.map((target) => ({
+ target,
+ detection: target.detect(loc),
+ }));
+}
+
+/**
+ * Resolve a `--target=` flag value to a list of `AgentTarget`
+ * instances. Accepts:
+ *
+ * - `auto` — return all targets whose `detect().installed` is true,
+ * or `['claude']` as a fallback if none detected (least-surprise
+ * for existing users).
+ * - `all` — every target in the registry.
+ * - `none` — empty list (caller skips agent writes entirely).
+ * - csv list — `'claude,cursor'` etc. Unknown ids throw.
+ */
+export function resolveTargetFlag(value: string, loc: Location): AgentTarget[] {
+ if (value === 'none') return [];
+ if (value === 'all') return [...ALL_TARGETS];
+ if (value === 'auto') {
+ const detected = detectAll(loc).filter(({ detection }) => detection.installed);
+ if (detected.length > 0) return detected.map(({ target }) => target);
+ const fallback = getTarget('claude');
+ return fallback ? [fallback] : [];
+ }
+
+ const ids = value.split(',').map((s) => s.trim()).filter(Boolean);
+ const resolved: AgentTarget[] = [];
+ const unknown: string[] = [];
+ for (const id of ids) {
+ const t = getTarget(id);
+ if (t) resolved.push(t);
+ else unknown.push(id);
+ }
+ if (unknown.length > 0) {
+ const known = listTargetIds().join(', ');
+ throw new Error(
+ `Unknown --target id(s): ${unknown.join(', ')}. Known: ${known}, plus 'auto' / 'all' / 'none'.`,
+ );
+ }
+ return resolved;
+}
diff --git a/src/installer/targets/shared.ts b/src/installer/targets/shared.ts
new file mode 100644
index 0000000..6d54ab5
--- /dev/null
+++ b/src/installer/targets/shared.ts
@@ -0,0 +1,206 @@
+/**
+ * Helpers shared across `AgentTarget` implementations.
+ *
+ * Lifted from the original `config-writer.ts` so each target can
+ * compose them without inheritance. Kept deliberately small — the
+ * targets are different enough (JSON vs TOML vs Markdown, varying
+ * idempotency markers) that a base class would force the awkward
+ * shape onto everyone.
+ */
+
+import * as fs from 'fs';
+import * as path from 'path';
+
+/**
+ * The MCP-server config block codegraph injects. Same shape across
+ * all JSON-shaped agent configs (Claude, Cursor, opencode), only the
+ * surrounding wrapper differs. Codex (TOML) builds its own block.
+ */
+export function getMcpServerConfig(): { type: string; command: string; args: string[] } {
+ return {
+ type: 'stdio',
+ command: 'codegraph',
+ args: ['serve', '--mcp'],
+ };
+}
+
+/**
+ * Permissions list for Claude `settings.json`. Other targets that
+ * have a permissions concept can compose this list directly. The
+ * permission strings follow Claude's `mcp____` format.
+ */
+export function getCodeGraphPermissions(): string[] {
+ return [
+ 'mcp__codegraph__codegraph_search',
+ 'mcp__codegraph__codegraph_context',
+ 'mcp__codegraph__codegraph_callers',
+ 'mcp__codegraph__codegraph_callees',
+ 'mcp__codegraph__codegraph_impact',
+ 'mcp__codegraph__codegraph_node',
+ 'mcp__codegraph__codegraph_status',
+ ];
+}
+
+/**
+ * Read a JSON file, returning `{}` when missing or unparseable.
+ *
+ * Unparseable files are backed up to `.backup` BEFORE we return
+ * `{}` — so an idempotent re-run never silently deletes a user's
+ * existing config that happened to break JSON parse temporarily.
+ */
+export function readJsonFile(filePath: string): Record {
+ if (!fs.existsSync(filePath)) {
+ return {};
+ }
+ try {
+ return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
+ } catch (err) {
+ const msg = err instanceof Error ? err.message : String(err);
+ console.warn(` Warning: Could not parse ${path.basename(filePath)}: ${msg}`);
+ console.warn(` A backup will be created before overwriting.`);
+ try {
+ fs.copyFileSync(filePath, filePath + '.backup');
+ } catch { /* ignore backup failure */ }
+ return {};
+ }
+}
+
+/**
+ * Write a file atomically: write to `.tmp.`, then rename.
+ *
+ * Prevents corruption if the process crashes mid-write. The temp
+ * file is cleaned up on rename failure.
+ */
+export function atomicWriteFileSync(filePath: string, content: string): void {
+ const dir = path.dirname(filePath);
+ if (!fs.existsSync(dir)) {
+ fs.mkdirSync(dir, { recursive: true });
+ }
+ const tmpPath = filePath + '.tmp.' + process.pid;
+ try {
+ fs.writeFileSync(tmpPath, content);
+ fs.renameSync(tmpPath, filePath);
+ } catch (err) {
+ try { fs.unlinkSync(tmpPath); } catch { /* ignore */ }
+ throw err;
+ }
+}
+
+/**
+ * Atomic JSON write. Trailing newline matches the convention every
+ * existing target had — preserves diff-friendly file shape.
+ */
+export function writeJsonFile(filePath: string, data: Record): void {
+ atomicWriteFileSync(filePath, JSON.stringify(data, null, 2) + '\n');
+}
+
+/**
+ * Compare two JSON values for deep equality, ignoring key order.
+ *
+ * Used for idempotency: when the on-disk config already exactly
+ * matches what we'd write, return action=`unchanged` instead of
+ * re-writing (and emitting a confusing "Updated" log line).
+ */
+export function jsonDeepEqual(a: unknown, b: unknown): boolean {
+ if (a === b) return true;
+ if (typeof a !== typeof b) return false;
+ if (a === null || b === null) return a === b;
+ if (typeof a !== 'object') return false;
+ if (Array.isArray(a) !== Array.isArray(b)) return false;
+ if (Array.isArray(a) && Array.isArray(b)) {
+ if (a.length !== b.length) return false;
+ return a.every((v, i) => jsonDeepEqual(v, b[i]));
+ }
+ const ao = a as Record;
+ const bo = b as Record;
+ const ak = Object.keys(ao).sort();
+ const bk = Object.keys(bo).sort();
+ if (ak.length !== bk.length) return false;
+ if (!ak.every((k, i) => k === bk[i])) return false;
+ return ak.every((k) => jsonDeepEqual(ao[k], bo[k]));
+}
+
+/**
+ * Replace or append a marker-delimited section in a markdown-ish file.
+ *
+ * Used by Claude / Codex for the ` ... ` block. Preserves all content outside the
+ * markers verbatim.
+ *
+ * Returns `created` when the file didn't exist; `updated` when
+ * markers were found and content swapped; `appended` when markers
+ * weren't found and section was added at end. `unchanged` when the
+ * existing block already matches `body`.
+ */
+export function replaceOrAppendMarkedSection(
+ filePath: string,
+ body: string,
+ startMarker: string,
+ endMarker: string,
+): 'created' | 'updated' | 'appended' | 'unchanged' {
+ if (!fs.existsSync(filePath)) {
+ atomicWriteFileSync(filePath, body + '\n');
+ return 'created';
+ }
+
+ const content = fs.readFileSync(filePath, 'utf-8');
+ const startIdx = content.indexOf(startMarker);
+ const endIdx = content.indexOf(endMarker);
+
+ if (startIdx !== -1 && endIdx > startIdx) {
+ const existingBlock = content.substring(startIdx, endIdx + endMarker.length);
+ if (existingBlock === body) {
+ return 'unchanged';
+ }
+ const before = content.substring(0, startIdx);
+ const after = content.substring(endIdx + endMarker.length);
+ atomicWriteFileSync(filePath, before + body + after);
+ return 'updated';
+ }
+
+ // No markers — append. Preserve existing content with a separating
+ // blank line.
+ const trimmed = content.trimEnd();
+ const sep = trimmed.length > 0 ? '\n\n' : '';
+ atomicWriteFileSync(filePath, trimmed + sep + body + '\n');
+ return 'appended';
+}
+
+/**
+ * Inverse of `replaceOrAppendMarkedSection`. Strips the marker
+ * block from `filePath` if present. If the file becomes empty after
+ * removal, deletes the file entirely (matches the existing Claude
+ * uninstall behavior).
+ *
+ * Returns `removed` when content was stripped, `not-found` when
+ * the markers weren't present, `kept` when the file didn't exist.
+ */
+export function removeMarkedSection(
+ filePath: string,
+ startMarker: string,
+ endMarker: string,
+): 'removed' | 'not-found' | 'kept' {
+ if (!fs.existsSync(filePath)) return 'kept';
+
+ let content: string;
+ try {
+ content = fs.readFileSync(filePath, 'utf-8');
+ } catch {
+ return 'kept';
+ }
+
+ const startIdx = content.indexOf(startMarker);
+ const endIdx = content.indexOf(endMarker);
+ if (startIdx === -1 || endIdx <= startIdx) return 'not-found';
+
+ const before = content.substring(0, startIdx).trimEnd();
+ const after = content.substring(endIdx + endMarker.length).trimStart();
+ const joined = before + (before && after ? '\n\n' : '') + after;
+
+ if (joined.trim() === '') {
+ try { fs.unlinkSync(filePath); } catch { /* ignore */ }
+ } else {
+ atomicWriteFileSync(filePath, joined.trim() + '\n');
+ }
+ return 'removed';
+}
diff --git a/src/installer/targets/toml.ts b/src/installer/targets/toml.ts
new file mode 100644
index 0000000..29348a7
--- /dev/null
+++ b/src/installer/targets/toml.ts
@@ -0,0 +1,154 @@
+/**
+ * Tiny TOML helpers — just enough to inject / replace / remove a
+ * single dotted-key table block (`[mcp_servers.codegraph]`) inside an
+ * existing `~/.codex/config.toml`. We deliberately do NOT try to be a
+ * general TOML parser/serializer; that would mean pulling in a
+ * dependency (~50KB) for ~6 lines of output.
+ *
+ * Strategy: treat the file as text. Find the `[mcp_servers.codegraph]`
+ * header line, splice it (and the lines that follow it until the next
+ * `[...]` header or EOF) in or out. Everything outside that block is
+ * preserved verbatim, byte-for-byte.
+ *
+ * Limitations (acceptable for our narrow use):
+ * - Only handles top-level table headers; not array-of-tables or
+ * subtables nested inside `[mcp_servers]` itself (we always write
+ * the full dotted key `[mcp_servers.codegraph]`).
+ * - Doesn't validate sibling TOML — if the file is malformed
+ * elsewhere, our injection won't fix it but won't make it worse.
+ * - Quotes string values with double quotes; escapes `\` and `"`.
+ */
+
+/**
+ * Serialize a record into the body lines of a TOML table. Values
+ * supported: string, string[]. Other types throw — the codex MCP
+ * config only needs these two.
+ */
+export function serializeTomlTableBody(values: Record): string {
+ const lines: string[] = [];
+ for (const [key, value] of Object.entries(values)) {
+ if (typeof value === 'string') {
+ lines.push(`${key} = ${quoteString(value)}`);
+ } else if (Array.isArray(value) && value.every((v) => typeof v === 'string')) {
+ const parts = value.map(quoteString).join(', ');
+ lines.push(`${key} = [${parts}]`);
+ } else {
+ throw new Error(`Unsupported TOML value type for key "${key}"`);
+ }
+ }
+ return lines.join('\n');
+}
+
+function quoteString(s: string): string {
+ // TOML basic strings: backslash and double-quote escapes; control
+ // chars not expected in our payload (paths/args).
+ return '"' + s.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"';
+}
+
+/**
+ * Build a full table block: header line + body. Suitable for direct
+ * insertion into a TOML file.
+ */
+export function buildTomlTable(header: string, values: Record): string {
+ return `[${header}]\n${serializeTomlTableBody(values)}`;
+}
+
+/**
+ * Insert or replace a top-level dotted-key TOML table block in the
+ * given file content. Preserves all other content verbatim.
+ *
+ * Returns `'inserted'` when the table was newly added, `'replaced'`
+ * when an existing one was rewritten, `'unchanged'` when the
+ * existing block already matches `block` byte-for-byte.
+ */
+export function upsertTomlTable(
+ fileContent: string,
+ header: string,
+ block: string,
+): { content: string; action: 'inserted' | 'replaced' | 'unchanged' } {
+ const headerLine = `[${header}]`;
+ const headerIdx = findHeaderIndex(fileContent, headerLine);
+
+ if (headerIdx === -1) {
+ // Insert at end with separating blank line if there's existing content.
+ const trimmed = fileContent.trimEnd();
+ const sep = trimmed.length > 0 ? '\n\n' : '';
+ return {
+ content: trimmed + sep + block + '\n',
+ action: 'inserted',
+ };
+ }
+
+ // Find the end of this block: next `[...]` header (at line start) or EOF.
+ const blockEnd = findNextTableHeader(fileContent, headerIdx + headerLine.length);
+ const existingBlock = fileContent.substring(headerIdx, blockEnd).replace(/\n+$/, '');
+
+ if (existingBlock === block) {
+ return { content: fileContent, action: 'unchanged' };
+ }
+
+ const before = fileContent.substring(0, headerIdx);
+ const after = fileContent.substring(blockEnd);
+ // Trim trailing blank lines from `before` (we'll re-add one) and
+ // leading blank lines from `after` so the file shape stays clean.
+ const beforeClean = before.replace(/\n+$/, '');
+ const afterClean = after.replace(/^\n+/, '');
+ const sepBefore = beforeClean.length > 0 ? '\n\n' : '';
+ const sepAfter = afterClean.length > 0 ? '\n\n' : '\n';
+ return {
+ content: beforeClean + sepBefore + block + sepAfter + afterClean,
+ action: 'replaced',
+ };
+}
+
+/**
+ * Remove a top-level dotted-key TOML table block. Returns the
+ * possibly-empty new content + an action flag.
+ */
+export function removeTomlTable(
+ fileContent: string,
+ header: string,
+): { content: string; action: 'removed' | 'not-found' } {
+ const headerLine = `[${header}]`;
+ const headerIdx = findHeaderIndex(fileContent, headerLine);
+ if (headerIdx === -1) return { content: fileContent, action: 'not-found' };
+
+ const blockEnd = findNextTableHeader(fileContent, headerIdx + headerLine.length);
+ const before = fileContent.substring(0, headerIdx).replace(/\n+$/, '');
+ const after = fileContent.substring(blockEnd).replace(/^\n+/, '');
+ const joined = before + (before && after ? '\n\n' : '') + after;
+ return { content: joined, action: 'removed' };
+}
+
+/**
+ * Locate the byte index of a header line (`[foo.bar]`) when it
+ * appears at the start of a line. Returns -1 if not found.
+ */
+function findHeaderIndex(content: string, headerLine: string): number {
+ // Search BOL or right after a newline.
+ if (content.startsWith(headerLine)) return 0;
+ const needle = '\n' + headerLine;
+ const idx = content.indexOf(needle);
+ return idx === -1 ? -1 : idx + 1;
+}
+
+/**
+ * Find the byte index of the next top-level `[...]` table header
+ * (excluding array-of-tables `[[...]]`) starting from `from`, or
+ * return content length when none.
+ */
+function findNextTableHeader(content: string, from: number): number {
+ // Look for "\n[" but skip "\n[[" (array of tables).
+ let i = from;
+ while (i < content.length) {
+ const nlIdx = content.indexOf('\n[', i);
+ if (nlIdx === -1) return content.length;
+ if (content[nlIdx + 2] === '[') {
+ // [[...]] — keep searching past it.
+ i = nlIdx + 2;
+ continue;
+ }
+ return nlIdx + 1;
+ }
+ return content.length;
+}
diff --git a/src/installer/targets/types.ts b/src/installer/targets/types.ts
new file mode 100644
index 0000000..fdff0d7
--- /dev/null
+++ b/src/installer/targets/types.ts
@@ -0,0 +1,121 @@
+/**
+ * Agent target abstraction for the installer.
+ *
+ * Each MCP-capable agent (Claude Code, Cursor, Codex CLI, opencode, ...)
+ * implements this interface so the installer orchestrator can write the
+ * right MCP-server config + instructions file + permissions for that
+ * agent without baking client-specific paths into core code. Adding a
+ * new agent = one new file in `targets/` + one entry in `registry.ts`.
+ *
+ * Closes the Claude-locked installer issue (upstream #137). The
+ * runtime MCP server is already agent-agnostic; this brings the
+ * installer to the same surface.
+ */
+
+export type Location = 'global' | 'local';
+
+/**
+ * Stable string id used in the `--target` CLI flag and the registry
+ * 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';
+
+/**
+ * Result of `target.detect(location)`.
+ *
+ * `installed` is a best-effort heuristic that the agent's CLI / app /
+ * config dir is present on this system — used to default the
+ * multiselect prompt to "what's actually here." False positives are
+ * acceptable (we still write); false negatives just mean the user
+ * has to opt in manually.
+ *
+ * `alreadyConfigured` reports whether codegraph has already been
+ * wired into this target at this location — drives the
+ * "Updated"-vs-"Added" log line and lets `--check` exit 0/1.
+ */
+export interface DetectionResult {
+ installed: boolean;
+ alreadyConfigured: boolean;
+ /** Path inspected; surfaced in diagnostic / dry-run output. */
+ configPath?: string;
+}
+
+/**
+ * What `target.install(location)` actually changed on disk. The
+ * orchestrator renders one log line per file using `action`.
+ *
+ * `unchanged` means we touched the file but its contents were already
+ * what we'd write — used for byte-identical idempotent re-runs.
+ */
+export interface WriteResult {
+ files: Array<{
+ path: string;
+ action: 'created' | 'updated' | 'unchanged' | 'removed' | 'not-found' | 'kept';
+ }>;
+ /**
+ * Optional one-line notes the orchestrator surfaces verbatim — e.g.
+ * "Restart Cursor to apply." Keep these short; multi-line goes in
+ * the README.
+ */
+ notes?: string[];
+}
+
+export interface InstallOptions {
+ /**
+ * Whether to write the agent's permissions / auto-allow surface
+ * (Claude `settings.json`, others where applicable). When the
+ * target has no permissions concept this option is a no-op.
+ */
+ autoAllow: boolean;
+}
+
+export interface AgentTarget {
+ /** Stable id; matches the `TargetId` union. */
+ readonly id: TargetId;
+ /** Human-readable name shown in clack prompts and log lines. */
+ readonly displayName: string;
+ /** Optional URL for "where do I learn more about this agent." */
+ readonly docsUrl?: string;
+ /**
+ * Whether this target supports the given install location.
+ *
+ * Some agents (Codex CLI as of 2026-05) have no project-local
+ * config concept — only a single `~/.codex/` dir. Returning false
+ * for an unsupported (target, location) pair lets the orchestrator
+ * skip cleanly with a clear message.
+ */
+ supportsLocation(loc: Location): boolean;
+ detect(loc: Location): DetectionResult;
+ install(loc: Location, opts: InstallOptions): WriteResult;
+ /**
+ * Inverse of install. Removes only what install would have written;
+ * preserves sibling MCP servers, sibling permissions, and unrelated
+ * markdown sections. Must be safe to call when nothing was ever
+ * installed (returns `not-found` actions).
+ */
+ uninstall(loc: Location): WriteResult;
+ /**
+ * Print the MCP-server snippet a user would paste manually for this
+ * target. Used by `codegraph install --print-config ` and by
+ * the README. Must NOT touch the filesystem.
+ */
+ printConfig(loc: Location): string;
+ /** Filesystem paths this target would write to at this location. */
+ describePaths(loc: Location): string[];
+ /**
+ * Optional. Write any project-local surfaces this target needs in
+ * order to work fully when its MCP config is configured globally.
+ * Called by `codegraph init` to bootstrap new projects without
+ * forcing the user to re-run `codegraph install` per project.
+ *
+ * Most targets need nothing here — their global config is complete.
+ * Cursor is the notable exception: its rules system
+ * (`.cursor/rules/*.mdc`) is project-scoped only, and is what makes
+ * Cursor's agent prefer codegraph over its built-in grep.
+ *
+ * Must be idempotent. Targets that have nothing project-local omit
+ * the method entirely.
+ */
+ wireProjectSurfaces?(): WriteResult;
+}