Ver Fonte

feat(installer): multi-target — Claude Code, Cursor, Codex CLI, opencode (#162)

* feat(installer): multi-target — Claude Code, Cursor, Codex CLI, opencode

Closes the Claude-locked installer behind issue #137. The runtime MCP
server was already agent-agnostic (stdio); only the installer was
locked. After this refactor, `codegraph install` can write per-agent
MCP config + instructions for any combination of supported agents.

## What ships

Four agent targets, each implementing the new `AgentTarget` interface:

- **Claude Code** — `~/.claude.json`, `~/.claude/settings.json`,
  `~/.claude/CLAUDE.md` (or local equivalents). Behavior preserved
  from the original installer; existing installs upgrade in place.
- **Cursor** — `~/.cursor/mcp.json` (g) or `./.cursor/mcp.json` (l)
  + project-local `./.cursor/rules/codegraph.mdc`.
- **Codex CLI** — `~/.codex/config.toml` with `[mcp_servers.codegraph]`
  + `~/.codex/AGENTS.md`. Global only. Hand-rolled TOML serializer
  scoped to the table we own — siblings + array-of-tables preserved.
- **opencode** — `~/.config/opencode/opencode.json` (XDG) or
  `./opencode.json`.

Adding a 5th agent is a new file in `src/installer/targets/` plus
one entry in `registry.ts`.

## CLI changes

```
codegraph install                                   # interactive multi-select
codegraph install --yes                             # auto-detect, install global
codegraph install --target=cursor,claude --yes     # explicit list
codegraph install --target=auto --location=local   # detected, project-local
codegraph install --target=none                    # skip agent writes entirely
codegraph install --print-config codex             # dump snippet, no writes
```

## Backwards compat

Every export from the old `config-writer.ts` (`writeMcpConfig`,
`writePermissions`, `writeClaudeMd`, `hasMcpConfig`, `hasPermissions`,
`hasClaudeMdSection`) is preserved as a `@deprecated` shim that
delegates to per-file helpers in `targets/claude.ts`. Existing Claude
users see byte-identical on-disk layout — `detect()` reports
`alreadyConfigured: true`, re-running is a no-op.

## Tests

+47 new tests in `__tests__/installer-targets.test.ts`:
- Parameterized contract test across all 4 targets × supported
  locations (install → unchanged on re-run, sibling preservation,
  uninstall reverses install, printConfig writes nothing).
- Codex partial-state recovery, locked-block contract for the
  codegraph table, full TOML serializer suite.
- Registry: getTarget, resolveTargetFlag (auto/all/none/csv).

`__tests__/installer.test.ts` relaxed one assertion: the new code
returns `unchanged` for byte-identical re-runs instead of `updated`;
the surrounding-custom-content contract is unchanged.

## Uninstall behavior change

`bin/uninstall.ts` now loops `ALL_TARGETS.uninstall('global')` on
`npm uninstall -g`. A user who manually configured
`~/.codex/config.toml` with our block will have only that block
removed on package uninstall — we only touch the dotted-key table
we own.

Based on andreinknv/codegraph@c5165e4. Issue #137.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(scripts): add local-install.sh for hands-on branch testing

Builds the current branch and `npm link`s it as the global
`codegraph` binary. `--undo` unlinks and reinstalls the published
version. Mirrors the style of scripts/release.sh.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(installer): move agent picker to the first prompt

Reorders runInstallerWithOptions so the multi-select for agents
(Claude / Cursor / Codex / opencode) is step 1 — before the
global-npm-install confirm and before the location prompt. Bare
`npx @colbymchenry/codegraph` now opens with "Which agents should
CodeGraph configure?", which is the answer most users want first.

Side effects of the reorder:

- Early exit if zero targets selected — skips global-install and
  location prompts entirely, exits with "nothing to do."
- Multiselect labels drop the per-location "will skip" hint (location
  isn't known yet) and replace it with a static "global only" badge
  for targets like Codex that have no project-local config concept.
- If every selected target is global-only, the location prompt is
  skipped and global is forced (no point asking).
- Detection probes the user-provided location if known via flag,
  else 'global' as the most common default — labels are a hint
  about what's installed locally, not load-bearing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(installer): disambiguate "global" wording in install prompts

Two prompts both said "global" but meant different things — users
read them as duplicates. Renamed for clarity:

- Step 2 (npm install -g): "Install codegraph globally?" →
  "Install the codegraph CLI on your PATH? (Required so agents can
  launch the MCP server)". Spinner messages match.
- Step 3 (config location): "Where would you like to install?" with
  "Global"/"Local" → "Apply agent configs to all your projects, or
  just this one?" with "All projects" (~/.claude, ~/.cursor, etc.)
  / "Just this project" (./.claude, ./.cursor, etc.).
- All-global-only fallback: "Using global install" → "Writing
  user-wide configs (selected agents have no project-local config)."

Underlying `Location` values ('global' / 'local') unchanged; only
the UI strings shift, so no test or flag breakage.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(installer/cursor): inject --path so workspace-aware queries work

Cursor launches MCP-server subprocesses with cwd != workspace root,
AND does not pass rootUri or workspaceFolders in the MCP initialize
call. The codegraph MCP server's process.cwd() fallback misses the
workspace's .codegraph/ and reports "not initialized" on every tool
call. Codex and Claude don't have this issue (Codex launches with
cwd=workspace, Claude passes rootUri).

