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