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:
co-authored by
Claude Opus 4.7
andreinknv
parent
7e617d819b
commit
a447e1d430
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user