Fix: inject `--path` into the args we write for Cursor.

- local install (./.cursor/mcp.json): hardcode the absolute project
  path — known at install time.
- global install (~/.cursor/mcp.json): use `${workspaceFolder}` so
  Cursor expands it per-workspace. One global config now drives
  every project the user opens, without per-project re-install.

No test breakage — the parameterized contract tests check
idempotency / sibling preservation, not the exact args content.
File-header comment documents the rationale so the next person
doesn't strip the arg as boilerplate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(init): auto-wire project-local agent surfaces

Closes the global-Cursor UX gap: `~/.cursor/mcp.json` registers the
MCP server, but Cursor's agent only learns to *prefer* codegraph
over native grep when it sees `.cursor/rules/codegraph.mdc` — a
project-local file that global install can't write. Previously the
user had to re-run `codegraph install --target=cursor --location=local`
for every new project. Now `codegraph init` does it automatically.

## What changed

- New optional `AgentTarget.wireProjectSurfaces()` returning a
  WriteResult of project-local files to drop. Most targets omit
  it (their global config is complete). Cursor implements it to
  write the rules file.
- New `wireProjectSurfacesForGlobalAgents()` orchestrator in
  installer/index.ts — iterates ALL_TARGETS, detects which are
  configured globally, calls their wireProjectSurfaces, returns
  what was written.
- `codegraph init` calls the orchestrator in both branches:
  - Fresh init: write surfaces after CodeGraph.init succeeds.
  - Already-initialized re-init: write surfaces too, so re-running
    `init` is the documented recovery path for a project missing
    its rules file.

