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
co-authored by Claude Opus 4.7 andreinknv
parent 7e617d819b
commit a447e1d430
19 changed files with 2265 additions and 532 deletions
+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;
}