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>
This commit is contained in:
Colby Mchenry
2026-05-17 19:26:09 -05:00
committed by GitHub
parent 7e617d819b
commit a447e1d430
19 changed files with 2265 additions and 532 deletions
+35 -10
View File
@@ -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)
### 2. Restart Claude Code
**Non-interactive (scripting / CI):**
Restart Claude Code for the MCP server to load.
```bash
codegraph install --yes # auto-detect agents, install global
codegraph install --target=cursor,claude --yes # explicit target list
codegraph install --target=auto --location=local # detected agents, project-local
codegraph install --print-config codex # print snippet, no file writes
```
| Flag | Values | Default |
|---|---|---|
| `--target` | `auto`, `all`, `none`, or csv (`claude,cursor,...`) | prompt |
| `--location` | `global`, `local` | prompt |
| `--yes` | (boolean) | prompt every step |
| `--no-permissions` | (boolean) skip Claude auto-allow list | permissions on |
| `--print-config <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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+25 -113
View File
@@ -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');
}
}
} catch {
// Never fail
}
}
// Run cleanup — never throw
try {
removeMcpConfig();
} catch { /* ignore */ }
try {
removeSettings();
} catch { /* ignore */ }
// Lazy require so any module-level error in the registry can't
// bubble out and abort the npm uninstall.
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { ALL_TARGETS } = require('../installer/targets/registry') as
typeof import('../installer/targets/registry');
try {
removeClaudeMd();
} catch { /* ignore */ }
for (const target of ALL_TARGETS) {
if (!target.supportsLocation('global')) continue;
try {
target.uninstall('global');
} catch {
// Each target is independently safe-to-skip; per-target failure
// must not stop the loop.
}
}
} catch {
// If the registry itself can't be loaded (e.g. partial install),
// we silently skip cleanup. Uninstall still completes.
}
+7
View File
@@ -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
View File
@@ -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';
+51 -266
View File
@@ -1,292 +1,77 @@
/**
* Config file writing for the CodeGraph installer
* Writes to claude.json, settings.json, and CLAUDE.md
* Backwards-compat shim — original Claude-only writer functions.
*
* The installer now uses the multi-target architecture in
* `./targets/`. This file is preserved so existing imports (the test
* suite, downstream tooling) keep working unchanged. Each function
* delegates to the Claude target. New code should import the target
* registry from `./targets/registry` directly.
*
* @deprecated Use `targets/registry.ts` and the `AgentTarget`
* abstraction instead.
*/
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
export type InstallLocation = 'global' | 'local';
import {
CLAUDE_MD_TEMPLATE,
CODEGRAPH_SECTION_START,
CODEGRAPH_SECTION_END,
} from './claude-md-template';
writeMcpEntry,
writePermissionsEntry,
writeInstructionsEntry,
} from './targets/claude';
import { readJsonFile } from './targets/shared';
export type InstallLocation = 'global' | 'local';
/**
* Get the path to the Claude config directory
* Each shim calls ONLY the named per-file helper — writeMcpConfig
* writes only the MCP JSON, writePermissions only settings.json,
* writeClaudeMd only CLAUDE.md. The full multi-file install lives
* in `claudeTarget.install()` which the new orchestrator uses.
*/
function getClaudeConfigDir(location: InstallLocation): string {
if (location === 'global') {
return path.join(os.homedir(), '.claude');
}
return path.join(process.cwd(), '.claude');
export function writeMcpConfig(location: InstallLocation): void {
writeMcpEntry(location);
}
/**
* Get the path to the claude.json file
* - Global: ~/.claude.json (root level)
* - Local: ./.claude.json (project root)
*/
function getClaudeJsonPath(location: InstallLocation): string {
if (location === 'global') {
return path.join(os.homedir(), '.claude.json');
}
return path.join(process.cwd(), '.claude.json');
export function writePermissions(location: InstallLocation): void {
writePermissionsEntry(location);
}
/**
* Get the path to the settings.json file
* - Global: ~/.claude/settings.json
* - Local: ./.claude/settings.json
*/
function getSettingsJsonPath(location: InstallLocation): string {
const configDir = getClaudeConfigDir(location);
return path.join(configDir, 'settings.json');
}
/**
* Read a JSON file, returning an empty object if it doesn't exist.
* Distinguishes between missing files (returns {}) and corrupted
* files (logs warning, returns {}).
*/
function readJsonFile(filePath: string): Record<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 {};
}
}
/**
* Write a file atomically by writing to a temp file then renaming.
* Prevents corruption if the process crashes mid-write.
*/
function atomicWriteFileSync(filePath: string, content: string): void {
const dir = path.dirname(filePath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
const tmpPath = filePath + '.tmp.' + process.pid;
try {
fs.writeFileSync(tmpPath, content);
fs.renameSync(tmpPath, filePath);
} catch (err) {
// Clean up temp file on failure
try { fs.unlinkSync(tmpPath); } catch { /* ignore */ }
throw err;
}
}
/**
* Write a JSON file, creating parent directories if needed
*/
function writeJsonFile(filePath: string, data: Record<string, any>): void {
atomicWriteFileSync(filePath, JSON.stringify(data, null, 2) + '\n');
}
/**
* 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)) {
const file = location === 'global'
? path.join(os.homedir(), '.claude', 'settings.json')
: path.join(process.cwd(), '.claude', 'settings.json');
const settings = readJsonFile(file);
const allow = settings.permissions?.allow;
if (!Array.isArray(allow)) return false;
return allow.some((p: string) => p.startsWith('mcp__codegraph__'));
}
export function hasClaudeMdSection(location: InstallLocation): boolean {
const file = location === 'global'
? path.join(os.homedir(), '.claude', 'CLAUDE.md')
: path.join(process.cwd(), '.claude', 'CLAUDE.md');
try {
if (!fs.existsSync(file)) return false;
const content = fs.readFileSync(file, 'utf-8');
return content.includes('<!-- CODEGRAPH_START -->') || content.includes('## CodeGraph');
} catch {
return false;
}
// Check if at least one CodeGraph permission exists
return permissions.some((p: string) => p.startsWith('mcp__codegraph__'));
}
/**
* Get the path to CLAUDE.md
* - Global: ~/.claude/CLAUDE.md
* - Local: ./.claude/CLAUDE.md
*/
function getClaudeMdPath(location: InstallLocation): string {
const configDir = getClaudeConfigDir(location);
return path.join(configDir, 'CLAUDE.md');
}
/**
* Check if CLAUDE.md has CodeGraph section
*/
export function hasClaudeMdSection(location: InstallLocation): boolean {
const claudeMdPath = getClaudeMdPath(location);
try {
if (fs.existsSync(claudeMdPath)) {
const content = fs.readFileSync(claudeMdPath, 'utf-8');
return content.includes(CODEGRAPH_SECTION_START) || content.includes('## CodeGraph');
}
} catch {
// Ignore errors
}
return false;
}
/**
* Write or update CLAUDE.md with CodeGraph instructions
*
* If the file exists and has a CodeGraph section (marked or unmarked),
* it will be replaced. Otherwise, the template is appended.
*/
export function writeClaudeMd(location: InstallLocation): { created: boolean; updated: boolean } {
const claudeMdPath = getClaudeMdPath(location);
const configDir = getClaudeConfigDir(location);
// Ensure directory exists
if (!fs.existsSync(configDir)) {
fs.mkdirSync(configDir, { recursive: true });
}
// Check if file exists
if (!fs.existsSync(claudeMdPath)) {
// Create new file with just the CodeGraph section
atomicWriteFileSync(claudeMdPath, CLAUDE_MD_TEMPLATE + '\n');
return { created: true, updated: false };
}
// Read existing content
let content = fs.readFileSync(claudeMdPath, 'utf-8');
// Check for marked section (from previous installer)
if (content.includes(CODEGRAPH_SECTION_START)) {
// Replace the marked section
const startIdx = content.indexOf(CODEGRAPH_SECTION_START);
const endIdx = content.indexOf(CODEGRAPH_SECTION_END);
if (endIdx > startIdx) {
// Replace existing marked section
const before = content.substring(0, startIdx);
const after = content.substring(endIdx + CODEGRAPH_SECTION_END.length);
content = before + CLAUDE_MD_TEMPLATE + after;
atomicWriteFileSync(claudeMdPath, content);
return { created: false, updated: true };
}
}
// Check for unmarked "## CodeGraph" section (from manual setup)
const codegraphHeaderRegex = /\n## CodeGraph\n/;
const match = content.match(codegraphHeaderRegex);
if (match && match.index !== undefined) {
// Find the end of the CodeGraph section (next h2 header or end of file)
// Use negative lookahead (?!#) to match "## X" but not "### X"
const sectionStart = match.index;
const afterSection = content.substring(sectionStart + 1);
const nextHeaderMatch = afterSection.match(/\n## (?!#)/);
let sectionEnd: number;
if (nextHeaderMatch && nextHeaderMatch.index !== undefined) {
sectionEnd = sectionStart + 1 + nextHeaderMatch.index;
} else {
sectionEnd = content.length;
}
// Replace the section
const before = content.substring(0, sectionStart);
const after = content.substring(sectionEnd);
content = before + '\n' + CLAUDE_MD_TEMPLATE + after;
atomicWriteFileSync(claudeMdPath, content);
return { created: false, updated: true };
}
// No existing section, append to end
content = content.trimEnd() + '\n\n' + CLAUDE_MD_TEMPLATE + '\n';
atomicWriteFileSync(claudeMdPath, content);
return { created: false, updated: false };
}
+243 -96
View File
@@ -1,18 +1,39 @@
/**
* CodeGraph Interactive Installer
*
* Uses @clack/prompts for a polished interactive CLI experience.
* Multi-target: writes MCP server config + instructions for the
* agents the user picks (Claude Code, Cursor, Codex CLI, opencode).
* Defaults to the Claude-only behavior for backwards compatibility
* when no targets are explicitly chosen and nothing else is detected.
*
* Uses @clack/prompts for the interactive UI; `runInstallerWithOptions`
* is the non-interactive entry point used by the `--target` /
* `--print-config` CLI flags.
*/
import { execSync } from 'child_process';
import * as path from 'path';
import * as fs from 'fs';
import {
writeMcpConfig, writePermissions, writeClaudeMd,
hasMcpConfig, hasPermissions,
} from './config-writer';
ALL_TARGETS,
detectAll,
getTarget,
resolveTargetFlag,
} from './targets/registry';
import type { AgentTarget, Location, WriteResult } from './targets/types';
import type { InstallLocation } from './config-writer';
// Backwards-compat: keep these named exports — downstream code may
// import them. The shim in `config-writer.ts` continues to re-export
// them too.
export {
writeMcpConfig,
writePermissions,
writeClaudeMd,
hasMcpConfig,
hasPermissions,
hasClaudeMdSection,
} from './config-writer';
export type { InstallLocation } from './config-writer';
// Dynamic import helper — tsc compiles import() to require() in CJS mode,
// which fails for ESM-only packages. This bypasses the transformation.
@@ -20,16 +41,10 @@ import type { InstallLocation } from './config-writer';
const importESM = new Function('specifier', 'return import(specifier)') as
(specifier: string) => Promise<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,
});
// --yes implies all defaults; explicit flags still win.
const useDefaults = opts.yes === true;
if (clack.isCancel(shouldInstallGlobally)) {
clack.cancel('Installation cancelled.');
process.exit(0);
// Step 1: which agent targets? Asked FIRST so the user knows what
// they're committing to before we touch npm or disk. Detection
// probes the user-provided location if known, else 'global' as the
// most common default — labels are a hint, not load-bearing.
const detectionLocation: Location = opts.location ?? 'global';
const targets = await resolveTargets(clack, opts, detectionLocation, useDefaults);
if (targets.length === 0) {
clack.outro('No agent targets selected — nothing to do.');
return;
}
if (shouldInstallGlobally) {
const s = clack.spinner();
s.start('Installing codegraph globally...');
try {
execSync('npm install -g @colbymchenry/codegraph', { stdio: 'pipe' });
s.stop('Installed codegraph globally');
} catch {
s.stop('Could not install globally (permission denied)');
clack.log.warn('Try: sudo npm install -g @colbymchenry/codegraph');
// Step 2: install the codegraph npm package on PATH (always offered;
// matches existing behavior). Skipped when --yes (assume present).
if (!useDefaults) {
const shouldInstallGlobally = await clack.confirm({
message: 'Install the codegraph CLI on your PATH? (Required so agents can launch the MCP server)',
initialValue: true,
});
if (clack.isCancel(shouldInstallGlobally)) {
clack.cancel('Installation cancelled.');
process.exit(0);
}
if (shouldInstallGlobally) {
const s = clack.spinner();
s.start('Installing codegraph CLI...');
try {
execSync('npm install -g @colbymchenry/codegraph', { stdio: 'pipe' });
s.stop('Installed codegraph CLI on PATH');
} catch {
s.stop('Could not install (permission denied)');
clack.log.warn('Try: sudo npm install -g @colbymchenry/codegraph');
}
} else {
clack.log.info('Skipped CLI install — agents will not be able to launch the MCP server without it');
}
}
// Step 3: where the per-agent config files should land.
let location: Location;
if (opts.location) {
location = opts.location;
} else if (useDefaults) {
location = 'global';
} else {
clack.log.info('Skipped global install — MCP server may not work without it');
// If every selected target is global-only (e.g. Codex), skip the
// prompt and force user-wide — project-local would just produce
// skip warnings.
const allGlobalOnly = targets.every((t) => !t.supportsLocation('local'));
if (allGlobalOnly) {
location = 'global';
clack.log.info('Writing user-wide configs (selected agents have no project-local config).');
} else {
const sel = await clack.select({
message: 'Apply agent configs to all your projects, or just this one?',
options: [
{ value: 'global' as const, label: 'All projects', hint: '~/.claude, ~/.cursor, etc.' },
{ value: 'local' as const, label: 'Just this project', hint: './.claude, ./.cursor, etc.' },
],
initialValue: 'global' as const,
});
if (clack.isCancel(sel)) {
clack.cancel('Installation cancelled.');
process.exit(0);
}
location = sel;
}
}
// Step 2: Installation location
const location = await clack.select({
message: 'Where would you like to install?',
options: [
{ value: 'global' as const, label: 'Global', hint: '~/.claude — available in all projects' },
{ value: 'local' as const, label: 'Local', hint: './.claude — this project only' },
],
initialValue: 'global' as const,
});
if (clack.isCancel(location)) {
clack.cancel('Installation cancelled.');
process.exit(0);
// Step 4: auto-allow permissions (only meaningful for Claude;
// skipped silently by other targets).
let autoAllow: boolean;
if (opts.autoAllow !== undefined) {
autoAllow = opts.autoAllow;
} else if (useDefaults) {
autoAllow = true;
} else if (targets.some((t) => t.id === 'claude')) {
const ans = await clack.confirm({
message: 'Auto-allow CodeGraph commands? (Skips permission prompts in Claude Code)',
initialValue: true,
});
if (clack.isCancel(ans)) {
clack.cancel('Installation cancelled.');
process.exit(0);
}
autoAllow = ans;
} else {
autoAllow = false;
}
// Step 3: Auto-allow permissions
const autoAllow = await clack.confirm({
message: 'Auto-allow CodeGraph commands? (Skips permission prompts)',
initialValue: true,
});
if (clack.isCancel(autoAllow)) {
clack.cancel('Installation cancelled.');
process.exit(0);
// Step 5: per-target install loop.
for (const target of targets) {
if (!target.supportsLocation(location)) {
clack.log.warn(
`${target.displayName}: skipped — does not support --location=${location}.`,
);
continue;
}
const result = target.install(location, { autoAllow });
for (const file of result.files) {
const verb = file.action === 'unchanged'
? 'Unchanged'
: file.action === 'created' ? 'Created' : 'Updated';
clack.log.success(`${target.displayName}: ${verb} ${tildify(file.path)}`);
}
for (const note of result.notes ?? []) {
clack.log.info(`${target.displayName}: ${note}`);
}
}
// Step 4: Write configuration files
writeConfigs(clack, location, autoAllow);
// Step 5: For local install, initialize the project
// Step 6: for local install, initialize the project.
if (location === 'local') {
await initializeLocalProject(clack);
}
// Done
if (location === 'global') {
clack.note(
'cd your-project\ncodegraph init -i',
'Quick start',
);
clack.note('cd your-project\ncodegraph init -i', 'Quick start');
}
clack.outro('Done! Restart Claude Code to use CodeGraph.');
const finalNote = targets.length > 0
? `Done! Restart your agent${targets.length > 1 ? 's' : ''} to use CodeGraph.`
: 'Done!';
clack.outro(finalNote);
}
/**
* Write all configuration files and log results
* For every target that has a global config and exposes
* `wireProjectSurfaces`, write its project-local surfaces (e.g.
* Cursor's `.cursor/rules/codegraph.mdc`). Idempotent — runs
* silently when there's nothing to write.
*
* Called by `codegraph init` so that a user who ran
* `codegraph install` once globally doesn't have to re-run it per
* project to get full agent support.
*
* Returns the list of `(target, file)` pairs that were created or
* updated — caller decides how to surface them.
*/
function writeConfigs(
clack: typeof import('@clack/prompts'),
location: InstallLocation,
autoAllow: boolean,
): void {
const locationLabel = location === 'global' ? '~/.claude' : './.claude';
// MCP config
const mcpAction = hasMcpConfig(location) ? 'Updated' : 'Added';
writeMcpConfig(location);
clack.log.success(`${mcpAction} MCP server in ${locationLabel}.json`);
// Permissions
if (autoAllow) {
const permAction = hasPermissions(location) ? 'Updated' : 'Added';
writePermissions(location);
clack.log.success(`${permAction} permissions in ${locationLabel}/settings.json`);
}
// CLAUDE.md
const claudeMdResult = writeClaudeMd(location);
const claudeMdPath = `${locationLabel}/CLAUDE.md`;
if (claudeMdResult.created) {
clack.log.success(`Created ${claudeMdPath}`);
} else if (claudeMdResult.updated) {
clack.log.success(`Updated ${claudeMdPath}`);
} else {
clack.log.success(`Added CodeGraph instructions to ${claudeMdPath}`);
export function wireProjectSurfacesForGlobalAgents(): Array<{
target: AgentTarget;
file: WriteResult['files'][number];
}> {
const written: Array<{ target: AgentTarget; file: WriteResult['files'][number] }> = [];
for (const target of ALL_TARGETS) {
if (typeof target.wireProjectSurfaces !== 'function') continue;
const detection = target.detect('global');
if (!detection.alreadyConfigured) continue;
const result = target.wireProjectSurfaces();
for (const file of result.files) {
if (file.action === 'created' || file.action === 'updated') {
written.push({ target, file });
}
}
}
return written;
}
/**
* Initialize CodeGraph in the current project (for local installs)
* Replace home-directory prefix in a path with `~/` for cleaner log
* lines. Pure cosmetic.
*/
function tildify(p: string): string {
const home = require('os').homedir();
if (p.startsWith(home + path.sep)) return '~' + p.substring(home.length);
return p;
}
async function resolveTargets(
clack: typeof import('@clack/prompts'),
opts: RunInstallerOptions,
location: Location,
useDefaults: boolean,
): Promise<AgentTarget[]> {
// Explicit --target flag wins.
if (opts.target !== undefined) {
return resolveTargetFlag(opts.target, location);
}
// --yes implies auto-detect.
if (useDefaults) {
return resolveTargetFlag('auto', location);
}
// Interactive multi-select.
const detected = detectAll(location);
const initialValues = detected
.filter(({ detection }) => detection.installed)
.map(({ target }) => target.id);
// If nothing detected, default to Claude alone (matches the
// historical default and the smallest-surprise outcome).
const initial = initialValues.length > 0 ? initialValues : ['claude'];
const choice = await clack.multiselect<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).
* 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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}