## Steady-state UX

  1. Once, ever: `codegraph install` (writes global agent configs)
  2. Per project: `codegraph init -i` (builds the index + auto-wires
     project-local agent surfaces — currently Cursor's rules file)

No new tests — wireProjectSurfaces delegates to writeRulesEntry,
which is already covered by the parameterized contract tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(installer): agent-agnostic instructions template

The old template was inherited from the Claude-only era and
prescribed "ALWAYS spawn an Explore agent" — a Claude Code-specific
concept (subagents via the Task tool). When Cursor's agent read
this it had no Explore agent to spawn, got confused, and fell back
to native grep/read even for structural queries the codegraph MCP
tools answer in one call.

This rewrite:

- Frames each tool by the question it answers (search vs callers
  vs impact vs context vs explore vs node vs files vs status).
- Tells the agent explicitly to TRUST codegraph results and not
  re-verify them with grep — the over-grep-after-codegraph
  behavior was the main symptom we saw on Cursor.
- Reframes "spawn Explore agent" as an OPTIONAL pattern for
  harnesses that support parallel subagents — Claude Code still
  gets the hint, Cursor / Codex / opencode just skip it.
- Trims the "if not initialized" section to one prescriptive line.

Same marker delimiters (`<!-- CODEGRAPH_START/END -->`) so existing
installs upgrade in place via the marker-based section swap. No
test changes needed — the parameterized contract tests check
marker placement + sibling preservation, not the literal body.

Effective surfaces: ~/.claude/CLAUDE.md (Claude), .cursor/rules/
codegraph.mdc (Cursor, project-local), ~/.codex/AGENTS.md (Codex).
Users get the new copy by re-running `codegraph install` for
global writes, or `codegraph init` for Cursor's project rules.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(readme): reflect multi-agent support at the top + accurate flow

- Tagline now reads "Supercharge Claude Code, Cursor & Codex" instead
  of Claude-only — multi-agent support is what the PR is about, the
  README should say so above the fold.
- New badge row (Claude Code / Cursor / Codex CLI / opencode) in the
  same shields.io style as the OS row.
- Install-flow bullets reordered to match the actual prompt order
  (agent picker first, then PATH install, then location).
- `codegraph init -i` step now mentions that init wires up
  project-local agent surfaces (Cursor rules file etc.) so global
  install works in every project without a re-run.
- Agent-agnostic phrasing in the closing line ("your agent" not
  "Claude Code").

Headline-level brand decision left intentionally in this PR — the
existing Claude-only positioning predates multi-agent support.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: andreinknv <andrei.nknv@outlook.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Colby Mchenry há 1 mês atrás
pai
commit
a447e1d430

+ 35 - 10
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 @@
 [![macOS](https://img.shields.io/badge/macOS-supported-blue.svg)](#)
 [![Linux](https://img.shields.io/badge/Linux-supported-blue.svg)](#)
 
+[![Claude Code](https://img.shields.io/badge/Claude_Code-supported-blueviolet.svg)](#)
+[![Cursor](https://img.shields.io/badge/Cursor-supported-blueviolet.svg)](#)
+[![Codex CLI](https://img.shields.io/badge/Codex_CLI-supported-blueviolet.svg)](#)
+[![opencode](https://img.shields.io/badge/opencode-supported-blueviolet.svg)](#)
+
 <br />
 
 ### Get Started
@@ -22,7 +27,7 @@
 npx @colbymchenry/codegraph
 ```
 
-<sub>Interactive installer configures Claude Code automatically</sub>
+<sub>Interactive installer auto-configures your agent(s) — Claude Code, Cursor, Codex CLI, opencode</sub>
 
 #### 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)
+
+**Non-interactive (scripting / CI):**
 
-### 2. Restart Claude Code
+```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
+```
 
-Restart Claude Code for the MCP server to load.
+| 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 <id>` | 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.
 
 <details>
 <summary><strong>Manual Setup (Alternative)</strong></summary>

+ 332 - 0
__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<string, any> = { mcpServers: { other: { command: 'x' } } };
+            // opencode uses `mcp` not `mcpServers`. Match its shape too.
+            if (target.id === 'opencode') {
+              delete seed.mcpServers;
+              seed.mcp = { other: { type: 'local', command: ['x'], enabled: true } };
+            }
+            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;
+}

+ 4 - 3
__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');

+ 41 - 0
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"

+ 78 - 4
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 <ids>', 'Target agent(s): comma-separated ids, or "auto"|"all"|"none". Default: prompt')
+  .option('-l, --location <where>', '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 <id>', '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

+ 24 - 112
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 = '<!-- CODEGRAPH_START -->';
-const CODEGRAPH_SECTION_END = '<!-- CODEGRAPH_END -->';
-
-function readJson(filePath: string): Record<string, any> | 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<string, any>): 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');
-      }
+try {
+  // 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');
+
+  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 {
-    // Never fail
   }
+} catch {
+  // If the registry itself can't be loaded (e.g. partial install),
+  // we silently skip cleanup. Uninstall still completes.
 }
-
-// Run cleanup — never throw
-try {
-  removeMcpConfig();
-} catch { /* ignore */ }
-
-try {
-  removeSettings();
-} catch { /* ignore */ }
-
-try {
-  removeClaudeMd();
-} catch { /* ignore */ }

+ 7 - 0
src/installer/clack.d.ts

@@ -24,6 +24,13 @@ declare module '@clack/prompts' {
     initialValue?: Value;
   }): Promise<Value | symbol>;
 
+  export function multiselect<Value>(opts: {
+    message: string;
+    options: { value: Value; label: string; hint?: string }[];
+    initialValues?: Value[];
+    required?: boolean;
+  }): Promise<Value[] | symbol>;
+
   export function spinner(): {
     start(message?: string): void;
     stop(message?: string): void;

+ 15 - 40
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 = '<!-- CODEGRAPH_START -->';
-export const CODEGRAPH_SECTION_END = '<!-- CODEGRAPH_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';

+ 46 - 261
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';
-
-/**
- * Get the path to the Claude config directory
- */
-function getClaudeConfigDir(location: InstallLocation): string {
-  if (location === 'global') {
-    return path.join(os.homedir(), '.claude');
-  }
-  return path.join(process.cwd(), '.claude');
-}
+  writeMcpEntry,
+  writePermissionsEntry,
+  writeInstructionsEntry,
+} from './targets/claude';
+import { readJsonFile } from './targets/shared';
 
-/**
- * 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');
-}
-
-/**
- * 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<string, any> {
-  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 {};
-  }
-}
+export type InstallLocation = 'global' | 'local';
 
 /**
- * Write a file atomically by writing to a temp file then renaming.
- * Prevents corruption if the process crashes mid-write.
+ * 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 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;
-  }
+export function writeMcpConfig(location: InstallLocation): void {
+  writeMcpEntry(location);
 }
 
-/**
- * Write a JSON file, creating parent directories if needed
- */
-function writeJsonFile(filePath: string, data: Record<string, any>): void {
-  atomicWriteFileSync(filePath, JSON.stringify(data, null, 2) + '\n');
+export function writePermissions(location: InstallLocation): void {
+  writePermissionsEntry(location);
 }
 
-/**
- * Get the MCP server configuration
- */
-function getMcpServerConfig(): Record<string, any> {
+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)) {
-    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');
+  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__'));
 }
 
-/**
- * Check if CLAUDE.md has CodeGraph section
- */
 export function hasClaudeMdSection(location: InstallLocation): boolean {
-  const claudeMdPath = getClaudeMdPath(location);
+  const file = location === 'global'
+    ? path.join(os.homedir(), '.claude', 'CLAUDE.md')
+    : path.join(process.cwd(), '.claude', 'CLAUDE.md');
   try {
-    if (fs.existsSync(claudeMdPath)) {
-      const content = fs.readFileSync(claudeMdPath, 'utf-8');
-      return content.includes(CODEGRAPH_SECTION_START) || content.includes('## CodeGraph');
-    }
+    if (!fs.existsSync(file)) return false;
+    const content = fs.readFileSync(file, 'utf-8');
+    return content.includes('<!-- CODEGRAPH_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 };
+    return false;
   }
-
-  // No existing section, append to end
-  content = content.trimEnd() + '\n\n' + CLAUDE_MD_TEMPLATE + '\n';
-  atomicWriteFileSync(claudeMdPath, content);
-  return { created: false, updated: false };
 }

+ 241 - 94
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,
+  ALL_TARGETS,
+  detectAll,
+  getTarget,
+  resolveTargetFlag,
+} from './targets/registry';
+import type { AgentTarget, Location, WriteResult } from './targets/types';
+
+// 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';
-
-import type { InstallLocation } 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<typeof import('@clack/prompts')>;
 
-/**
- * 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<void> {
+  return runInstallerWithOptions({});
+}
+
+export async function runInstallerWithOptions(opts: RunInstallerOptions): Promise<void> {
   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,
-  });
-
-  if (clack.isCancel(shouldInstallGlobally)) {
-    clack.cancel('Installation cancelled.');
-    process.exit(0);
+  // --yes implies all defaults; explicit flags still win.
+  const useDefaults = opts.yes === true;
+
+  // 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');
     }
-  } else {
-    clack.log.info('Skipped global install — MCP server may not work without it');
   }
 
-  // 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 3: where the per-agent config files should land.
+  let location: Location;
+  if (opts.location) {
+    location = opts.location;
+  } else if (useDefaults) {
+    location = 'global';
+  } else {
+    // 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 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 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 4: Write configuration files
-  writeConfigs(clack, location, autoAllow);
+  // 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 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);
+}
+
+/**
+ * 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.
+ */
+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;
 }
 
 /**
- * Write all configuration files and log results
+ * Replace home-directory prefix in a path with `~/` for cleaner log
+ * lines. Pure cosmetic.
  */
-function writeConfigs(
+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'),
-  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`);
+  opts: RunInstallerOptions,
+  location: Location,
+  useDefaults: boolean,
+): Promise<AgentTarget[]> {
+  // Explicit --target flag wins.
+  if (opts.target !== undefined) {
+    return resolveTargetFlag(opts.target, location);
   }
 
-  // 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}`);
+  // --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<string>({
+    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)
+ * 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<void> {
   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 };

+ 62 - 0
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 = '<!-- CODEGRAPH_START -->';
+export const CODEGRAPH_SECTION_END = '<!-- CODEGRAPH_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;

+ 254 - 0
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();

+ 181 - 0
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();

+ 240 - 0
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();

+ 133 - 0
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.<name>`
+ * (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();

+ 83 - 0
src/installer/targets/registry.ts

@@ -0,0 +1,83 @@
+/**
+ * Registry of all known agent targets.
+ *
+ * Adding a new target = create `targets/<id>.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<AgentTarget['detect']>;
+}> {
+  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;
+}

+ 206 - 0
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__<server>__<tool>` 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 `<path>.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<string, any> {
+  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 `<path>.tmp.<pid>`, 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<string, any>): 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<string, unknown>;
+  const bo = b as Record<string, unknown>;
+  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 `<!-- CODEGRAPH_START --> ... <!--
+ * CODEGRAPH_END -->` 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';
+}

+ 154 - 0
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, string | string[]>): 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, string | string[]>): 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;
+}

+ 121 - 0
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 <id>` 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;
+}