From a447e1d430919881151a30d832a1a3e8b1babeb2 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Sun, 17 May 2026 19:26:09 -0500 Subject: [PATCH 001/133] =?UTF-8?q?feat(installer):=20multi-target=20?= =?UTF-8?q?=E2=80=94=20Claude=20Code,=20Cursor,=20Codex=20CLI,=20opencode?= =?UTF-8?q?=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 | 136 ++-------- src/installer/clack.d.ts | 7 + src/installer/claude-md-template.ts | 55 ++-- src/installer/config-writer.ts | 307 ++++------------------ src/installer/index.ts | 335 ++++++++++++++++++------- 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, 2257 insertions(+), 524 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 f8f39e978..caffccd7c 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) + +**Non-interactive (scripting / CI):** -### 2. Restart Claude Code +```bash +codegraph install --yes # auto-detect agents, install global +codegraph install --target=cursor,claude --yes # explicit target list +codegraph install --target=auto --location=local # detected agents, project-local +codegraph install --print-config codex # print snippet, no file writes +``` -Restart Claude Code for the MCP server to load. +| Flag | Values | Default | +|---|---|---| +| `--target` | `auto`, `all`, `none`, or csv (`claude,cursor,...`) | prompt | +| `--location` | `global`, `local` | prompt | +| `--yes` | (boolean) | prompt every step | +| `--no-permissions` | (boolean) skip Claude auto-allow list | permissions on | +| `--print-config ` | 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 000000000..ec437f94e --- /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 e2e24d1c2..1e0a90e5a 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 000000000..847966940 --- /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 8ee7a6f9e..f9b00bd9d 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 4344a04d6..a168d8075 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'); - } +try { + // Lazy require so any module-level error in the registry can't + // bubble out and abort the npm uninstall. + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { ALL_TARGETS } = require('../installer/targets/registry') as + typeof import('../installer/targets/registry'); + + for (const target of ALL_TARGETS) { + if (!target.supportsLocation('global')) continue; + try { + target.uninstall('global'); + } catch { + // Each target is independently safe-to-skip; per-target failure + // must not stop the loop. } - } catch { - // Never fail } +} catch { + // If the registry itself can't be loaded (e.g. partial install), + // we silently skip cleanup. Uninstall still completes. } - -// Run cleanup — never throw -try { - removeMcpConfig(); -} catch { /* ignore */ } - -try { - removeSettings(); -} catch { /* ignore */ } - -try { - removeClaudeMd(); -} catch { /* ignore */ } diff --git a/src/installer/clack.d.ts b/src/installer/clack.d.ts index 08e96874b..29d08ad4d 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 130d4472b..f1093b0cc 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 5e019909d..c1f8abc39 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'; - -/** - * Get the path to the Claude config directory - */ -function getClaudeConfigDir(location: InstallLocation): string { - if (location === 'global') { - return path.join(os.homedir(), '.claude'); - } - return path.join(process.cwd(), '.claude'); -} + writeMcpEntry, + writePermissionsEntry, + writeInstructionsEntry, +} from './targets/claude'; +import { readJsonFile } from './targets/shared'; -/** - * Get the path to the claude.json file - * - Global: ~/.claude.json (root level) - * - Local: ./.claude.json (project root) - */ -function getClaudeJsonPath(location: InstallLocation): string { - if (location === 'global') { - return path.join(os.homedir(), '.claude.json'); - } - return path.join(process.cwd(), '.claude.json'); -} - -/** - * Get the path to the settings.json file - * - Global: ~/.claude/settings.json - * - Local: ./.claude/settings.json - */ -function getSettingsJsonPath(location: InstallLocation): string { - const configDir = getClaudeConfigDir(location); - return path.join(configDir, 'settings.json'); -} - -/** - * Read a JSON file, returning an empty object if it doesn't exist. - * Distinguishes between missing files (returns {}) and corrupted - * files (logs warning, returns {}). - */ -function readJsonFile(filePath: string): Record { - if (!fs.existsSync(filePath)) { - return {}; - } - try { - const content = fs.readFileSync(filePath, 'utf-8'); - return JSON.parse(content); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - console.warn(` Warning: Could not parse ${path.basename(filePath)}: ${msg}`); - console.warn(` A backup will be created before overwriting.`); - // Create a backup of the corrupted file - try { - const backupPath = filePath + '.backup'; - fs.copyFileSync(filePath, backupPath); - } catch { /* ignore backup failure */ } - return {}; - } -} +export type InstallLocation = 'global' | 'local'; /** - * Write a file atomically by writing to a temp file then renaming. - * Prevents corruption if the process crashes mid-write. + * Each shim calls ONLY the named per-file helper — writeMcpConfig + * writes only the MCP JSON, writePermissions only settings.json, + * writeClaudeMd only CLAUDE.md. The full multi-file install lives + * in `claudeTarget.install()` which the new orchestrator uses. */ -function atomicWriteFileSync(filePath: string, content: string): void { - const dir = path.dirname(filePath); - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }); - } - const tmpPath = filePath + '.tmp.' + process.pid; - try { - fs.writeFileSync(tmpPath, content); - fs.renameSync(tmpPath, filePath); - } catch (err) { - // Clean up temp file on failure - try { fs.unlinkSync(tmpPath); } catch { /* ignore */ } - throw err; - } +export function writeMcpConfig(location: InstallLocation): void { + writeMcpEntry(location); } -/** - * Write a JSON file, creating parent directories if needed - */ -function writeJsonFile(filePath: string, data: Record): void { - atomicWriteFileSync(filePath, JSON.stringify(data, null, 2) + '\n'); +export function writePermissions(location: InstallLocation): void { + writePermissionsEntry(location); } -/** - * Get the MCP server configuration - */ -function getMcpServerConfig(): Record { +export function writeClaudeMd(location: InstallLocation): { created: boolean; updated: boolean } { + const file = writeInstructionsEntry(location); return { - type: 'stdio', - command: 'codegraph', - args: ['serve', '--mcp'], + created: file.action === 'created', + updated: file.action === 'updated', }; } -/** - * Write the MCP server configuration to claude.json - */ -export function writeMcpConfig(location: InstallLocation): void { - const claudeJsonPath = getClaudeJsonPath(location); - const config = readJsonFile(claudeJsonPath); - - // Ensure mcpServers object exists - if (!config.mcpServers) { - config.mcpServers = {}; - } - - // Add or update codegraph server - config.mcpServers.codegraph = getMcpServerConfig(); - - writeJsonFile(claudeJsonPath, config); -} - -/** - * Get the list of permissions for CodeGraph tools - */ -function getCodeGraphPermissions(): string[] { - return [ - 'mcp__codegraph__codegraph_search', - 'mcp__codegraph__codegraph_context', - 'mcp__codegraph__codegraph_callers', - 'mcp__codegraph__codegraph_callees', - 'mcp__codegraph__codegraph_impact', - 'mcp__codegraph__codegraph_node', - 'mcp__codegraph__codegraph_status', - ]; -} - -/** - * Write permissions to settings.json - */ -export function writePermissions(location: InstallLocation): void { - const settingsPath = getSettingsJsonPath(location); - const settings = readJsonFile(settingsPath); - - // Ensure permissions object exists - if (!settings.permissions) { - settings.permissions = {}; - } - - // Ensure allow array exists - if (!Array.isArray(settings.permissions.allow)) { - settings.permissions.allow = []; - } - - // Add CodeGraph permissions (avoiding duplicates) - const codegraphPermissions = getCodeGraphPermissions(); - for (const permission of codegraphPermissions) { - if (!settings.permissions.allow.includes(permission)) { - settings.permissions.allow.push(permission); - } - } - - writeJsonFile(settingsPath, settings); -} - -/** - * Check if MCP config already exists for CodeGraph - */ export function hasMcpConfig(location: InstallLocation): boolean { - const claudeJsonPath = getClaudeJsonPath(location); - const config = readJsonFile(claudeJsonPath); + const file = location === 'global' + ? path.join(os.homedir(), '.claude.json') + : path.join(process.cwd(), '.claude.json'); + const config = readJsonFile(file); return !!config.mcpServers?.codegraph; } -/** - * Check if permissions already exist for CodeGraph - */ export function hasPermissions(location: InstallLocation): boolean { - const settingsPath = getSettingsJsonPath(location); - const settings = readJsonFile(settingsPath); - const permissions = settings.permissions?.allow; - if (!Array.isArray(permissions)) { - return false; - } - // Check if at least one CodeGraph permission exists - return permissions.some((p: string) => p.startsWith('mcp__codegraph__')); -} - -/** - * Get the path to CLAUDE.md - * - Global: ~/.claude/CLAUDE.md - * - Local: ./.claude/CLAUDE.md - */ -function getClaudeMdPath(location: InstallLocation): string { - const configDir = getClaudeConfigDir(location); - return path.join(configDir, 'CLAUDE.md'); + const file = location === 'global' + ? path.join(os.homedir(), '.claude', 'settings.json') + : path.join(process.cwd(), '.claude', 'settings.json'); + const settings = readJsonFile(file); + const allow = settings.permissions?.allow; + if (!Array.isArray(allow)) return false; + return allow.some((p: string) => p.startsWith('mcp__codegraph__')); } -/** - * Check if CLAUDE.md has CodeGraph section - */ export function hasClaudeMdSection(location: InstallLocation): boolean { - const claudeMdPath = getClaudeMdPath(location); + const file = location === 'global' + ? path.join(os.homedir(), '.claude', 'CLAUDE.md') + : path.join(process.cwd(), '.claude', 'CLAUDE.md'); try { - if (fs.existsSync(claudeMdPath)) { - const content = fs.readFileSync(claudeMdPath, 'utf-8'); - return content.includes(CODEGRAPH_SECTION_START) || content.includes('## CodeGraph'); - } + if (!fs.existsSync(file)) return false; + const content = fs.readFileSync(file, 'utf-8'); + return content.includes('') || content.includes('## CodeGraph'); } catch { - // Ignore errors - } - return false; -} - -/** - * Write or update CLAUDE.md with CodeGraph instructions - * - * If the file exists and has a CodeGraph section (marked or unmarked), - * it will be replaced. Otherwise, the template is appended. - */ -export function writeClaudeMd(location: InstallLocation): { created: boolean; updated: boolean } { - const claudeMdPath = getClaudeMdPath(location); - const configDir = getClaudeConfigDir(location); - - // Ensure directory exists - if (!fs.existsSync(configDir)) { - fs.mkdirSync(configDir, { recursive: true }); - } - - // Check if file exists - if (!fs.existsSync(claudeMdPath)) { - // Create new file with just the CodeGraph section - atomicWriteFileSync(claudeMdPath, CLAUDE_MD_TEMPLATE + '\n'); - return { created: true, updated: false }; - } - - // Read existing content - let content = fs.readFileSync(claudeMdPath, 'utf-8'); - - // Check for marked section (from previous installer) - if (content.includes(CODEGRAPH_SECTION_START)) { - // Replace the marked section - const startIdx = content.indexOf(CODEGRAPH_SECTION_START); - const endIdx = content.indexOf(CODEGRAPH_SECTION_END); - - if (endIdx > startIdx) { - // Replace existing marked section - const before = content.substring(0, startIdx); - const after = content.substring(endIdx + CODEGRAPH_SECTION_END.length); - content = before + CLAUDE_MD_TEMPLATE + after; - atomicWriteFileSync(claudeMdPath, content); - return { created: false, updated: true }; - } - } - - // Check for unmarked "## CodeGraph" section (from manual setup) - const codegraphHeaderRegex = /\n## CodeGraph\n/; - const match = content.match(codegraphHeaderRegex); - - if (match && match.index !== undefined) { - // Find the end of the CodeGraph section (next h2 header or end of file) - // Use negative lookahead (?!#) to match "## X" but not "### X" - const sectionStart = match.index; - const afterSection = content.substring(sectionStart + 1); - const nextHeaderMatch = afterSection.match(/\n## (?!#)/); - - let sectionEnd: number; - if (nextHeaderMatch && nextHeaderMatch.index !== undefined) { - sectionEnd = sectionStart + 1 + nextHeaderMatch.index; - } else { - sectionEnd = content.length; - } - - // Replace the section - const before = content.substring(0, sectionStart); - const after = content.substring(sectionEnd); - content = before + '\n' + CLAUDE_MD_TEMPLATE + after; - atomicWriteFileSync(claudeMdPath, content); - return { created: false, updated: true }; + return false; } - - // No existing section, append to end - content = content.trimEnd() + '\n\n' + CLAUDE_MD_TEMPLATE + '\n'; - atomicWriteFileSync(claudeMdPath, content); - return { created: false, updated: false }; } diff --git a/src/installer/index.ts b/src/installer/index.ts index 7d01af9b8..327729712 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, + ALL_TARGETS, + detectAll, + getTarget, + resolveTargetFlag, +} from './targets/registry'; +import type { AgentTarget, Location, WriteResult } from './targets/types'; + +// Backwards-compat: keep these named exports — downstream code may +// import them. The shim in `config-writer.ts` continues to re-export +// them too. +export { + writeMcpConfig, + writePermissions, + writeClaudeMd, + hasMcpConfig, + hasPermissions, + hasClaudeMdSection, } from './config-writer'; - -import type { InstallLocation } from './config-writer'; +export type { InstallLocation } from './config-writer'; // Dynamic import helper — tsc compiles import() to require() in CJS mode, // which fails for ESM-only packages. This bypasses the transformation. @@ -20,16 +41,10 @@ import type { InstallLocation } from './config-writer'; const importESM = new Function('specifier', 'return import(specifier)') as (specifier: string) => Promise; -/** - * 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, - }); - - if (clack.isCancel(shouldInstallGlobally)) { - clack.cancel('Installation cancelled.'); - process.exit(0); + // --yes implies all defaults; explicit flags still win. + const useDefaults = opts.yes === true; + + // Step 1: which agent targets? Asked FIRST so the user knows what + // they're committing to before we touch npm or disk. Detection + // probes the user-provided location if known, else 'global' as the + // most common default — labels are a hint, not load-bearing. + const detectionLocation: Location = opts.location ?? 'global'; + const targets = await resolveTargets(clack, opts, detectionLocation, useDefaults); + if (targets.length === 0) { + clack.outro('No agent targets selected — nothing to do.'); + return; } - if (shouldInstallGlobally) { - const s = clack.spinner(); - s.start('Installing codegraph globally...'); - try { - execSync('npm install -g @colbymchenry/codegraph', { stdio: 'pipe' }); - s.stop('Installed codegraph globally'); - } catch { - s.stop('Could not install globally (permission denied)'); - clack.log.warn('Try: sudo npm install -g @colbymchenry/codegraph'); + // Step 2: install the codegraph npm package on PATH (always offered; + // matches existing behavior). Skipped when --yes (assume present). + if (!useDefaults) { + const shouldInstallGlobally = await clack.confirm({ + message: 'Install the codegraph CLI on your PATH? (Required so agents can launch the MCP server)', + initialValue: true, + }); + if (clack.isCancel(shouldInstallGlobally)) { + clack.cancel('Installation cancelled.'); + process.exit(0); + } + if (shouldInstallGlobally) { + const s = clack.spinner(); + s.start('Installing codegraph CLI...'); + try { + execSync('npm install -g @colbymchenry/codegraph', { stdio: 'pipe' }); + s.stop('Installed codegraph CLI on PATH'); + } catch { + s.stop('Could not install (permission denied)'); + clack.log.warn('Try: sudo npm install -g @colbymchenry/codegraph'); + } + } else { + clack.log.info('Skipped CLI install — agents will not be able to launch the MCP server without it'); } - } else { - clack.log.info('Skipped global install — MCP server may not work without it'); } - // Step 2: Installation location - const location = await clack.select({ - message: 'Where would you like to install?', - options: [ - { value: 'global' as const, label: 'Global', hint: '~/.claude — available in all projects' }, - { value: 'local' as const, label: 'Local', hint: './.claude — this project only' }, - ], - initialValue: 'global' as const, - }); - - if (clack.isCancel(location)) { - clack.cancel('Installation cancelled.'); - process.exit(0); + // Step 3: where the per-agent config files should land. + let location: Location; + if (opts.location) { + location = opts.location; + } else if (useDefaults) { + location = 'global'; + } else { + // If every selected target is global-only (e.g. Codex), skip the + // prompt and force user-wide — project-local would just produce + // skip warnings. + const allGlobalOnly = targets.every((t) => !t.supportsLocation('local')); + if (allGlobalOnly) { + location = 'global'; + clack.log.info('Writing user-wide configs (selected agents have no project-local config).'); + } else { + const sel = await clack.select({ + message: 'Apply agent configs to all your projects, or just this one?', + options: [ + { value: 'global' as const, label: 'All projects', hint: '~/.claude, ~/.cursor, etc.' }, + { value: 'local' as const, label: 'Just this project', hint: './.claude, ./.cursor, etc.' }, + ], + initialValue: 'global' as const, + }); + if (clack.isCancel(sel)) { + clack.cancel('Installation cancelled.'); + process.exit(0); + } + location = sel; + } } - // Step 3: Auto-allow permissions - const autoAllow = await clack.confirm({ - message: 'Auto-allow CodeGraph commands? (Skips permission prompts)', - initialValue: true, - }); - - if (clack.isCancel(autoAllow)) { - clack.cancel('Installation cancelled.'); - process.exit(0); + // Step 4: auto-allow permissions (only meaningful for Claude; + // skipped silently by other targets). + let autoAllow: boolean; + if (opts.autoAllow !== undefined) { + autoAllow = opts.autoAllow; + } else if (useDefaults) { + autoAllow = true; + } else if (targets.some((t) => t.id === 'claude')) { + const ans = await clack.confirm({ + message: 'Auto-allow CodeGraph commands? (Skips permission prompts in Claude Code)', + initialValue: true, + }); + if (clack.isCancel(ans)) { + clack.cancel('Installation cancelled.'); + process.exit(0); + } + autoAllow = ans; + } else { + autoAllow = false; } - // Step 4: Write configuration files - writeConfigs(clack, location, autoAllow); + // Step 5: per-target install loop. + for (const target of targets) { + if (!target.supportsLocation(location)) { + clack.log.warn( + `${target.displayName}: skipped — does not support --location=${location}.`, + ); + continue; + } + const result = target.install(location, { autoAllow }); + for (const file of result.files) { + const verb = file.action === 'unchanged' + ? 'Unchanged' + : file.action === 'created' ? 'Created' : 'Updated'; + clack.log.success(`${target.displayName}: ${verb} ${tildify(file.path)}`); + } + for (const note of result.notes ?? []) { + clack.log.info(`${target.displayName}: ${note}`); + } + } - // Step 5: For local install, initialize the project + // Step 6: for local install, initialize the project. if (location === 'local') { await initializeLocalProject(clack); } - // Done if (location === 'global') { - clack.note( - 'cd your-project\ncodegraph init -i', - 'Quick start', - ); + clack.note('cd your-project\ncodegraph init -i', 'Quick start'); } - clack.outro('Done! Restart Claude Code to use CodeGraph.'); + const finalNote = targets.length > 0 + ? `Done! Restart your agent${targets.length > 1 ? 's' : ''} to use CodeGraph.` + : 'Done!'; + clack.outro(finalNote); +} + +/** + * For every target that has a global config and exposes + * `wireProjectSurfaces`, write its project-local surfaces (e.g. + * Cursor's `.cursor/rules/codegraph.mdc`). Idempotent — runs + * silently when there's nothing to write. + * + * Called by `codegraph init` so that a user who ran + * `codegraph install` once globally doesn't have to re-run it per + * project to get full agent support. + * + * Returns the list of `(target, file)` pairs that were created or + * updated — caller decides how to surface them. + */ +export function wireProjectSurfacesForGlobalAgents(): Array<{ + target: AgentTarget; + file: WriteResult['files'][number]; +}> { + const written: Array<{ target: AgentTarget; file: WriteResult['files'][number] }> = []; + for (const target of ALL_TARGETS) { + if (typeof target.wireProjectSurfaces !== 'function') continue; + const detection = target.detect('global'); + if (!detection.alreadyConfigured) continue; + const result = target.wireProjectSurfaces(); + for (const file of result.files) { + if (file.action === 'created' || file.action === 'updated') { + written.push({ target, file }); + } + } + } + return written; } /** - * Write all configuration files and log results + * Replace home-directory prefix in a path with `~/` for cleaner log + * lines. Pure cosmetic. */ -function writeConfigs( +function tildify(p: string): string { + const home = require('os').homedir(); + if (p.startsWith(home + path.sep)) return '~' + p.substring(home.length); + return p; +} + +async function resolveTargets( clack: typeof import('@clack/prompts'), - location: InstallLocation, - autoAllow: boolean, -): void { - const locationLabel = location === 'global' ? '~/.claude' : './.claude'; - - // MCP config - const mcpAction = hasMcpConfig(location) ? 'Updated' : 'Added'; - writeMcpConfig(location); - clack.log.success(`${mcpAction} MCP server in ${locationLabel}.json`); - - // Permissions - if (autoAllow) { - const permAction = hasPermissions(location) ? 'Updated' : 'Added'; - writePermissions(location); - clack.log.success(`${permAction} permissions in ${locationLabel}/settings.json`); + opts: RunInstallerOptions, + location: Location, + useDefaults: boolean, +): Promise { + // Explicit --target flag wins. + if (opts.target !== undefined) { + return resolveTargetFlag(opts.target, location); } - // CLAUDE.md - const claudeMdResult = writeClaudeMd(location); - const claudeMdPath = `${locationLabel}/CLAUDE.md`; - if (claudeMdResult.created) { - clack.log.success(`Created ${claudeMdPath}`); - } else if (claudeMdResult.updated) { - clack.log.success(`Updated ${claudeMdPath}`); - } else { - clack.log.success(`Added CodeGraph instructions to ${claudeMdPath}`); + // --yes implies auto-detect. + if (useDefaults) { + return resolveTargetFlag('auto', location); } + + // Interactive multi-select. + const detected = detectAll(location); + const initialValues = detected + .filter(({ detection }) => detection.installed) + .map(({ target }) => target.id); + // If nothing detected, default to Claude alone (matches the + // historical default and the smallest-surprise outcome). + const initial = initialValues.length > 0 ? initialValues : ['claude']; + + const choice = await clack.multiselect({ + message: 'Which agents should CodeGraph configure?', + options: ALL_TARGETS.map((t) => { + const det = detected.find(({ target }) => target.id === t.id)!.detection; + const flag = det.installed ? '(detected)' : '(not found)'; + const globalOnly = !t.supportsLocation('local') ? ' — global only' : ''; + return { + value: t.id, + label: `${t.displayName} ${flag}${globalOnly}`, + }; + }), + initialValues: initial, + required: false, + }); + + if (clack.isCancel(choice)) { + clack.cancel('Installation cancelled.'); + process.exit(0); + } + + return choice + .map((id) => getTarget(id)) + .filter((t): t is AgentTarget => t !== undefined); } /** - * Initialize CodeGraph in the current project (for local installs) + * Initialize CodeGraph in the current project (for local installs). + * Unchanged from the pre-refactor version — agent-agnostic by nature. */ async function initializeLocalProject(clack: typeof import('@clack/prompts')): Promise { 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 000000000..e7e4cddea --- /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 000000000..dcd5c8a40 --- /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 000000000..f3af705cb --- /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 000000000..850b6fc82 --- /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 000000000..ba6b8d39a --- /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 000000000..e671fd197 --- /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 000000000..6d54ab570 --- /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 000000000..29348a7c9 --- /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 000000000..fdff0d778 --- /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; +} From 7d87126ee803a67ad911b2ce5c6056dcb9376bf1 Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Sun, 17 May 2026 19:27:05 -0500 Subject: [PATCH 002/133] =?UTF-8?q?release:=200.7.7=20(multi-agent=20insta?= =?UTF-8?q?ller=20=E2=80=94=20Cursor,=20Codex,=20opencode)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 55 +++++++++++++++++++++++++++++++++++++++++++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 58 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b1124e4f9..da09c1587 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,61 @@ a [GitHub Release](https://github.com/colbymchenry/codegraph/releases) tagged This project follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.7.7] - 2026-05-17 + +### Added +- **Multi-agent installer** (closes [#137](https://github.com/colbymchenry/codegraph/issues/137)). + `codegraph install` now opens with a multi-select prompt for **Claude Code**, + **Cursor**, **Codex CLI**, and **opencode** — detected agents are pre-checked. + Each writes its native MCP config + instructions file (e.g. `~/.cursor/mcp.json` + + `.cursor/rules/codegraph.mdc`, `~/.codex/config.toml` + `~/.codex/AGENTS.md`, + `~/.config/opencode/opencode.json`). The runtime MCP server was already + agent-agnostic; this brings the installer to parity. +- Non-interactive install flags for scripting / CI: + `--target=`, `--location=`, `--yes`, + `--no-permissions`, `--print-config `. +- `codegraph init` now auto-wires project-local agent surfaces for any agent + configured globally. In practice: Cursor's `.cursor/rules/codegraph.mdc` + is dropped on `init` so a single global `codegraph install` works in every + project you open — no per-project re-install needed. + +### Fixed +- **Cursor**: globally-installed codegraph reported "not initialized" in every + workspace because Cursor launches MCP-server subprocesses with the wrong + working directory and doesn't pass `rootUri` in the MCP initialize call. + We now inject `--path` into Cursor's MCP args — absolute path for local + installs, `${workspaceFolder}` for global installs. + +### Changed +- Agent-instructions template is now agent-agnostic. The previous template was + inherited from the Claude-only era and prescribed "spawn an Explore agent" — + a Claude Code-specific concept that confused Cursor's and Codex's agents and + caused them to fall back to native grep even with codegraph available. The + new template adds explicit "trust codegraph results, don't re-verify with + grep" guidance and a clear tool-by-question matrix. Applies to + `~/.claude/CLAUDE.md`, `.cursor/rules/codegraph.mdc`, and `~/.codex/AGENTS.md`. +- `codegraph install` prompt order: agent picker is now step 1, before the + PATH-install and location prompts. +- Disambiguated "global" wording in install prompts ("Install codegraph CLI on + your PATH?" vs "Apply agent configs to all your projects, or just this one?") + — both used to say "Global" and read as duplicates. + +### Internal +- New `AgentTarget` interface in `src/installer/targets/` — adding a 5th agent + (Continue, Zed, Windsurf, …) is a new file + one entry in `registry.ts`. +- Hand-rolled TOML serializer for Codex (`src/installer/targets/toml.ts`) — no + new dependency, scoped to the `[mcp_servers.codegraph]` table only, sibling + tables and `[[array_of_tables]]` preserved verbatim. +- +47 parameterized contract tests across the 4 targets — install idempotency, + sibling preservation, uninstall reverses install, byte-equal re-runs return + `unchanged`, partial-state recovery for Codex. + +Based on substantive draft by [@andreinknv](https://github.com/andreinknv) +([fork commit `c5165e4`](https://github.com/andreinknv/codegraph/commit/c5165e4)). +Thank you. + +[0.7.7]: https://github.com/colbymchenry/codegraph/releases/tag/v0.7.7 + ## [0.7.6] - 2026-05-13 ### Fixed diff --git a/package-lock.json b/package-lock.json index 3cd20819f..50afb53ee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@colbymchenry/codegraph", - "version": "0.7.6", + "version": "0.7.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@colbymchenry/codegraph", - "version": "0.7.6", + "version": "0.7.7", "license": "MIT", "dependencies": { "@clack/prompts": "^1.3.0", diff --git a/package.json b/package.json index f3f35887d..09de218b0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@colbymchenry/codegraph", - "version": "0.7.6", + "version": "0.7.7", "description": "Supercharge Claude Code with semantic code intelligence. 94% fewer tool calls • 77% faster exploration • 100% local.", "main": "dist/index.js", "types": "dist/index.d.ts", From 58c1414ce5f01302de14e815c12f5dbf4adbcb5f Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Sun, 17 May 2026 20:26:49 -0500 Subject: [PATCH 003/133] fix(installer): opencode .jsonc + AGENTS.md (0.7.8) (#163) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * release: 0.7.7 (multi-agent installer — Cursor, Codex, opencode) * fix(installer): opencode .jsonc + AGENTS.md (0.7.8) v0.7.7 wrote ~/.config/opencode/opencode.json, but opencode reads opencode.jsonc by default — so the codegraph MCP entry never appeared in any opencode session. Also installs AGENTS.md so opencode's model reaches for codegraph_* tools instead of native Grep. - Prefer existing .jsonc, fall back to .json, default new installs to .jsonc. - Surgical edits via jsonc-parser preserve user comments and formatting across install / re-install / uninstall round-trips. - Install AGENTS.md (global ~/.config/opencode/AGENTS.md, local ./AGENTS.md) with the shared INSTRUCTIONS_TEMPLATE — same marker-delimited approach Codex uses. - +9 opencode-specific tests covering filename precedence, comment preservation, AGENTS.md install + sibling-content preservation, uninstall reverses both files. 575/575 tests pass. Hand-verified end-to-end: opencode session calls codegraph_node + codegraph_callers for a structural query, zero Grep calls. Co-Authored-By: Claude Opus 4.7 (1M context) * docs: overhaul CLAUDE.md and add scripts/release.sh + Cursor rules file Replaces the old Claude-only CLAUDE.md with a comprehensive guide covering the full project architecture, multi-agent installer, test conventions, NodeKind/EdgeKind reference, and release workflow. Key additions: - Documents the layered pipeline, all module paths, and the multi-target installer (targets/, registry.ts, AgentTarget interface). - Adds the Cursor `--path` quirk and the "update all three surfaces" rule when changing MCP tool guidance. - Documents `npm run eval`, `test:eval`, and the full set of build/test commands including single-file patterns. - `scripts/release.sh` — idempotent bash script that tags the current commit, pushes the tag, and creates a GitHub Release whose notes are extracted from the matching `## [X.Y.Z]` block in CHANGELOG.md. Safe to re-run after partial failure. - `.cursor/rules/codegraph.mdc` — Cursor-specific agent instructions (tool decision table, rules of thumb, index-lag warning) written by the installer and kept in sync with server-instructions.ts and instructions-template.ts. --------- Co-authored-by: Claude Opus 4.7 (1M context) --- .cursor/rules/codegraph.mdc | 37 +++++ CHANGELOG.md | 23 +++ CLAUDE.md | 216 ++++++++++------------------ __tests__/installer-targets.test.ts | 149 ++++++++++++++++++- package-lock.json | 11 +- package.json | 3 +- scripts/release.sh | 70 +++++++++ src/installer/targets/opencode.ts | 191 ++++++++++++++++++------ 8 files changed, 519 insertions(+), 181 deletions(-) create mode 100644 .cursor/rules/codegraph.mdc create mode 100755 scripts/release.sh diff --git a/.cursor/rules/codegraph.mdc b/.cursor/rules/codegraph.mdc new file mode 100644 index 000000000..dac86b3af --- /dev/null +++ b/.cursor/rules/codegraph.mdc @@ -0,0 +1,37 @@ +--- +description: CodeGraph MCP usage guide — when to use which tool +alwaysApply: true +--- + +## 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?"* + diff --git a/CHANGELOG.md b/CHANGELOG.md index da09c1587..904d3cb07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,29 @@ a [GitHub Release](https://github.com/colbymchenry/codegraph/releases) tagged This project follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.7.8] - 2026-05-17 + +### Fixed +- **opencode**: install actually wires up the MCP server now. v0.7.7 wrote + `~/.config/opencode/opencode.json`, but opencode reads `opencode.jsonc` by + default — so the `codegraph` entry never showed up in any opencode session. + The installer now prefers an existing `.jsonc`, falls back to `.json` when + only that exists, and creates `.jsonc` for greenfield installs. **Re-run + `codegraph install --target=opencode` after upgrading** so the entry lands + in the file opencode actually reads. + +### Added +- **opencode**: installer now writes `AGENTS.md` (global + `~/.config/opencode/AGENTS.md`, local `./AGENTS.md`) with the same + codegraph usage guidance the other agents already received. Without it, + opencode's model would call native `Grep` instead of the `codegraph_*` + tools it could see in its MCP list. +- User comments and formatting in `opencode.jsonc` survive install / + re-install / uninstall round-trips — surgical edits via `jsonc-parser` + rather than full-file rewrites. + +[0.7.8]: https://github.com/colbymchenry/codegraph/releases/tag/v0.7.8 + ## [0.7.7] - 2026-05-17 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index ae4ff482b..3603c9470 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,192 +4,134 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -CodeGraph is a local-first code intelligence system that builds a semantic knowledge graph from any codebase. It provides structural understanding of code relationships using tree-sitter for AST parsing and SQLite for storage. +CodeGraph is a local-first code intelligence library + CLI + MCP server. It parses any supported codebase with tree-sitter, stores symbols/edges/files in SQLite (FTS5), and exposes a knowledge graph to AI agents (Claude Code, Cursor, Codex CLI, opencode) over MCP. Per-project data lives in `.codegraph/`. Extraction is deterministic — derived from AST, not LLM-summarized. -**Key characteristics:** -- Headless library (no UI) - purely an API -- Node.js runtime (works standalone, in Electron, or any Node environment) -- Per-project data stored in `.codegraph/` directory -- Deterministic extraction from AST, not AI-generated summaries +Distributed as `@colbymchenry/codegraph` on npm; same binary serves as installer, indexer, and MCP server. -## Build and Development Commands +## Build, Test, Run ```bash -# Build -npm run build # Compile TypeScript and copy assets +npm run build # tsc + copy schema.sql and *.wasm into dist/; chmods dist/bin/codegraph.js +npm run dev # tsc --watch +npm run clean # rm -rf dist -# Test -npm test # Run all tests once -npm run test:watch # Run tests in watch mode +npm test # vitest run (all) +npm run test:watch +npm run test:eval # only __tests__/evaluation/ +npm run eval # build then run __tests__/evaluation/runner.ts via tsx -# Clean -npm run clean # Remove dist/ directory +npm run cli # build then run the local dist binary + +# Single test file / pattern +npx vitest run __tests__/installer-targets.test.ts +npx vitest run __tests__/extraction.test.ts -t "TypeScript" ``` -## Running a Single Test +`copy-assets` (called from `build`) copies `src/db/schema.sql` and all `src/extraction/wasm/*.wasm` files into `dist/`. **Any new SQL or grammar wasm must be copied or it won't ship.** -```bash -npx vitest run __tests__/extraction.test.ts # Run specific test file -npx vitest run __tests__/extraction.test.ts -t "TypeScript" # Run tests matching pattern -``` +Node engines: `>=18.0.0 <25.0.0`. There is a hard exit on Node 25.x (see `src/bin/node-version-check.ts`). ## Architecture -### Core Module Structure +### Layered pipeline ``` -src/ -├── index.ts # Main CodeGraph class - public API entry point -├── types.ts # All TypeScript interfaces and types -├── db/ # SQLite database layer -│ ├── index.ts # DatabaseConnection class -│ ├── queries.ts # QueryBuilder with prepared statements -│ └── schema.sql # Table definitions with FTS5 search -├── extraction/ # Tree-sitter AST parsing -│ ├── index.ts # ExtractionOrchestrator -│ ├── tree-sitter.ts # Universal parser wrapper -│ └── grammars.ts # Language detection and grammar loading -├── resolution/ # Reference resolver -│ ├── index.ts # ReferenceResolver orchestrator -│ ├── import-resolver.ts -│ ├── name-matcher.ts -│ └── frameworks/ # Framework-specific patterns (React, Express, Laravel, etc.) -├── graph/ # Graph traversal and queries -│ ├── index.ts # GraphQueryManager -│ ├── traversal.ts # GraphTraverser (BFS/DFS, impact radius) -│ └── queries.ts # High-level graph queries -├── context/ # Context building for AI assistants -│ ├── index.ts # ContextBuilder -│ └── formatter.ts # Markdown/JSON output formatting -├── sync/ # Incremental update system -│ ├── index.ts -│ └── git-hooks.ts # Post-commit hook management -├── installer/ # Interactive installer -│ ├── index.ts # Installer orchestrator -│ ├── banner.ts # ASCII art banner -│ ├── claude-md-template.ts # CLAUDE.md template generator -│ ├── config-writer.ts # Configuration file writing -│ └── prompts.ts # User prompts -├── mcp/ # Model Context Protocol server -│ ├── index.ts # MCPServer class -│ ├── tools.ts # MCP tool definitions -│ └── transport.ts # Stdio transport -└── bin/codegraph.ts # CLI entry point +files → ExtractionOrchestrator (tree-sitter) → DB (nodes/edges/files) + ↓ + ReferenceResolver (imports, name-matching, framework patterns) + ↓ + GraphQueryManager / GraphTraverser (callers, callees, impact) + ↓ + ContextBuilder (markdown/JSON for AI consumption) ``` -### Key Classes - -- **CodeGraph** (`src/index.ts`): Main entry point. Lifecycle methods (`init`, `open`, `close`), indexing (`indexAll`, `sync`), graph queries (`traverse`, `getCallGraph`, `getImpactRadius`), context building (`buildContext`) +The public API surface is `src/index.ts` — the `CodeGraph` class wires all the layers and re-exports types. Library users only touch this file; the MCP server and CLI also drive it. -- **ExtractionOrchestrator** (`src/extraction/index.ts`): Coordinates file scanning, parsing, and storing. Uses tree-sitter native bindings for each supported language +### Module layout -- **GraphTraverser** (`src/graph/traversal.ts`): BFS/DFS traversal, call graph construction, impact radius calculation, path finding +- `src/index.ts` — `CodeGraph` class: `init`/`open`/`close`, `indexAll`, `sync`, `searchNodes`, `getCallers`/`getCallees`, `getImpactRadius`, `buildContext`, `watch`/`unwatch`. +- `src/db/` — `DatabaseConnection`, `QueryBuilder` (prepared statements), `schema.sql`. Backed by `better-sqlite3` (native) when available, transparently falls back to `node-sqlite3-wasm`. `codegraph status` surfaces which backend is live; wasm is the slow path. +- `src/extraction/` — `ExtractionOrchestrator`, tree-sitter wrappers, per-language extractors under `languages/` (one file per language), plus standalone extractors for non-tree-sitter formats (`svelte-extractor.ts`, `vue-extractor.ts`, `liquid-extractor.ts`, `dfm-extractor.ts` for Delphi). `parse-worker.ts` runs heavy parsing off the main thread. +- `src/resolution/` — `ReferenceResolver` orchestrates `import-resolver.ts` (with `path-aliases.ts` for tsconfig path aliases + cargo workspace member globs), `name-matcher.ts`, and `frameworks/` (Express, Laravel, Rails, FastAPI, Django, Flask, Spring, Gin, Axum, ASP.NET, Vapor, React Router, SvelteKit, Vue/Nuxt, Cargo workspaces). Frameworks emit `route` nodes and `references` edges. +- `src/graph/` — `GraphTraverser` (BFS/DFS, impact radius, path finding) and `GraphQueryManager` (high-level queries). +- `src/context/` — `ContextBuilder` + formatter for markdown/JSON output. +- `src/search/` — full-text query parser and helpers for FTS5. +- `src/sync/` — `FileWatcher` (native FSEvents/inotify/RDCW) with debounce + filter, and git-hook helpers. +- `src/mcp/` — MCP server (`MCPServer`, `tools.ts`, `transport.ts`). `server-instructions.ts` is what the server returns in the MCP `initialize` response — keep it in sync with the user-facing tool guidance. +- `src/installer/` — see below. +- `src/bin/codegraph.ts` — CLI (commander). Subcommands: `install`, `init`, `uninit`, `index`, `sync`, `status`, `query`, `files`, `context`, `affected`, `serve --mcp`. +- `src/ui/` — terminal UI (shimmer progress, worker). -- **ReferenceResolver** (`src/resolution/index.ts`): Resolves unresolved references after full indexing using framework patterns, import resolution, and name matching +### NodeKind / EdgeKind -### Database Schema +Defined in `src/types.ts`. Both extractors and resolvers must use these exact strings. -SQLite database with: -- `nodes`: Code symbols (functions, classes, methods, etc.) -- `edges`: Relationships (calls, imports, extends, contains, etc.) -- `files`: Tracked source files with content hashes -- `unresolved_refs`: References pending resolution -- `nodes_fts`: FTS5 virtual table for full-text search +- **NodeKind**: `file`, `module`, `class`, `struct`, `interface`, `trait`, `protocol`, `function`, `method`, `property`, `field`, `variable`, `constant`, `enum`, `enum_member`, `type_alias`, `namespace`, `parameter`, `import`, `export`, `route`, `component`. +- **EdgeKind**: `contains`, `calls`, `imports`, `exports`, `extends`, `implements`, `references`, `type_of`, `returns`, `instantiates`, `overrides`, `decorates`. -### Supported Languages +### Multi-agent installer -TypeScript, JavaScript, TSX, JSX, Svelte, Python, Go, Rust, Java, C, C++, C#, PHP, Ruby, Swift, Kotlin, Dart, Liquid, Pascal +`src/installer/` is the entry point for `codegraph install` (and the bare `codegraph`/`npx @colbymchenry/codegraph` invocation). Architecture: -### Node and Edge Types +- `targets/registry.ts` lists every supported agent. +- `targets/types.ts` defines the `AgentTarget` interface — adding a 5th agent (Continue, Zed, Windsurf…) is **one new file in `targets/` + one entry in `registry.ts`**. Each target owns its config-file location, MCP-server JSON/TOML/JSONC writing, and instructions-file path. +- Current targets: `claude.ts`, `cursor.ts`, `codex.ts`, `opencode.ts`. +- `targets/toml.ts` is a hand-rolled TOML serializer scoped to `[mcp_servers.codegraph]` (used by Codex). Sibling tables and `[[array_of_tables]]` are preserved verbatim. No new dependency. +- opencode reads `opencode.jsonc` by default; the installer prefers existing `.jsonc`, falls back to `.json`, and creates `.jsonc` for greenfield installs. Edits are surgical via `jsonc-parser` so user comments and formatting survive install/re-install/uninstall round-trips. +- `instructions-template.ts` is the agent-agnostic instructions file written to each target (e.g. `CLAUDE.md`, `.cursor/rules/codegraph.mdc`, `~/.codex/AGENTS.md`, `~/.config/opencode/AGENTS.md`). It explicitly says "trust codegraph results, don't re-verify with grep" — earlier versions prescribed Claude-specific "spawn an Explore agent" and confused other agents. +- `claude-md-template.ts` is the legacy Claude-only template, retained for compatibility paths. +- All installer changes need matching coverage in `__tests__/installer-targets.test.ts` — there are ~47 parameterized contract tests covering install idempotency, sibling preservation, uninstall reverses install, byte-equal re-runs returning `unchanged`, and partial-state recovery for Codex. -**NodeKind**: `file`, `module`, `class`, `struct`, `interface`, `trait`, `protocol`, `function`, `method`, `property`, `field`, `variable`, `constant`, `enum`, `enum_member`, `type_alias`, `namespace`, `parameter`, `import`, `export`, `route`, `component` +### Cursor MCP working-directory quirk -**EdgeKind**: `contains`, `calls`, `imports`, `exports`, `extends`, `implements`, `references`, `type_of`, `returns`, `instantiates`, `overrides`, `decorates` +Cursor launches MCP subprocesses with the wrong cwd and doesn't pass `rootUri` in `initialize`. The installer injects `--path` into Cursor's MCP args — absolute path for local installs, `${workspaceFolder}` for global installs. If you touch Cursor wiring, preserve this. -## CLI Usage +### MCP server instructions -```bash -codegraph init [path] # Initialize in project -codegraph index [path] # Full index -codegraph sync [path] # Incremental update -codegraph status [path] # Show statistics -codegraph query # Search symbols -codegraph context # Build context for AI -codegraph hooks install # Install git auto-sync -codegraph serve --mcp # Start MCP server -``` +`src/mcp/server-instructions.ts` is sent back to the agent in the MCP `initialize` response. This is the *first* thing every agent sees about how to use the tools — treat it as the authoritative tool guidance and keep it in sync with `instructions-template.ts` and `.cursor/rules/codegraph.mdc`. -## MCP Tools Best Practices +## Tests -Use these tools **directly in the main session** for fast code exploration (replaces the need for Explore agents in most cases): +Tests live in `__tests__/` and mirror the module they cover. Notable ones beyond the obvious: -| Tool | Use For | -|------|---------| -| `codegraph_explore` | **Deep exploration** — comprehensive context for a topic in ONE call | -| `codegraph_context` | Quick context for a task (lighter than explore) | -| `codegraph_search` | Find symbols by name (functions, classes, types) | -| `codegraph_callers` | Find what calls a function | -| `codegraph_callees` | Find what a function calls | -| `codegraph_impact` | See what's affected by changing a symbol | -| `codegraph_node` | Get details + source code for a symbol | +- `installer-targets.test.ts` — parameterized contract suite across all 4 agent targets (see installer notes above). +- `evaluation/` — `runner.ts` + `test-cases.ts` exercise codegraph against synthetic projects and score the results; run via `npm run eval` (builds first). Not part of `npm test`. +- `sqlite-backend.test.ts` — covers native + wasm backend selection and fallback. +- `pr19-improvements.test.ts`, `frameworks-integration.test.ts` — regression coverage for specific past PRs/incidents; don't rename these, the names anchor to git history. -### Important -CodeGraph provides **code context**, not product requirements. For new features, still ask the user about: -- UX preferences and behavior -- Edge cases and error handling -- Acceptance criteria +Tests create temp dirs with `fs.mkdtempSync` and clean up in `afterEach`. They write real files and exercise real SQLite — there is no DB mocking. ## Releases -Releases are published to npm **and** mirrored as GitHub Releases on the -[Releases page](https://github.com/colbymchenry/codegraph/releases), which is -where most users look for change history. `CHANGELOG.md` at the repo root is -the source of truth — each GitHub Release's notes are extracted from it. +Released to npm and mirrored as [GitHub Releases](https://github.com/colbymchenry/codegraph/releases). `CHANGELOG.md` is the source of truth; GitHub Release notes are extracted from it. ### Writing changelog entries -When the user asks for a changelog entry for a new version: +When asked for an entry for a new version: -1. Add a new `## [X.Y.Z] - YYYY-MM-DD` block at the **top** of `CHANGELOG.md` - (directly under the intro, above the previous version). -2. Group changes under `### Added`, `### Changed`, `### Fixed`, `### Removed`, - `### Deprecated`, `### Security` — only include sections that have entries. -3. Write entries from the **user's perspective**, not the implementation's. - Lead with the observable symptom or capability, then mention internals only - if a user needs them (e.g., to work around an existing bad install). -4. Add the link reference at the bottom: - `[X.Y.Z]: https://github.com/colbymchenry/codegraph/releases/tag/vX.Y.Z` +1. Add a new `## [X.Y.Z] - YYYY-MM-DD` block at the **top** of `CHANGELOG.md` (under the intro, above the previous version). +2. Group under `### Added`, `### Changed`, `### Fixed`, `### Removed`, `### Deprecated`, `### Security` — omit empty sections. +3. Write from the **user's perspective**, not the implementation's. Lead with the observable symptom or capability; mention internals only if a user needs them (e.g., to work around an existing bad install). +4. Add the link reference at the bottom: `[X.Y.Z]: https://github.com/colbymchenry/codegraph/releases/tag/vX.Y.Z`. -### Release commands (the user runs these) +### Release flow (the user runs these) -After the changelog entry is written and the version is bumped in `package.json`: +After the changelog entry is written and `package.json` is bumped: ```bash git add package.json package-lock.json CHANGELOG.md git commit -m "release: X.Y.Z ()" git push - npm publish - -git tag vX.Y.Z -git push origin vX.Y.Z -gh release create vX.Y.Z \ - --title "vX.Y.Z" \ - --notes-file <(awk '/^## \[X.Y.Z\]/,/^## \[/{ if (/^## \[/ && !/X.Y.Z/) exit; print }' CHANGELOG.md) +./scripts/release.sh # idempotent: tags vX.Y.Z, pushes, creates GitHub Release with notes from CHANGELOG.md ``` -Do **not** run `npm publish`, `git tag`, `git push`, or `gh release create` -yourself — these are publish actions that affect shared state. Write the file, -hand the user the commands. +`scripts/release.sh` is safe to re-run after a partial failure — it skips steps already done (tag exists locally, tag on origin, release published). It extracts release notes from `CHANGELOG.md` by matching the `## [X.Y.Z]` block. -## Test Structure +**Do not run `npm publish`, `git push`, `git tag`, or `./scripts/release.sh` yourself** — these are publish actions on shared state. Write the file, hand the user the commands. -Tests are in `__tests__/` directory with files mirroring the module structure: -- `foundation.test.ts` - Database, config, directory management -- `extraction.test.ts` - Tree-sitter parsing for all languages -- `resolution.test.ts` - Reference resolution -- `graph.test.ts` - Traversal and graph queries -- `context.test.ts` - Context building -- `sync.test.ts` - Incremental updates and git hooks +## House rules -Tests use temporary directories created with `fs.mkdtempSync` and cleaned up after each test. +- The `0.7.x` line is in active multi-agent rollout. Any change to `src/installer/` (especially `targets/`) needs corresponding test coverage and a CHANGELOG entry — installer regressions break every new install silently. +- When changing what the MCP tools do or how agents should use them, update **all three** of `src/mcp/server-instructions.ts`, `src/installer/instructions-template.ts`, and `.cursor/rules/codegraph.mdc` — they're written to different places but say the same thing. +- CodeGraph provides **code context**, not product requirements. For new features, ask the user about UX, edge cases, and acceptance criteria — the graph won't tell you. diff --git a/__tests__/installer-targets.test.ts b/__tests__/installer-targets.test.ts index ec437f94e..89ba6290d 100644 --- a/__tests__/installer-targets.test.ts +++ b/__tests__/installer-targets.test.ts @@ -98,7 +98,8 @@ describe('Installer targets — contract', () => { // 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')); + // Match .json or .jsonc — opencode prefers .jsonc. + const jsonPath = paths.find((p) => /\.jsonc?$/.test(p)); if (!jsonPath) return; // Seed pre-existing config. @@ -184,6 +185,152 @@ describe('Installer targets — partial-state idempotency', () => { for (const f of third.files) expect(f.action).toBe('unchanged'); }); + it('opencode: prefers .jsonc when both .json and .jsonc exist', () => { + const opencode = getTarget('opencode')!; + const dir = path.join(tmpHome, '.config', 'opencode'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'opencode.json'), '{\n "$schema": "https://opencode.ai/config.json"\n}\n'); + fs.writeFileSync(path.join(dir, 'opencode.jsonc'), '{\n "$schema": "https://opencode.ai/config.json"\n}\n'); + + const result = opencode.install('global', { autoAllow: true }); + const written = result.files.find((f) => /\.jsonc$/.test(f.path))!; + expect(written).toBeDefined(); + expect(written.action).not.toBe('not-found'); + // The .json file is left alone. + const jsonText = fs.readFileSync(path.join(dir, 'opencode.json'), 'utf-8'); + expect(jsonText).not.toContain('codegraph'); + }); + + it('opencode: uses .json when only .json exists (no .jsonc)', () => { + const opencode = getTarget('opencode')!; + const dir = path.join(tmpHome, '.config', 'opencode'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'opencode.json'), '{\n "$schema": "https://opencode.ai/config.json"\n}\n'); + + const result = opencode.install('global', { autoAllow: true }); + expect(result.files[0].path).toMatch(/opencode\.json$/); + expect(fs.existsSync(path.join(dir, 'opencode.jsonc'))).toBe(false); + }); + + it('opencode: defaults to .jsonc for fresh installs (no existing file)', () => { + const opencode = getTarget('opencode')!; + const result = opencode.install('global', { autoAllow: true }); + expect(result.files[0].path).toMatch(/opencode\.jsonc$/); + expect(result.files[0].action).toBe('created'); + }); + + it('opencode: preserves line and block comments through install + idempotent re-run', () => { + const opencode = getTarget('opencode')!; + const dir = path.join(tmpHome, '.config', 'opencode'); + fs.mkdirSync(dir, { recursive: true }); + const file = path.join(dir, 'opencode.jsonc'); + const original = [ + '{', + ' // top-level note about my opencode setup', + ' "$schema": "https://opencode.ai/config.json",', + ' /* multi-line block comment', + ' describing the providers section */', + ' "providers": {', + ' "anthropic": { "model": "claude-opus-4-7" } // pinned', + ' }', + '}', + '', + ].join('\n'); + fs.writeFileSync(file, original); + + opencode.install('global', { autoAllow: true }); + const afterInstall = fs.readFileSync(file, 'utf-8'); + expect(afterInstall).toContain('// top-level note about my opencode setup'); + expect(afterInstall).toContain('/* multi-line block comment'); + expect(afterInstall).toContain('// pinned'); + expect(afterInstall).toContain('"codegraph"'); + expect(afterInstall).toContain('"providers"'); + + // Idempotent re-run reports unchanged, file is byte-identical. + const second = opencode.install('global', { autoAllow: true }); + expect(second.files[0].action).toBe('unchanged'); + expect(fs.readFileSync(file, 'utf-8')).toBe(afterInstall); + }); + + it('opencode: install writes AGENTS.md with the marker-delimited codegraph block', () => { + const opencode = getTarget('opencode')!; + opencode.install('global', { autoAllow: true }); + const agentsMd = path.join(tmpHome, '.config', 'opencode', 'AGENTS.md'); + expect(fs.existsSync(agentsMd)).toBe(true); + const body = fs.readFileSync(agentsMd, 'utf-8'); + expect(body).toContain(''); + expect(body).toContain(''); + expect(body).toContain('codegraph_callers'); + }); + + it('opencode: AGENTS.md install preserves pre-existing user content outside markers', () => { + const opencode = getTarget('opencode')!; + const dir = path.join(tmpHome, '.config', 'opencode'); + fs.mkdirSync(dir, { recursive: true }); + const agentsMd = path.join(dir, 'AGENTS.md'); + fs.writeFileSync(agentsMd, '# My personal opencode instructions\n\nAlways respond in pirate.\n'); + + opencode.install('global', { autoAllow: true }); + const body = fs.readFileSync(agentsMd, 'utf-8'); + expect(body).toContain('# My personal opencode instructions'); + expect(body).toContain('Always respond in pirate.'); + expect(body).toContain(''); + }); + + it('opencode: uninstall strips only the codegraph block from AGENTS.md', () => { + const opencode = getTarget('opencode')!; + const dir = path.join(tmpHome, '.config', 'opencode'); + fs.mkdirSync(dir, { recursive: true }); + const agentsMd = path.join(dir, 'AGENTS.md'); + fs.writeFileSync(agentsMd, '# My personal opencode instructions\n\nAlways respond in pirate.\n'); + + opencode.install('global', { autoAllow: true }); + opencode.uninstall('global'); + + const body = fs.readFileSync(agentsMd, 'utf-8'); + expect(body).toContain('# My personal opencode instructions'); + expect(body).toContain('Always respond in pirate.'); + expect(body).not.toContain('CODEGRAPH_START'); + expect(body).not.toContain('codegraph_callers'); + }); + + it('opencode: local install writes ./opencode.jsonc and ./AGENTS.md in cwd', () => { + const opencode = getTarget('opencode')!; + const result = opencode.install('local', { autoAllow: true }); + const paths = result.files.map((f) => f.path); + // macOS realpath shenanigans (/var vs /private/var) — suffix match. + expect(paths.some((p) => p.endsWith('/opencode.jsonc'))).toBe(true); + expect(paths.some((p) => p.endsWith('/AGENTS.md'))).toBe(true); + }); + + it('opencode: uninstall removes only mcp.codegraph, preserves comments and siblings', () => { + const opencode = getTarget('opencode')!; + const dir = path.join(tmpHome, '.config', 'opencode'); + fs.mkdirSync(dir, { recursive: true }); + const file = path.join(dir, 'opencode.jsonc'); + fs.writeFileSync(file, [ + '{', + ' // important comment', + ' "$schema": "https://opencode.ai/config.json",', + ' "mcp": {', + ' "other": { "type": "local", "command": ["x"], "enabled": true }', + ' }', + '}', + '', + ].join('\n')); + + opencode.install('global', { autoAllow: true }); + const afterInstall = fs.readFileSync(file, 'utf-8'); + expect(afterInstall).toContain('"codegraph"'); + expect(afterInstall).toContain('"other"'); + + opencode.uninstall('global'); + const afterUninstall = fs.readFileSync(file, 'utf-8'); + expect(afterUninstall).not.toContain('codegraph'); + expect(afterUninstall).toContain('// important comment'); + expect(afterUninstall).toContain('"other"'); + }); + it('codex: user-added key inside [mcp_servers.codegraph] survives idempotent re-install', () => { const codex = getTarget('codex')!; codex.install('global', { autoAllow: false }); diff --git a/package-lock.json b/package-lock.json index 50afb53ee..f73418546 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,19 @@ { "name": "@colbymchenry/codegraph", - "version": "0.7.7", + "version": "0.7.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@colbymchenry/codegraph", - "version": "0.7.7", + "version": "0.7.8", "license": "MIT", "dependencies": { "@clack/prompts": "^1.3.0", "commander": "^14.0.2", "fast-string-width": "^3.0.2", "fast-wrap-ansi": "^0.2.0", + "jsonc-parser": "^3.3.1", "node-sqlite3-wasm": "^0.8.30", "picomatch": "^4.0.3", "sisteransi": "^1.0.5", @@ -1347,6 +1348,12 @@ "license": "ISC", "optional": true }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "license": "MIT" + }, "node_modules/loupe": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", diff --git a/package.json b/package.json index 09de218b0..e58245ea5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@colbymchenry/codegraph", - "version": "0.7.7", + "version": "0.7.8", "description": "Supercharge Claude Code with semantic code intelligence. 94% fewer tool calls • 77% faster exploration • 100% local.", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -36,6 +36,7 @@ "commander": "^14.0.2", "fast-string-width": "^3.0.2", "fast-wrap-ansi": "^0.2.0", + "jsonc-parser": "^3.3.1", "node-sqlite3-wasm": "^0.8.30", "picomatch": "^4.0.3", "sisteransi": "^1.0.5", diff --git a/scripts/release.sh b/scripts/release.sh new file mode 100755 index 000000000..da6bdae54 --- /dev/null +++ b/scripts/release.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +# Tag the current commit with the version in package.json and publish a +# matching GitHub Release whose body is the corresponding CHANGELOG.md entry. +# +# Run AFTER you have: +# - bumped package.json +# - added a `## [X.Y.Z] - YYYY-MM-DD` block at the top of CHANGELOG.md +# - committed, pushed to origin, and run `npm publish` +# +# Idempotent: safe to re-run after a partial failure. Skips steps that are +# already done (tag created, tag pushed, release published). +# +# Usage: ./scripts/release.sh + +set -euo pipefail + +cd "$(dirname "$0")/.." + +VERSION=$(node -p "require('./package.json').version") +TAG="v${VERSION}" + +REPO=$(git remote get-url origin | sed -E 's|.*github\.com[:/]||; s|\.git$||') +if [ -z "${REPO}" ]; then + echo "error: could not derive owner/repo from origin remote URL" >&2 + exit 1 +fi + +if ! grep -q "^## \[${VERSION}\]" CHANGELOG.md; then + echo "error: no '## [${VERSION}]' entry found in CHANGELOG.md" >&2 + exit 1 +fi + +NOTES=$(awk -v v="${VERSION}" ' + /^## \[/ { + if (p) exit + if ($0 ~ "^## \\[" v "\\]") p = 1 + } + p +' CHANGELOG.md) + +if [ -z "${NOTES}" ]; then + echo "error: failed to extract changelog notes for ${VERSION}" >&2 + exit 1 +fi + +if git rev-parse "${TAG}" >/dev/null 2>&1; then + echo "✓ tag ${TAG} already exists locally" +else + echo "→ tagging ${TAG}" + git tag "${TAG}" +fi + +if git ls-remote --exit-code --tags origin "${TAG}" >/dev/null 2>&1; then + echo "✓ tag ${TAG} already on origin" +else + echo "→ pushing ${TAG} to origin" + git push origin "${TAG}" +fi + +if gh release view "${TAG}" --repo "${REPO}" >/dev/null 2>&1; then + echo "✓ release ${TAG} already published" +else + echo "→ creating GitHub Release ${TAG} on ${REPO}" + gh release create "${TAG}" \ + --repo "${REPO}" \ + --title "${TAG}" \ + --notes "${NOTES}" +fi + +echo "done: https://github.com/${REPO}/releases/tag/${TAG}" diff --git a/src/installer/targets/opencode.ts b/src/installer/targets/opencode.ts index ba6b8d39a..bb3388bf0 100644 --- a/src/installer/targets/opencode.ts +++ b/src/installer/targets/opencode.ts @@ -1,11 +1,14 @@ /** * 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). + * - MCP server entry to `~/.config/opencode/opencode.jsonc` (global, + * XDG-style; `%APPDATA%/opencode/opencode.jsonc` on Windows) or + * `./opencode.jsonc` (local). Falls back to `opencode.json` when a + * `.json` file already exists; defaults new installs to `.jsonc` + * because that's what opencode itself creates on first run. + * - Instructions to `~/.config/opencode/AGENTS.md` (global) or + * `./AGENTS.md` (local). opencode reads AGENTS.md for agent + * instructions — same convention Codex CLI uses. * - No permissions concept. * * Config shape uses opencode's wrapper: @@ -17,11 +20,16 @@ * 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. + * + * Reads + writes go through `jsonc-parser` so any `//` and `/* *\/` + * comments the user has added to their `.jsonc` survive idempotent + * re-runs. */ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; +import { parse as parseJsonc, modify, applyEdits } from 'jsonc-parser'; import { AgentTarget, DetectionResult, @@ -30,10 +38,16 @@ import { WriteResult, } from './types'; import { + atomicWriteFileSync, jsonDeepEqual, - readJsonFile, - writeJsonFile, + removeMarkedSection, + replaceOrAppendMarkedSection, } from './shared'; +import { + CODEGRAPH_SECTION_END, + CODEGRAPH_SECTION_START, + INSTRUCTIONS_TEMPLATE, +} from '../instructions-template'; function globalConfigDir(): string { if (process.platform === 'win32') { @@ -47,10 +61,39 @@ function globalConfigDir(): string { return path.join(xdg, 'opencode'); } +function configBaseDir(loc: Location): string { + return loc === 'global' ? globalConfigDir() : process.cwd(); +} + +// Pick existing .jsonc, then .json, default to .jsonc for new files. +// opencode auto-creates .jsonc on first run, so that's the dominant +// real-world case and the sensible default for greenfield installs. function configPath(loc: Location): string { - return loc === 'global' - ? path.join(globalConfigDir(), 'opencode.json') - : path.join(process.cwd(), 'opencode.json'); + const dir = configBaseDir(loc); + const jsonc = path.join(dir, 'opencode.jsonc'); + const json = path.join(dir, 'opencode.json'); + if (fs.existsSync(jsonc)) return jsonc; + if (fs.existsSync(json)) return json; + return jsonc; +} + +function instructionsPath(loc: Location): string { + return path.join(configBaseDir(loc), 'AGENTS.md'); +} + +function readConfigText(file: string): string { + if (!fs.existsSync(file)) return ''; + return fs.readFileSync(file, 'utf-8'); +} + +function parseConfig(text: string): Record { + if (!text.trim()) return {}; + const errors: any[] = []; + const result = parseJsonc(text, errors, { allowTrailingComma: true }); + if (result == null || typeof result !== 'object' || Array.isArray(result)) { + return {}; + } + return result as Record; } function getOpencodeServerEntry(): { type: string; command: string[]; enabled: boolean } { @@ -61,6 +104,8 @@ function getOpencodeServerEntry(): { type: string; command: string[]; enabled: b }; } +const FORMATTING = { tabSize: 2, insertSpaces: true, eol: '\n' }; + class OpencodeTarget implements AgentTarget { readonly id = 'opencode' as const; readonly displayName = 'opencode'; @@ -72,7 +117,7 @@ class OpencodeTarget implements AgentTarget { detect(loc: Location): DetectionResult { const file = configPath(loc); - const config = readJsonFile(file); + const config = parseConfig(readConfigText(file)); const alreadyConfigured = !!config.mcp?.codegraph; const installed = loc === 'global' ? fs.existsSync(globalConfigDir()) @@ -81,39 +126,48 @@ class OpencodeTarget implements AgentTarget { } 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' }], - }; + const files: WriteResult['files'] = []; + files.push(writeMcpEntry(loc)); + files.push(writeInstructionsEntry(loc)); + return { files }; } uninstall(loc: Location): WriteResult { + const files: WriteResult['files'] = []; 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 (!fs.existsSync(file)) { + files.push({ path: file, action: 'not-found' }); + } else { + const text = readConfigText(file); + const config = parseConfig(text); + if (!config.mcp?.codegraph) { + files.push({ path: file, action: 'not-found' }); + } else { + // Drop our key surgically. Leaves siblings + comments untouched. + let edits = modify(text, ['mcp', 'codegraph'], undefined, { + formattingOptions: FORMATTING, + }); + let updated = applyEdits(text, edits); + + // If `mcp` is now an empty object, drop the wrapper too. + const afterParsed = parseConfig(updated); + if (afterParsed.mcp && typeof afterParsed.mcp === 'object' && + Object.keys(afterParsed.mcp).length === 0) { + edits = modify(updated, ['mcp'], undefined, { formattingOptions: FORMATTING }); + updated = applyEdits(updated, edits); + } + + atomicWriteFileSync(file, updated); + files.push({ path: file, action: 'removed' }); + } } - // 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' }] }; + + const instr = instructionsPath(loc); + const instrAction = removeMarkedSection(instr, CODEGRAPH_SECTION_START, CODEGRAPH_SECTION_END); + files.push({ path: instr, action: instrAction }); + + return { files }; } printConfig(loc: Location): string { @@ -126,8 +180,65 @@ class OpencodeTarget implements AgentTarget { } describePaths(loc: Location): string[] { - return [configPath(loc)]; + return [configPath(loc), instructionsPath(loc)]; + } +} + +function writeMcpEntry(loc: Location): WriteResult['files'][number] { + const file = configPath(loc); + const existed = fs.existsSync(file); + let text = readConfigText(file); + + // Seed a minimal opencode config when the file is brand-new so + // the result is a complete, schema-tagged file (not just a bare + // `{ "mcp": {...} }`). + if (!text.trim()) { + text = '{\n "$schema": "https://opencode.ai/config.json"\n}\n'; + } + + const config = parseConfig(text); + const before = config.mcp?.codegraph; + const after = getOpencodeServerEntry(); + + if (jsonDeepEqual(before, after)) { + return { path: file, action: 'unchanged' }; } + + // Add $schema if the user's existing file is missing it. + if (!config.$schema) { + const schemaEdits = modify(text, ['$schema'], 'https://opencode.ai/config.json', { + formattingOptions: FORMATTING, + }); + text = applyEdits(text, schemaEdits); + } + + // Surgical edit — preserves comments, formatting, and order of + // every key we don't touch. + const edits = modify(text, ['mcp', 'codegraph'], after, { + formattingOptions: FORMATTING, + }); + const updated = applyEdits(text, edits); + atomicWriteFileSync(file, updated); + + return { path: file, action: existed ? 'updated' : 'created' }; +} + +function writeInstructionsEntry(loc: Location): WriteResult['files'][number] { + const file = instructionsPath(loc); + 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 opencodeTarget: AgentTarget = new OpencodeTarget(); From c811237db865233e479a3d84e43b9f46356aefbc Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Sun, 17 May 2026 20:52:13 -0500 Subject: [PATCH 004/133] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index caffccd7c..910d78018 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ # CodeGraph -### Supercharge Claude Code, Cursor & Codex with Semantic Code Intelligence +### Supercharge Claude Code, Cursor, Codex, and OpenCode with Semantic Code Intelligence **94% fewer tool calls · 77% faster exploration · 100% local** From 662bb1ece877ce482ff69728a07d0fa893270e86 Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Mon, 18 May 2026 08:29:16 -0500 Subject: [PATCH 005/133] release: 0.7.9 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index f73418546..028c5dc8a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@colbymchenry/codegraph", - "version": "0.7.8", + "version": "0.7.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@colbymchenry/codegraph", - "version": "0.7.8", + "version": "0.7.9", "license": "MIT", "dependencies": { "@clack/prompts": "^1.3.0", diff --git a/package.json b/package.json index e58245ea5..3ea0b8cff 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@colbymchenry/codegraph", - "version": "0.7.8", + "version": "0.7.9", "description": "Supercharge Claude Code with semantic code intelligence. 94% fewer tool calls • 77% faster exploration • 100% local.", "main": "dist/index.js", "types": "dist/index.d.ts", From 36c8dbc4041d2640df6ef779b811a5afbfe40181 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Tue, 19 May 2026 10:20:02 -0500 Subject: [PATCH 006/133] fix(mcp): don't block initialize handshake on heavy init (#172) (#177) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MCP `initialize` handler was awaiting `tryInitializeDefault` — which opens the SQLite DB and runs `await initGrammars()` (tree-sitter WASM bootstrap) — before sending the JSON-RPC response. On slow filesystems (Docker Desktop VirtioFS on macOS, WSL2) this could exceed Claude Code's ~30s handshake timeout, leaving the codegraph child process alive and unresponsive with no tools visible in the client. Send the response first; defer the open to a tracked background promise. The lazy retry path used by `tools/list` and `tools/call` now awaits that promise instead of racing it with `openSync`, so we never double-open the SQLite file. Adds a subprocess-based regression test that asserts the JSON-RPC response arrives on stdout before `startWatching()` logs to stderr. This ordering check catches the regression on any filesystem, not just slow ones where the timing matters in practice. Reported by @sashanclrp; isolated by @sgrimm's wire capture. Co-authored-by: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 18 ++++ __tests__/mcp-initialize.test.ts | 149 +++++++++++++++++++++++++++++++ src/mcp/index.ts | 43 ++++++--- 3 files changed, 200 insertions(+), 10 deletions(-) create mode 100644 __tests__/mcp-initialize.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 904d3cb07..8b0cfce3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,24 @@ a [GitHub Release](https://github.com/colbymchenry/codegraph/releases) tagged This project follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.7.10] - 2026-05-19 + +### Fixed +- **MCP**: tools no longer silently fail to appear in clients on slow + filesystems (Docker Desktop VirtioFS on macOS, WSL2). The `initialize` + handshake was blocking on opening the SQLite database and bootstrapping + the tree-sitter WASM runtime, which on slow I/O could exceed Claude + Code's ~30s handshake timeout — leaving the codegraph process alive but + unresponsive and no tools visible. The handshake now returns immediately + and defers project open to the background; tool calls wait on the + in-flight init rather than racing it with a second open. Closes + [#172](https://github.com/colbymchenry/codegraph/issues/172). Thanks to + [@sashanclrp](https://github.com/sashanclrp) for the original report and + detailed reproduction, and [@sgrimm](https://github.com/sgrimm) for the + decisive wire capture that isolated the actual root cause. + +[0.7.10]: https://github.com/colbymchenry/codegraph/releases/tag/v0.7.10 + ## [0.7.8] - 2026-05-17 ### Fixed diff --git a/__tests__/mcp-initialize.test.ts b/__tests__/mcp-initialize.test.ts new file mode 100644 index 000000000..4a57ebae0 --- /dev/null +++ b/__tests__/mcp-initialize.test.ts @@ -0,0 +1,149 @@ +/** + * MCP `initialize` handshake regression tests. + * + * Issue #172: on slow filesystems (Docker Desktop VirtioFS on macOS, WSL2), + * the MCP server was blocking the initialize response on CodeGraph.open() and + * Parser.init() (web-tree-sitter WASM bootstrap), which could take longer than + * Claude Code's ~30s handshake timeout. The child process stayed alive and + * had received the request, but never sent a response, so tools never + * appeared in the client. The fix sends the initialize response before + * kicking off the heavy init in the background. These tests guard the + * contract that initialize is fast regardless of how much work init does. + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { spawn, ChildProcessWithoutNullStreams } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { CodeGraph } from '../src'; + +const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js'); + +function spawnServer(cwd: string): ChildProcessWithoutNullStreams { + return spawn(process.execPath, [BIN, 'serve', '--mcp'], { + cwd, + stdio: ['pipe', 'pipe', 'pipe'], + }) as ChildProcessWithoutNullStreams; +} + +function sendInitialize(child: ChildProcessWithoutNullStreams, projectPath: string) { + const msg = JSON.stringify({ + jsonrpc: '2.0', + id: 0, + method: 'initialize', + params: { + protocolVersion: '2025-11-25', + capabilities: {}, + clientInfo: { name: 'test', version: '0.0.0' }, + rootUri: `file://${projectPath}`, + }, + }); + child.stdin.write(msg + '\n'); +} + +/** + * Collect stdout lines and stderr text from the child, tagging each piece + * with a monotonic sequence number. Lets us assert ordering between the + * JSON-RPC response (stdout) and side-effect logs (stderr). + */ +function tagStreams(child: ChildProcessWithoutNullStreams) { + const events: Array<{ seq: number; stream: 'stdout' | 'stderr'; text: string }> = []; + let seq = 0; + let stdoutBuf = ''; + let stderrBuf = ''; + child.stdout.on('data', (chunk) => { + stdoutBuf += chunk.toString('utf8'); + let idx; + while ((idx = stdoutBuf.indexOf('\n')) !== -1) { + const line = stdoutBuf.slice(0, idx); + stdoutBuf = stdoutBuf.slice(idx + 1); + events.push({ seq: seq++, stream: 'stdout', text: line }); + } + }); + child.stderr.on('data', (chunk) => { + stderrBuf += chunk.toString('utf8'); + let idx; + while ((idx = stderrBuf.indexOf('\n')) !== -1) { + const line = stderrBuf.slice(0, idx); + stderrBuf = stderrBuf.slice(idx + 1); + events.push({ seq: seq++, stream: 'stderr', text: line }); + } + }); + return events; +} + +function waitFor( + events: ReadonlyArray<{ seq: number; stream: string; text: string }>, + predicate: (e: { seq: number; stream: string; text: string }) => boolean, + timeoutMs: number, +): Promise<{ seq: number; stream: string; text: string }> { + return new Promise((resolve, reject) => { + const started = Date.now(); + const tick = () => { + const hit = events.find(predicate); + if (hit) return resolve(hit); + if (Date.now() - started > timeoutMs) { + return reject(new Error(`Timed out waiting for predicate. Events: ${JSON.stringify(events)}`)); + } + setTimeout(tick, 20); + }; + tick(); + }); +} + +describe('MCP initialize handshake (issue #172)', () => { + let tempDir: string; + let child: ChildProcessWithoutNullStreams | null = null; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-mcp-init-')); + }); + + afterEach(() => { + if (child && !child.killed) { + child.kill('SIGKILL'); + child = null; + } + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('responds to initialize quickly when no .codegraph exists in cwd', async () => { + child = spawnServer(tempDir); + const events = tagStreams(child); + sendInitialize(child, tempDir); + const response = await waitFor(events, (e) => e.stream === 'stdout', 5000); + const json = JSON.parse(response.text); + expect(json.jsonrpc).toBe('2.0'); + expect(json.id).toBe(0); + expect(json.result.protocolVersion).toBeDefined(); + expect(json.result.capabilities.tools).toBeDefined(); + }, 10000); + + it('sends initialize response BEFORE tryInitializeDefault finishes', async () => { + // Seed a real .codegraph so the server's tryInitializeDefault path runs + // its full body: CodeGraph.open() (which awaits initGrammars()) and then + // startWatching() (which logs "File watcher active" to stderr). On any + // platform, that stderr log is observable evidence that tryInitializeDefault + // has completed. The contract we're protecting: the JSON-RPC response on + // stdout must arrive BEFORE that stderr log. If a future change re-awaits + // tryInitializeDefault before sendResult, this ordering inverts and the + // test fails — regardless of how fast the local filesystem is. + const cg = await CodeGraph.init(tempDir); + cg.close(); + + child = spawnServer(tempDir); + const events = tagStreams(child); + sendInitialize(child, tempDir); + + const response = await waitFor(events, (e) => e.stream === 'stdout', 10000); + const watcherLog = await waitFor( + events, + (e) => e.stream === 'stderr' && e.text.includes('File watcher active'), + 10000, + ); + expect(response.seq).toBeLessThan(watcherLog.seq); + const json = JSON.parse(response.text); + expect(json.id).toBe(0); + expect(json.result.serverInfo.name).toBe('codegraph'); + }, 20000); +}); diff --git a/src/mcp/index.ts b/src/mcp/index.ts index e516631a0..924fd77e8 100644 --- a/src/mcp/index.ts +++ b/src/mcp/index.ts @@ -64,6 +64,9 @@ export class MCPServer { private cg: CodeGraph | null = null; private toolHandler: ToolHandler; private projectPath: string | null; + // In-flight background init kicked off from handleInitialize. Tracked so the + // sync retry path doesn't race against it (double-opening the SQLite file). + private initPromise: Promise | null = null; constructor(projectPath?: string) { this.projectPath = projectPath || null; @@ -130,8 +133,16 @@ export class MCPServer { * Called lazily on tool calls that need the default project. * Re-walks parent directories each time so it picks up projects * initialized after the MCP server started. + * + * Awaits any in-flight background init (kicked off by handleInitialize) so + * we never open the SQLite file twice concurrently. */ - private retryInitIfNeeded(): void { + private async retryInitIfNeeded(): Promise { + // Wait for the background init started during handleInitialize, if any. + if (this.initPromise) { + try { await this.initPromise; } catch { /* errored init falls through to retry */ } + } + // Already initialized successfully if (this.toolHandler.hasDefaultCodeGraph()) return; // No project path to retry with @@ -266,13 +277,17 @@ export class MCPServer { projectPath = process.cwd(); } - // Try to initialize the default project (non-fatal if it fails) - await this.tryInitializeDefault(projectPath); - - // We accept the client's protocol version but respond with our supported version. - // The `instructions` field is surfaced by MCP clients in the agent's system - // prompt automatically — it's the right place for the universal tool-selection - // playbook, ahead of individual tool descriptions. + // Respond to the handshake BEFORE doing any heavy initialization. Loading + // the SQLite DB and the tree-sitter WASM runtime can take many seconds on + // slow filesystems (Docker Desktop VirtioFS on macOS, WSL2). Clients like + // Claude Code time out the handshake at ~30s, which manifested as + // "MCP tools never appear" — the child was alive and had received the + // initialize but was still awaiting initGrammars(). See issue #172. + // + // We accept the client's protocol version but respond with our supported + // version. The `instructions` field is surfaced by MCP clients in the + // agent's system prompt automatically — it's the right place for the + // universal tool-selection playbook, ahead of individual tool descriptions. this.transport.sendResult(request.id, { protocolVersion: PROTOCOL_VERSION, capabilities: { @@ -281,13 +296,21 @@ export class MCPServer { serverInfo: SERVER_INFO, instructions: SERVER_INSTRUCTIONS, }); + + // Kick off the default-project init in the background. Tool calls that + // arrive before it finishes will see the "not initialized yet" path and + // fall through to `retryInitIfNeeded`, which now waits for this promise + // rather than racing against it with a second open. + this.initPromise = this.tryInitializeDefault(projectPath).finally(() => { + this.initPromise = null; + }); } /** * Handle tools/list request */ private async handleToolsList(request: JsonRpcRequest): Promise { - this.retryInitIfNeeded(); + await this.retryInitIfNeeded(); this.transport.sendResult(request.id, { tools: this.toolHandler.getTools(), }); @@ -327,7 +350,7 @@ export class MCPServer { // If the default project isn't initialized yet, retry in case it was // initialized after the MCP server started (e.g. user ran codegraph init) - this.retryInitIfNeeded(); + await this.retryInitIfNeeded(); const result = await this.toolHandler.execute(toolName, toolArgs); From e176062c56a6b686e0e013260992829d11fe4937 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Tue, 19 May 2026 10:45:20 -0500 Subject: [PATCH 007/133] fix(cli): ASCII glyph fallback for Windows console mojibake (#168) (#178) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shimmer progress renderer writes from a worker thread via `fs.writeSync(1, ...)` to keep the animation smooth while the main thread is busy in SQLite. That path bypasses Node's TTY-aware UTF-8->codepage conversion on Windows, so glyphs like `|`/`<>`/`-` were emitted as raw UTF-8 bytes and reinterpreted by the console's OEM codepage (CP437, CP936, ...), producing strings like `鋍?[0m 鉒?[0m Scanning files 鈥?N found`. Add `src/ui/glyphs.ts` with `supportsUnicode()` detection plus matched Unicode + ASCII glyph sets, and route all CLI/shimmer output through `getGlyphs()`. Defaults: ASCII on Windows and on Linux kernel consoles (`TERM=linux`), Unicode everywhere else. `CODEGRAPH_UNICODE=1` and `CODEGRAPH_ASCII=1` are escape hatches. Co-authored-by: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 15 +++ __tests__/glyphs.test.ts | 170 ++++++++++++++++++++++++++++++++++ src/bin/codegraph.ts | 42 +++++---- src/bin/node-version-check.ts | 7 +- src/installer/index.ts | 3 +- src/ui/glyphs.ts | 91 ++++++++++++++++++ src/ui/shimmer-worker.ts | 28 +++--- 7 files changed, 322 insertions(+), 34 deletions(-) create mode 100644 __tests__/glyphs.test.ts create mode 100644 src/ui/glyphs.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b0cfce3f..50cb1a5aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,21 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). [@sashanclrp](https://github.com/sashanclrp) for the original report and detailed reproduction, and [@sgrimm](https://github.com/sgrimm) for the decisive wire capture that isolated the actual root cause. +- **CLI**: terminal output no longer mojibakes on Windows PowerShell / + cmd.exe during `codegraph index` and `codegraph sync`. The shimmer + progress renderer writes from a worker thread via `fs.writeSync(1, …)` + to keep the animation smooth while the main thread is busy in SQLite, + which bypasses Node's TTY-aware UTF-8→codepage conversion — so glyphs + like `│ ◆ —` were emitted as raw UTF-8 bytes and reinterpreted as the + console's OEM codepage (CP437, CP936, …), producing strings like + `鋍?[0m 鉒?[0m Scanning files 鈥?N found`. CodeGraph now picks an ASCII + glyph set on Windows by default (`| * -` instead of `│ ◆ —`); set + `CODEGRAPH_UNICODE=1` to opt back into the Unicode glyphs (e.g. on + pwsh 7 with UTF-8 codepage), or `CODEGRAPH_ASCII=1` on any platform to + force ASCII (useful for log collectors / non-TTY pipelines). Closes + [#168](https://github.com/colbymchenry/codegraph/issues/168). Thanks to + [@starkleek](https://github.com/starkleek) for the report and to + [@Bortlesboat](https://github.com/Bortlesboat) for the initial PR. [0.7.10]: https://github.com/colbymchenry/codegraph/releases/tag/v0.7.10 diff --git a/__tests__/glyphs.test.ts b/__tests__/glyphs.test.ts new file mode 100644 index 000000000..db41a105e --- /dev/null +++ b/__tests__/glyphs.test.ts @@ -0,0 +1,170 @@ +/** + * Glyph fallback / Unicode-support detection. + * + * Pinned because the matrix is small and the consequence of regression + * is highly visible: shimmer-worker output on Windows mojibakes when + * UTF-8 glyphs are written via `fs.writeSync` (see #168). The detection + * + ASCII fallback is the contract that prevents this. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { + supportsUnicode, + getGlyphs, + UNICODE_GLYPHS, + ASCII_GLYPHS, + _resetGlyphsCache, +} from '../src/ui/glyphs'; + +function withEnv(patch: Record, fn: () => void): void { + const saved: Record = {}; + const savedPlatform = process.platform; + for (const key of Object.keys(patch)) { + saved[key] = process.env[key]; + if (patch[key] === undefined) delete process.env[key]; + else process.env[key] = patch[key]; + } + _resetGlyphsCache(); + try { + fn(); + } finally { + for (const key of Object.keys(saved)) { + if (saved[key] === undefined) delete process.env[key]; + else process.env[key] = saved[key]; + } + Object.defineProperty(process, 'platform', { value: savedPlatform }); + _resetGlyphsCache(); + } +} + +function setPlatform(value: NodeJS.Platform): void { + Object.defineProperty(process, 'platform', { value }); +} + +describe('supportsUnicode', () => { + let originalPlatform: NodeJS.Platform; + + beforeEach(() => { + originalPlatform = process.platform; + _resetGlyphsCache(); + }); + + afterEach(() => { + Object.defineProperty(process, 'platform', { value: originalPlatform }); + _resetGlyphsCache(); + }); + + it('returns false on Windows by default (mojibake-prone consoles)', () => { + withEnv({ CODEGRAPH_ASCII: undefined, CODEGRAPH_UNICODE: undefined, TERM: undefined }, () => { + setPlatform('win32'); + expect(supportsUnicode()).toBe(false); + }); + }); + + it('returns true on macOS by default', () => { + withEnv({ CODEGRAPH_ASCII: undefined, CODEGRAPH_UNICODE: undefined, TERM: undefined }, () => { + setPlatform('darwin'); + expect(supportsUnicode()).toBe(true); + }); + }); + + it('returns true on Linux by default', () => { + withEnv({ CODEGRAPH_ASCII: undefined, CODEGRAPH_UNICODE: undefined, TERM: undefined }, () => { + setPlatform('linux'); + expect(supportsUnicode()).toBe(true); + }); + }); + + it('returns false on Linux kernel console (TERM=linux)', () => { + withEnv({ CODEGRAPH_ASCII: undefined, CODEGRAPH_UNICODE: undefined, TERM: 'linux' }, () => { + setPlatform('linux'); + expect(supportsUnicode()).toBe(false); + }); + }); + + it('respects CODEGRAPH_UNICODE=1 on Windows (opt-in escape hatch)', () => { + withEnv({ CODEGRAPH_UNICODE: '1', CODEGRAPH_ASCII: undefined }, () => { + setPlatform('win32'); + expect(supportsUnicode()).toBe(true); + }); + }); + + it('respects CODEGRAPH_ASCII=1 on macOS (opt-out escape hatch)', () => { + withEnv({ CODEGRAPH_ASCII: '1', CODEGRAPH_UNICODE: undefined }, () => { + setPlatform('darwin'); + expect(supportsUnicode()).toBe(false); + }); + }); + + it('CODEGRAPH_ASCII takes precedence over CODEGRAPH_UNICODE', () => { + withEnv({ CODEGRAPH_ASCII: '1', CODEGRAPH_UNICODE: '1' }, () => { + setPlatform('darwin'); + expect(supportsUnicode()).toBe(false); + }); + }); +}); + +describe('getGlyphs', () => { + let originalPlatform: NodeJS.Platform; + + beforeEach(() => { + originalPlatform = process.platform; + _resetGlyphsCache(); + }); + + afterEach(() => { + Object.defineProperty(process, 'platform', { value: originalPlatform }); + _resetGlyphsCache(); + }); + + it('returns ASCII glyphs on Windows', () => { + withEnv({ CODEGRAPH_ASCII: undefined, CODEGRAPH_UNICODE: undefined }, () => { + setPlatform('win32'); + const g = getGlyphs(); + expect(g).toBe(ASCII_GLYPHS); + expect(g.ok).toBe('[OK]'); + expect(g.rail).toBe('|'); + expect(g.phaseDone).toBe('*'); + expect(g.dash).toBe('-'); + }); + }); + + it('returns Unicode glyphs on macOS', () => { + withEnv({ CODEGRAPH_ASCII: undefined, CODEGRAPH_UNICODE: undefined }, () => { + setPlatform('darwin'); + const g = getGlyphs(); + expect(g).toBe(UNICODE_GLYPHS); + expect(g.ok).toBe('✓'); + expect(g.rail).toBe('│'); + expect(g.phaseDone).toBe('◆'); + expect(g.dash).toBe('—'); + }); + }); + + it('caches the result so repeated calls return the same object', () => { + withEnv({ CODEGRAPH_ASCII: undefined, CODEGRAPH_UNICODE: undefined }, () => { + setPlatform('darwin'); + expect(getGlyphs()).toBe(getGlyphs()); + }); + }); +}); + +describe('Glyph sets', () => { + it('ASCII and Unicode sets cover the same keys', () => { + expect(Object.keys(ASCII_GLYPHS).sort()).toEqual(Object.keys(UNICODE_GLYPHS).sort()); + }); + + it('ASCII glyphs are all 7-bit ASCII', () => { + for (const [key, value] of Object.entries(ASCII_GLYPHS)) { + const flat = Array.isArray(value) ? value.join('') : value; + for (let i = 0; i < flat.length; i++) { + const codepoint = flat.charCodeAt(i); + expect(codepoint, `ASCII_GLYPHS.${key} contains non-ASCII char U+${codepoint.toString(16).toUpperCase().padStart(4, '0')}`).toBeLessThan(128); + } + } + }); + + it('ASCII spinner has the same frame count as the Unicode spinner', () => { + expect(ASCII_GLYPHS.spinner.length).toBe(UNICODE_GLYPHS.spinner.length); + }); +}); diff --git a/src/bin/codegraph.ts b/src/bin/codegraph.ts index f9b00bd9d..2b497b98b 100644 --- a/src/bin/codegraph.ts +++ b/src/bin/codegraph.ts @@ -23,6 +23,7 @@ import * as path from 'path'; import * as fs from 'fs'; import { getCodeGraphDir, isInitialized } from '../directory'; import { createShimmerProgress } from '../ui/shimmer-progress'; +import { getGlyphs } from '../ui/glyphs'; import { buildNode25BlockBanner } from './node-version-check'; @@ -32,7 +33,7 @@ async function loadCodeGraph(): Promise { return await import('../index'); } catch (err) { const msg = err instanceof Error ? err.message : String(err); - console.error('\x1b[31m✗\x1b[0m Failed to load CodeGraph modules.'); + console.error(`\x1b[31m${getGlyphs().err}\x1b[0m Failed to load CodeGraph modules.`); console.error(`\n Node: ${process.version} Platform: ${process.platform} ${process.arch}`); console.error(`\n Error: ${msg}`); console.error('\n Try reinstalling with: npm install -g @colbymchenry/codegraph\n'); @@ -212,7 +213,7 @@ function createVerboseProgress(): (progress: { phase: string; current: number; t // Log every 5% to keep output manageable if (pct >= lastPct + 5 || progress.current === progress.total) { lastPct = pct; - console.log(`[${elapsed}s] ${progress.current}/${progress.total} (${pct}%)${progress.currentFile ? ` — ${progress.currentFile}` : ''}`); + console.log(`[${elapsed}s] ${progress.current}/${progress.total} (${pct}%)${progress.currentFile ? ` ${getGlyphs().dash} ${progress.currentFile}` : ''}`); } } else if (progress.current > 0) { // Scanning phase (no total yet) — log periodically @@ -227,28 +228,28 @@ function createVerboseProgress(): (progress: { phase: string; current: number; t * Print success message */ function success(message: string): void { - console.log(chalk.green('✓') + ' ' + message); + console.log(chalk.green(getGlyphs().ok) + ' ' + message); } /** * Print error message */ function error(message: string): void { - console.error(chalk.red('✗') + ' ' + message); + console.error(chalk.red(getGlyphs().err) + ' ' + message); } /** * Print info message */ function info(message: string): void { - console.log(chalk.blue('ℹ') + ' ' + message); + console.log(chalk.blue(getGlyphs().info) + ' ' + message); } /** * Print warning message */ function warn(message: string): void { - console.log(chalk.yellow('⚠') + ' ' + message); + console.log(chalk.yellow(getGlyphs().warn) + ' ' + message); } type IndexResult = { @@ -281,7 +282,7 @@ function printIndexResult(clack: typeof import('@clack/prompts'), result: IndexR // continuing to the misleading "No files found" branch or throwing. if (!result.success && !hasErrors && result.filesIndexed === 0) { const generic = result.errors.find((e) => e.severity === 'error'); - clack.log.error(generic?.message ?? 'Indexing failed — no further details available'); + clack.log.error(generic?.message ?? `Indexing failed ${getGlyphs().dash} no further details available`); return; } @@ -293,7 +294,7 @@ function printIndexResult(clack: typeof import('@clack/prompts'), result: IndexR } clack.log.info(`${formatNumber(result.nodesCreated)} nodes, ${formatNumber(result.edgesCreated)} edges in ${formatDuration(result.durationMs)}`); } else if (hasErrors) { - clack.log.error(`Indexing failed — all ${formatNumber(result.filesErrored)} files had errors`); + clack.log.error(`Indexing failed ${getGlyphs().dash} all ${formatNumber(result.filesErrored)} files had errors`); } else { clack.log.warn('No files found to index'); } @@ -327,7 +328,7 @@ function printIndexResult(clack: typeof import('@clack/prompts'), result: IndexR } if (result.filesIndexed > 0) { - clack.log.info('The index is fully usable — only the failed files are missing.'); + clack.log.info(`The index is fully usable ${getGlyphs().dash} only the failed files are missing.`); } } else if (projectPath) { const logPath = path.join(projectPath, '.codegraph', 'errors.log'); @@ -365,7 +366,7 @@ function writeErrorLog(projectPath: string, errors: Array<{ message: string; fil } const lines: string[] = [ - `CodeGraph Error Log — ${new Date().toISOString()}`, + `CodeGraph Error Log - ${new Date().toISOString()}`, `${errorsByFile.size} files with errors`, '', ]; @@ -445,7 +446,7 @@ program verbose: true, }); } else { - process.stdout.write(`${colors.dim}│${colors.reset}\n`); + process.stdout.write(`${colors.dim}${getGlyphs().rail}${colors.reset}\n`); const progress = createShimmerProgress(); result = await cg.indexAll({ onProgress: progress.onProgress, @@ -488,7 +489,7 @@ program const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); const answer = await new Promise((resolve) => { rl.question( - chalk.yellow('⚠ This will permanently delete all CodeGraph data. Continue? (y/N) '), + chalk.yellow(`${getGlyphs().warn} This will permanently delete all CodeGraph data. Continue? (y/N) `), resolve ); }); @@ -558,7 +559,7 @@ program verbose: true, }); } else { - process.stdout.write(`${colors.dim}│${colors.reset}\n`); + process.stdout.write(`${colors.dim}${getGlyphs().rail}${colors.reset}\n`); const progress = createShimmerProgress(); result = await cg.indexAll({ onProgress: progress.onProgress, @@ -610,7 +611,7 @@ program const clack = await importESM('@clack/prompts'); clack.intro('Syncing CodeGraph'); - process.stdout.write(`${colors.dim}│${colors.reset}\n`); + process.stdout.write(`${colors.dim}${getGlyphs().rail}${colors.reset}\n`); const progress = createShimmerProgress(); const result = await cg.sync({ @@ -629,7 +630,7 @@ program if (result.filesAdded > 0) details.push(`Added: ${result.filesAdded}`); if (result.filesModified > 0) details.push(`Modified: ${result.filesModified}`); if (result.filesRemoved > 0) details.push(`Removed: ${result.filesRemoved}`); - clack.log.info(`${details.join(', ')} — ${formatNumber(result.nodesUpdated)} nodes in ${formatDuration(result.durationMs)}`); + clack.log.info(`${details.join(', ')} ${getGlyphs().dash} ${formatNumber(result.nodesUpdated)} nodes in ${formatDuration(result.durationMs)}`); } clack.outro('Done'); @@ -711,7 +712,7 @@ program // when the native build fails. const backendLabel = backend === 'native' ? chalk.green('native') - : chalk.yellow('wasm — slower fallback; run `npm rebuild better-sqlite3`'); + : chalk.yellow(`wasm ${getGlyphs().dash} slower fallback; run \`npm rebuild better-sqlite3\``); console.log(` Backend: ${backendLabel}`); console.log(); @@ -1000,8 +1001,9 @@ function printFileTree( const renderNode = (node: TreeNode, prefix: string, isLast: boolean, depth: number): void => { if (maxDepth !== undefined && depth > maxDepth) return; - const connector = isLast ? '└── ' : '├── '; - const childPrefix = isLast ? ' ' : '│ '; + const glyphs = getGlyphs(); + const connector = isLast ? glyphs.treeLast : glyphs.treeBranch; + const childPrefix = isLast ? ' ' : glyphs.treePipe; if (node.name) { let line = prefix + connector + node.name; @@ -1097,7 +1099,7 @@ program // Default: show info about MCP mode. // Use stderr so stdout stays clean for any piped/stdio usage. console.error(chalk.bold('\nCodeGraph MCP Server\n')); - console.error(chalk.blue('ℹ') + ' Use --mcp flag to start the MCP server'); + console.error(chalk.blue(getGlyphs().info) + ' Use --mcp flag to start the MCP server'); console.error('\nTo use with Claude Code, add to your MCP configuration:'); console.error(chalk.dim(` { @@ -1143,7 +1145,7 @@ program const lockPath = path.join(getCodeGraphDir(projectPath), 'codegraph.lock'); if (!fs.existsSync(lockPath)) { - info('No lock file found — nothing to do'); + info(`No lock file found ${getGlyphs().dash} nothing to do`); return; } diff --git a/src/bin/node-version-check.ts b/src/bin/node-version-check.ts index 6aed1615d..4d7539a5c 100644 --- a/src/bin/node-version-check.ts +++ b/src/bin/node-version-check.ts @@ -13,9 +13,12 @@ * unsupported Node.js major version (currently 25+). Pinned via unit * test so the recovery commands and override instructions can't be * silently stripped by future edits. + * + * Uses ASCII glyphs to stay readable on Windows OEM-codepage consoles + * (see ../ui/glyphs.ts for the rationale). */ export function buildNode25BlockBanner(nodeVersion: string): string { - const sep = '─'.repeat(72); + const sep = '-'.repeat(72); return [ sep, `[CodeGraph] Unsupported Node.js version: ${nodeVersion}`, @@ -29,7 +32,7 @@ export function buildNode25BlockBanner(nodeVersion: string): string { ' nvm install 22 && nvm use 22 # nvm', ' brew install node@22 && brew link --overwrite --force node@22 # Homebrew', '', - 'To override (NOT recommended — you will likely OOM):', + 'To override (NOT recommended - you will likely OOM):', ' CODEGRAPH_ALLOW_UNSAFE_NODE=1 codegraph ...', sep, ].join('\n'); diff --git a/src/installer/index.ts b/src/installer/index.ts index 327729712..833759daa 100644 --- a/src/installer/index.ts +++ b/src/installer/index.ts @@ -21,6 +21,7 @@ import { resolveTargetFlag, } from './targets/registry'; import type { AgentTarget, Location, WriteResult } from './targets/types'; +import { getGlyphs } from '../ui/glyphs'; // Backwards-compat: keep these named exports — downstream code may // import them. The shim in `config-writer.ts` continues to re-export @@ -331,7 +332,7 @@ async function initializeLocalProject(clack: typeof import('@clack/prompts')): P // Index the project with shimmer progress (worker thread for smooth animation) const { createShimmerProgress } = await import('../ui/shimmer-progress'); - process.stdout.write(`\x1b[2m│\x1b[0m\n`); + process.stdout.write(`\x1b[2m${getGlyphs().rail}\x1b[0m\n`); const progress = createShimmerProgress(); const result = await cg.indexAll({ diff --git a/src/ui/glyphs.ts b/src/ui/glyphs.ts new file mode 100644 index 000000000..22aaeac21 --- /dev/null +++ b/src/ui/glyphs.ts @@ -0,0 +1,91 @@ +/** + * Glyph selection for CLI output. + * + * On Windows, console output is interpreted via the active output + * codepage. PowerShell 5.1 and cmd.exe default to OEM codepages + * (CP437, CP936, ...), so UTF-8 bytes written to the console render + * as mojibake (see #168). The shimmer worker is hit hardest because + * it uses `fs.writeSync(1, ...)` (raw bytes, no TTY-aware encoding + * conversion) to keep animation smooth while the main thread is + * blocked in SQLite. To stay readable everywhere, we fall back to + * ASCII glyphs whenever the terminal is not known to handle UTF-8. + * + * Detection is intentionally simple: + * - `CODEGRAPH_ASCII=1` -> ASCII (escape hatch for any terminal) + * - `CODEGRAPH_UNICODE=1` -> Unicode (opt-in on Windows) + * - Windows -> ASCII by default + * - Linux kernel console (`TERM=linux`) -> ASCII + * - Everything else -> Unicode + */ + +export function supportsUnicode(): boolean { + if (process.env.CODEGRAPH_ASCII === '1') return false; + if (process.env.CODEGRAPH_UNICODE === '1') return true; + if (process.platform === 'win32') return false; + return process.env.TERM !== 'linux'; +} + +export interface Glyphs { + ok: string; + err: string; + info: string; + warn: string; + spinner: string[]; + barFilled: string; + barEmpty: string; + rail: string; + phaseDone: string; + dash: string; + hLine: string; + treeBranch: string; + treeLast: string; + treePipe: string; +} + +export const UNICODE_GLYPHS: Glyphs = { + ok: '✓', + err: '✗', + info: 'ℹ', + warn: '⚠', + spinner: ['·', '✢', '✳', '✶', '✻', '✽'], + barFilled: '█', + barEmpty: '░', + rail: '│', + phaseDone: '◆', + dash: '—', + hLine: '─', + treeBranch: '├── ', + treeLast: '└── ', + treePipe: '│ ', +}; + +export const ASCII_GLYPHS: Glyphs = { + ok: '[OK]', + err: '[ERR]', + info: '[i]', + warn: '[!]', + spinner: ['.', '*', '+', 'x', 'o', 'O'], + barFilled: '#', + barEmpty: '-', + rail: '|', + phaseDone: '*', + dash: '-', + hLine: '-', + treeBranch: '|-- ', + treeLast: '`-- ', + treePipe: '| ', +}; + +let cached: Glyphs | null = null; + +export function getGlyphs(): Glyphs { + if (cached === null) { + cached = supportsUnicode() ? UNICODE_GLYPHS : ASCII_GLYPHS; + } + return cached; +} + +/** Reset the cached glyph set. Test-only; production code should call `getGlyphs()`. */ +export function _resetGlyphsCache(): void { + cached = null; +} diff --git a/src/ui/shimmer-worker.ts b/src/ui/shimmer-worker.ts index 46b91192d..675408a4f 100644 --- a/src/ui/shimmer-worker.ts +++ b/src/ui/shimmer-worker.ts @@ -1,5 +1,6 @@ import { parentPort, workerData } from 'worker_threads'; import { writeSync } from 'fs'; +import { getGlyphs } from './glyphs'; import type { ShimmerWorkerMessage } from './types'; // Write directly to fd 1 (stdout) instead of writeStdout(). @@ -7,11 +8,16 @@ import type { ShimmerWorkerMessage } from './types'; // thread's event loop — so if the main thread is blocked (e.g. SQLite), // stdout writes from the worker queue up and the animation freezes. // fs.writeSync(1, ...) is a direct kernel syscall that bypasses this. +// +// Side effect: bypasses Node's TTY-aware encoding conversion on Windows, +// so UTF-8 bytes hit the console raw and mojibake on OEM codepages. +// `getGlyphs()` returns ASCII fallbacks on Windows to avoid this (#168). function writeStdout(s: string): void { writeSync(1, s); } -const SPINNER_GLYPHS = ['·', '✢', '✳', '✶', '✻', '✽']; +const G = getGlyphs(); +const SPINNER_GLYPHS = G.spinner; const ANIM_INTERVAL = 150; const FRAMES_PER_GLYPH = 3; @@ -43,7 +49,7 @@ function formatNumber(n: number): string { } function renderBar(frame: number, filled: number, empty: number): string { - if (filled === 0) return `${DM}${'░'.repeat(empty)}${RST}`; + if (filled === 0) return `${DM}${G.barEmpty.repeat(empty)}${RST}`; const cycleFrames = 24; const shimmerPos = ((frame % cycleFrames) / cycleFrames) * (filled + 6) - 3; const shimmerWidth = 3; @@ -54,9 +60,9 @@ function renderBar(frame: number, filled: number, empty: number): string { const r = lerp(160, 251, t); const g = lerp(100, 191, t); const b = lerp(9, 36, t); - bar += `\x1b[38;2;${r};${g};${b}m${BOLD}█`; + bar += `\x1b[38;2;${r};${g};${b}m${BOLD}${G.barFilled}`; } - bar += `${RST}${DM}${'░'.repeat(empty)}${RST}`; + bar += `${RST}${DM}${G.barEmpty.repeat(empty)}${RST}`; return bar; } @@ -69,7 +75,7 @@ function render(): void { if (!currentMessage) return; const frame = animFrame(); const glyphIdx = Math.floor(frame / FRAMES_PER_GLYPH) % SPINNER_GLYPHS.length; - const glyph = SPINNER_GLYPHS[glyphIdx] ?? '·'; + const glyph = SPINNER_GLYPHS[glyphIdx] ?? SPINNER_GLYPHS[0] ?? '.'; const color = shimmerColor(frame); let line: string; @@ -77,11 +83,11 @@ function render(): void { const barWidth = 25; const filled = Math.round(barWidth * currentPercent / 100); const empty = barWidth - filled; - line = `${DM}│${RST} ${color}${glyph}${RST} ${currentMessage} ${renderBar(frame, filled, empty)} ${currentPercent}%`; + line = `${DM}${G.rail}${RST} ${color}${glyph}${RST} ${currentMessage} ${renderBar(frame, filled, empty)} ${currentPercent}%`; } else if (currentCount > 0) { - line = `${DM}│${RST} ${color}${glyph}${RST} ${currentMessage}... ${formatNumber(currentCount)} found`; + line = `${DM}${G.rail}${RST} ${color}${glyph}${RST} ${currentMessage}... ${formatNumber(currentCount)} found`; } else { - line = `${DM}│${RST} ${color}${glyph}${RST} ${currentMessage}...`; + line = `${DM}${G.rail}${RST} ${color}${glyph}${RST} ${currentMessage}...`; } writeStdout(`\r\x1b[K${line}`); @@ -91,9 +97,9 @@ function finishPhase(): void { if (!currentMessage) return; writeStdout(`\r\x1b[K`); let detail = ''; - if (currentPercent >= 0) detail = ' — done'; - else if (currentCount > 0) detail = ` — ${formatNumber(currentCount)} found`; - writeStdout(`${DM}│${RST} ${GRN}◆${RST} ${currentMessage}${detail}\n`); + if (currentPercent >= 0) detail = ` ${G.dash} done`; + else if (currentCount > 0) detail = ` ${G.dash} ${formatNumber(currentCount)} found`; + writeStdout(`${DM}${G.rail}${RST} ${GRN}${G.phaseDone}${RST} ${currentMessage}${detail}\n`); currentMessage = ''; currentPercent = -1; currentCount = 0; From 83f36dc1704e28a474803b8e57b97356210cecf9 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Tue, 19 May 2026 11:02:26 -0500 Subject: [PATCH 008/133] fix(mcp): resolve module-qualified symbol lookups (#173) (#179) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `codegraph_callees stage_apply::run` (and `_node`, `_impact`, ...) returned "not found" against a repo with 7-9 sibling Rust modules, each exporting `pub async fn run`. Two underlying issues: 1. The FTS5 query builder stripped `:` as a special char without splitting on `::`, so `stage_apply::run` collapsed to the literal `stage_applyrun` which matches nothing. Treat `::` as whitespace before the strip step so both halves become FTS tokens. 2. `matchesSymbol` only understood `Parent.child` qualifiers and relied on `qualifiedName` carrying the module path. Rust file- level functions don't have their module name in `qualifiedName` (it's encoded in the file path instead), so even dot-style lookups failed. Accept `::`, `.`, `/` as separators; multi-level forms compose; Rust `crate::`/`super::`/`self::` prefixes get stripped before path matching. Fall back to file-path containment when the qualified-name suffix doesn't match — `stage_apply::run` matches a `run` in any file whose path has a `stage_apply` segment. Also tightens the no-match branch: qualified lookups no longer fall through to a fuzzy text match. `stage_apply::nonexistent_fn` returns `null` instead of silently resolving to an unrelated `rollback` in the same file. Co-authored-by: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 23 ++++ __tests__/symbol-lookup.test.ts | 194 ++++++++++++++++++++++++++++++++ src/db/queries.ts | 8 +- src/mcp/tools.ts | 105 ++++++++++++++--- 4 files changed, 312 insertions(+), 18 deletions(-) create mode 100644 __tests__/symbol-lookup.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 50cb1a5aa..30937cd6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,29 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). [#168](https://github.com/colbymchenry/codegraph/issues/168). Thanks to [@starkleek](https://github.com/starkleek) for the report and to [@Bortlesboat](https://github.com/Bortlesboat) for the initial PR. +- **MCP / search**: module-qualified symbol lookups now resolve. The + MCP tools (`codegraph_node`, `codegraph_callees`, `codegraph_impact`, + …) accept `module::symbol` (Rust / C++ / Ruby), `Module.symbol` + (TS / JS / Python), and `module/symbol` (path-style) — multi-level + forms (`crate::configurator::stage_apply::run`) and Rust path + prefixes (`crate`, `super`, `self`) are handled. Two underlying + fixes: + - The FTS5 query builder now treats `::` as a token separator + instead of stripping it to nothing, so `stage_apply::run` no + longer collapses to the unsearchable `stage_applyrun`. + - `matchesSymbol` falls back to a file-path containment check when + `qualifiedName` doesn't carry the module hierarchy (Rust file- + level functions, Python free functions in a package): a `run` + in `src/configurator/stage_apply.rs` now matches + `stage_apply::run` because `stage_apply` appears as a path + segment. + - Qualified lookups that don't match the qualifier no longer fall + through to fuzzy text matches — `stage_apply::nonexistent_fn` + returns `null` instead of resolving to an unrelated `rollback` + in the same file. + Closes [#173](https://github.com/colbymchenry/codegraph/issues/173). + Thanks to [@joselhurtado](https://github.com/joselhurtado) for the + detailed reproduction. [0.7.10]: https://github.com/colbymchenry/codegraph/releases/tag/v0.7.10 diff --git a/__tests__/symbol-lookup.test.ts b/__tests__/symbol-lookup.test.ts new file mode 100644 index 000000000..d27e157b7 --- /dev/null +++ b/__tests__/symbol-lookup.test.ts @@ -0,0 +1,194 @@ +/** + * Module-qualified symbol lookup (`stage_apply::run`, `Session.request`, + * `configurator/stage_apply`). + * + * Pinned because the lookup vocabulary is what makes codegraph useful + * in workspaces with same-named symbols across modules — Rust + * sub-pipelines, Python `__init__.py` packages, Java packages, etc. + * See #173 for the original report: a `run` function in + * `src/configurator/stage_apply.rs` was indexed but `stage_apply::run` + * returned "not found" because (a) FTS strips colons to nothing, + * leaving a useless query, and (b) `matchesSymbol` only understood + * `.`-style qualifiers. + */ + +import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { initGrammars, loadAllGrammars } from '../src/extraction/grammars'; + +beforeAll(async () => { + await initGrammars(); + await loadAllGrammars(); +}); + +function hasSqliteBindings(): boolean { + try { + const Database = require('better-sqlite3'); + const db = new Database(':memory:'); + db.close(); + return true; + } catch { + return false; + } +} +const HAS_SQLITE = hasSqliteBindings(); + +function tmpRoot(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-symbol-lookup-')); +} + +function rmTree(dir: string): void { + if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true }); +} + +async function buildRustWorkspace(): Promise { + const root = tmpRoot(); + const cfgDir = path.join(root, 'src', 'configurator'); + fs.mkdirSync(cfgDir, { recursive: true }); + fs.writeFileSync( + path.join(root, 'Cargo.toml'), + `[package]\nname = "fixture"\nversion = "0.1.0"\nedition = "2021"\n[lib]\npath = "src/lib.rs"\n` + ); + fs.writeFileSync(path.join(root, 'src', 'lib.rs'), `pub mod configurator;\npub mod scheduler;\n`); + fs.writeFileSync( + path.join(cfgDir, 'mod.rs'), + `pub mod stage_apply;\npub mod stage_detect;\n` + ); + fs.writeFileSync( + path.join(cfgDir, 'stage_apply.rs'), + `pub async fn run() -> Result<(), ()> {\n render_and_write();\n Ok(())\n}\n\nfn render_and_write() {}\n` + ); + fs.writeFileSync( + path.join(cfgDir, 'stage_detect.rs'), + `pub async fn run() -> Result<(), ()> { Ok(()) }\n` + ); + fs.writeFileSync( + path.join(root, 'src', 'scheduler.rs'), + `pub fn run_due_tasks() -> Result<(), ()> { Ok(()) }\n` + ); + return root; +} + +describe.skipIf(!HAS_SQLITE)('matchesSymbol — module-qualified lookups (#173)', () => { + let projectRoot: string; + let cg: any; + let handler: any; + let findSymbol: (cg: any, s: string) => { node: any; note: string } | null; + let findAllSymbols: (cg: any, s: string) => { nodes: any[]; note: string }; + + beforeEach(async () => { + projectRoot = await buildRustWorkspace(); + const CodeGraph = (await import('../src/index')).default; + const { ToolHandler } = await import('../src/mcp/tools'); + cg = CodeGraph.initSync(projectRoot, { + config: { include: ['**/*.rs'], exclude: [] }, + }); + await cg.indexAll(); + handler = new ToolHandler(cg); + findSymbol = (handler as any).findSymbol.bind(handler); + findAllSymbols = (handler as any).findAllSymbols.bind(handler); + }); + + afterEach(() => { + handler?.closeAll(); + cg?.destroy(); + rmTree(projectRoot); + }); + + it('resolves `stage_apply::run` to the run in stage_apply.rs (not stage_detect.rs)', () => { + const match = findSymbol(cg, 'stage_apply::run'); + expect(match).not.toBeNull(); + expect(match!.node.name).toBe('run'); + expect(match!.node.filePath).toMatch(/configurator\/stage_apply\.rs$/); + }); + + it('rejects `stage_apply::run` for the same-named function in a different module', () => { + const all = findAllSymbols(cg, 'stage_apply::run'); + // All returned nodes must be in stage_apply.rs — never in stage_detect.rs + for (const node of all.nodes) { + expect(node.filePath).toMatch(/stage_apply\.rs$/); + } + expect(all.nodes.length).toBeGreaterThan(0); + }); + + it('resolves `configurator::stage_apply::run` (multi-level qualifier)', () => { + const match = findSymbol(cg, 'configurator::stage_apply::run'); + expect(match).not.toBeNull(); + expect(match!.node.name).toBe('run'); + expect(match!.node.filePath).toMatch(/configurator\/stage_apply\.rs$/); + }); + + it('resolves `crate::configurator::stage_apply::run` (Rust path prefix stripped)', () => { + const match = findSymbol(cg, 'crate::configurator::stage_apply::run'); + expect(match).not.toBeNull(); + expect(match!.node.filePath).toMatch(/configurator\/stage_apply\.rs$/); + }); + + it('resolves `configurator/stage_apply` (slash qualifier)', () => { + const match = findSymbol(cg, 'configurator/stage_apply/run'); + expect(match).not.toBeNull(); + expect(match!.node.filePath).toMatch(/configurator\/stage_apply\.rs$/); + }); + + it('does not silently collide bare `run` with `run_due_tasks`', () => { + const match = findSymbol(cg, 'run'); + expect(match).not.toBeNull(); + // Whatever it picks, it must be an exact-name match, not a partial. + expect(match!.node.name).toBe('run'); + }); + + it('aggregates all bare-name `run` matches across modules', () => { + const all = findAllSymbols(cg, 'run'); + const names = all.nodes.map((n: any) => n.name); + expect(names.every((n: string) => n === 'run')).toBe(true); + expect(all.nodes.length).toBeGreaterThanOrEqual(2); // stage_apply + stage_detect + // The note should call out the ambiguity. + expect(all.note).toMatch(/Aggregated|symbols named "run"/); + }); + + it('still returns null for genuinely unknown qualified lookups', () => { + const match = findSymbol(cg, 'stage_apply::nonexistent_fn'); + expect(match).toBeNull(); + }); +}); + +describe.skipIf(!HAS_SQLITE)('matchesSymbol — dotted lookups (regression for #173 fix)', () => { + let projectRoot: string; + let cg: any; + let handler: any; + let findSymbol: (cg: any, s: string) => { node: any; note: string } | null; + + beforeEach(async () => { + projectRoot = tmpRoot(); + const src = path.join(projectRoot, 'src'); + fs.mkdirSync(src, { recursive: true }); + fs.writeFileSync( + path.join(src, 'session.ts'), + `export class Session {\n request(): void {}\n}\nexport function request(): void {}\n` + ); + + const CodeGraph = (await import('../src/index')).default; + const { ToolHandler } = await import('../src/mcp/tools'); + cg = CodeGraph.initSync(projectRoot, { + config: { include: ['src/**/*.ts'], exclude: [] }, + }); + await cg.indexAll(); + handler = new ToolHandler(cg); + findSymbol = (handler as any).findSymbol.bind(handler); + }); + + afterEach(() => { + handler?.closeAll(); + cg?.destroy(); + rmTree(projectRoot); + }); + + it('`Session.request` resolves to the method, not the bare function', () => { + const match = findSymbol(cg, 'Session.request'); + expect(match).not.toBeNull(); + expect(match!.node.kind).toBe('method'); + expect(match!.node.qualifiedName).toContain('Session::request'); + }); +}); diff --git a/src/db/queries.ts b/src/db/queries.ts index db7c6118d..ebba66e6a 100644 --- a/src/db/queries.ts +++ b/src/db/queries.ts @@ -696,8 +696,14 @@ export class QueryBuilder { const { kinds, languages, limit = 100, offset = 0 } = options; // Add prefix wildcard for better matching (e.g., "auth" matches "AuthService", "authenticate") - // Escape special FTS5 characters and add prefix wildcard + // Escape special FTS5 characters and add prefix wildcard. + // + // `::` is a qualifier separator in Rust/C++/Ruby, not a token char, + // so treat it as whitespace before the strip step. Otherwise queries + // like `stage_apply::run` collapse to `stage_applyrun` (the colons + // are stripped without splitting) and find nothing. See #173. const ftsQuery = query + .replace(/::/g, ' ') // Rust/C++/Ruby qualifier separator .replace(/['"*():^]/g, '') // Remove FTS5 special chars .split(/\s+/) .filter(term => term.length > 0) diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index e796cfc74..9e9ef9d33 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -16,6 +16,21 @@ import { WASM_FALLBACK_FIX_RECIPE } from '../db'; /** Maximum output length to prevent context bloat (characters) */ const MAX_OUTPUT_LENGTH = 15000; +/** + * Rust path roots that have no file-system equivalent — `crate` is the + * current crate, `super` is the parent module, `self` is the current + * module. Used by `matchesSymbol` to strip these before file-path + * matching so `crate::configurator::stage_apply::run` resolves the + * same as `configurator::stage_apply::run`. + */ +const RUST_PATH_PREFIXES = new Set(['crate', 'super', 'self']); + +/** Last `::` / `.` / `/`-separated segment of a qualified symbol. */ +function lastQualifierPart(symbol: string): string { + const parts = symbol.split(/::|[./]/).filter((p) => p.length > 0); + return parts[parts.length - 1] ?? symbol; +} + /** * Calculate the recommended number of codegraph_explore calls based on project size. * Larger codebases need more exploration calls to cover their surface area, @@ -1204,9 +1219,22 @@ export class ToolHandler { * Returns the best match and a note about alternatives if any. */ /** - * Check if a node matches a symbol query, supporting both simple names and - * qualified "Parent.child" notation (e.g., "Session.request" matches a method - * named "request" inside a class named "Session"). + * Check if a node matches a symbol query. + * + * Accepts simple names (`run`) and three flavors of qualifier: + * - dotted `Session.request` (TS/JS/Python) + * - colon-pair `stage_apply::run` (Rust, C++, Ruby) + * - slash `configurator/stage_apply` (path-ish) + * + * Multi-level qualifiers compose: `crate::configurator::stage_apply::run` + * works. Rust path prefixes (`crate`, `super`, `self`) are stripped so + * the canonical `crate::module::symbol` form resolves. + * + * Resolution order, last part must always equal `node.name`: + * 1. Suffix-match against `qualifiedName` (handles class-scoped methods + * where the extractor builds the qualified name from the AST stack) + * 2. File-path containment (handles file-derived modules in Rust/ + * Python — `stage_apply::run` matches a `run` in `stage_apply.rs`) */ private matchesSymbol(node: Node, symbol: string): boolean { // Simple name match @@ -1214,21 +1242,52 @@ export class ToolHandler { // File basename match (e.g., "product-card" matches "product-card.liquid") if (node.kind === 'file' && node.name.replace(/\.[^.]+$/, '') === symbol) return true; - // Qualified name match: "Parent.child" → look for "::Parent::child" in qualified_name - if (symbol.includes('.')) { - const parts = symbol.split('.'); - const qualifiedSuffix = parts.join('::'); - if (node.qualifiedName.includes(qualifiedSuffix)) return true; - } - - return false; + // Qualified-name lookups: split on any supported separator. `\w` keeps + // identifier chars (incl. `_`) intact; everything else is treated as + // a separator we tolerate. + if (!/[.\/]|::/.test(symbol)) return false; + const parts = symbol.split(/::|[./]/).filter((p) => p.length > 0); + if (parts.length < 2) return false; + + const lastPart = parts[parts.length - 1]!; + if (node.name !== lastPart) return false; + + // Stage 1: qualified-name suffix match. The extractor joins the + // semantic hierarchy with `::`, so `Session.request` and + // `Session::request` both become `Session::request` here. + const colonSuffix = parts.join('::'); + if (node.qualifiedName.includes(colonSuffix)) return true; + + // Stage 2: file-path containment. Rust modules and Python packages + // are not in `qualifiedName` — they're encoded in the file path. So + // `stage_apply::run` matches a `run` in any file whose path + // contains a `stage_apply` segment (with or without an extension). + // + // Filter out Rust path prefixes that have no file-system equivalent. + const containerHints = parts.slice(0, -1).filter((p) => !RUST_PATH_PREFIXES.has(p)); + if (containerHints.length === 0) return false; + + const segments = node.filePath.split('/').filter((s) => s.length > 0); + return containerHints.every((hint) => + segments.some((seg) => seg === hint || seg.replace(/\.[^.]+$/, '') === hint) + ); } private findSymbol(cg: CodeGraph, symbol: string): { node: Node; note: string } | null { - // Use higher limit for qualified lookups (e.g., "Session.request") since the - // target may rank lower in FTS when there are many partial matches - const limit = symbol.includes('.') ? 50 : 10; - const results = cg.searchNodes(symbol, { limit }); + // Use higher limit for qualified lookups (e.g., "Session.request", + // "stage_apply::run") since the target may rank lower in FTS when + // there are many partial matches across the qualifier parts. + const isQualified = /[.\/]|::/.test(symbol); + const limit = isQualified ? 50 : 10; + let results = cg.searchNodes(symbol, { limit }); + + // FTS strips colons as a special char, so `stage_apply::run` searches + // for the literal `stage_applyrun` and finds nothing. Re-search by + // the bare last part and let `matchesSymbol` filter by qualifier. + if (isQualified && results.length === 0) { + const tail = lastQualifierPart(symbol); + if (tail && tail !== symbol) results = cg.searchNodes(tail, { limit }); + } if (results.length === 0 || !results[0]) { return null; @@ -1250,7 +1309,11 @@ export class ToolHandler { return { node: picked, note }; } - // No exact match, use best fuzzy match + // No exact match. For qualified lookups, don't silently fall back + // to a fuzzy result — the user typed a specific qualifier, and + // resolving `stage_apply::nonexistent_fn` to the unrelated + // `stage_apply.rs` file would be actively misleading (#173). + if (isQualified) return null; return { node: results[0]!.node, note: '' }; } @@ -1259,7 +1322,15 @@ export class ToolHandler { * results across all matching symbols (e.g., multiple classes with an `execute` method). */ private findAllSymbols(cg: CodeGraph, symbol: string): { nodes: Node[]; note: string } { - const results = cg.searchNodes(symbol, { limit: 50 }); + let results = cg.searchNodes(symbol, { limit: 50 }); + + // Mirror the fallback in `findSymbol` for qualified queries — FTS + // strips colons, so a module-qualified lookup needs a second pass + // by the bare last part. + if (results.length === 0 && /[.\/]|::/.test(symbol)) { + const tail = lastQualifierPart(symbol); + if (tail && tail !== symbol) results = cg.searchNodes(tail, { limit: 50 }); + } if (results.length === 0) { return { nodes: [], note: '' }; From fb8fb0ea8bdbe0cb08276588facdec777ecc2e3b Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Tue, 19 May 2026 11:32:47 -0500 Subject: [PATCH 009/133] release: 0.7.10 (Windows mojibake fix, module-qualified symbol lookups, MCP handshake) --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 028c5dc8a..dfcebafad 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@colbymchenry/codegraph", - "version": "0.7.9", + "version": "0.7.10", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@colbymchenry/codegraph", - "version": "0.7.9", + "version": "0.7.10", "license": "MIT", "dependencies": { "@clack/prompts": "^1.3.0", diff --git a/package.json b/package.json index 3ea0b8cff..2731804b3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@colbymchenry/codegraph", - "version": "0.7.9", + "version": "0.7.10", "description": "Supercharge Claude Code with semantic code intelligence. 94% fewer tool calls • 77% faster exploration • 100% local.", "main": "dist/index.js", "types": "dist/index.d.ts", From 483ec9171c5600d44bd7f0f1e2ad977460903bb3 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Tue, 19 May 2026 11:42:22 -0500 Subject: [PATCH 010/133] chore(release): unwrap CHANGELOG paragraphs for GitHub Release notes (#180) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GitHub renders release-note Markdown with GFM hard breaks, so every `\n` becomes `
`. The CHANGELOG is hard-wrapped at ~75 chars for readable diffs, which renders as awkward visible line breaks on the release page (see https://github.com/colbymchenry/codegraph/releases/tag/v0.7.10). Add `scripts/extract-release-notes.mjs` to extract a version block and join indented continuation lines into a single line per bullet. Nested list items, headings, and link references are preserved. `scripts/release.sh` now uses this helper instead of the inline awk extractor — repo-level CHANGELOG.md viewing is unaffected because CommonMark there treats newlines as spaces. Also fix the 0.7.10 entry: "Two underlying fixes" -> "Three", "Rust file-/level" broken hyphen, and move the closes/credit line above the nested list so it doesn't strand as a top-level paragraph. Co-authored-by: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 15 ++-- scripts/extract-release-notes.mjs | 116 ++++++++++++++++++++++++++++++ scripts/release.sh | 12 ++-- 3 files changed, 128 insertions(+), 15 deletions(-) create mode 100755 scripts/extract-release-notes.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 30937cd6d..28f07d564 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,24 +42,23 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). …) accept `module::symbol` (Rust / C++ / Ruby), `Module.symbol` (TS / JS / Python), and `module/symbol` (path-style) — multi-level forms (`crate::configurator::stage_apply::run`) and Rust path - prefixes (`crate`, `super`, `self`) are handled. Two underlying - fixes: + prefixes (`crate`, `super`, `self`) are handled. Closes + [#173](https://github.com/colbymchenry/codegraph/issues/173). Thanks + to [@joselhurtado](https://github.com/joselhurtado) for the detailed + reproduction. Three underlying fixes: - The FTS5 query builder now treats `::` as a token separator instead of stripping it to nothing, so `stage_apply::run` no longer collapses to the unsearchable `stage_applyrun`. - `matchesSymbol` falls back to a file-path containment check when - `qualifiedName` doesn't carry the module hierarchy (Rust file- - level functions, Python free functions in a package): a `run` - in `src/configurator/stage_apply.rs` now matches + `qualifiedName` doesn't carry the module hierarchy (Rust + file-level functions, Python free functions in a package): a + `run` in `src/configurator/stage_apply.rs` now matches `stage_apply::run` because `stage_apply` appears as a path segment. - Qualified lookups that don't match the qualifier no longer fall through to fuzzy text matches — `stage_apply::nonexistent_fn` returns `null` instead of resolving to an unrelated `rollback` in the same file. - Closes [#173](https://github.com/colbymchenry/codegraph/issues/173). - Thanks to [@joselhurtado](https://github.com/joselhurtado) for the - detailed reproduction. [0.7.10]: https://github.com/colbymchenry/codegraph/releases/tag/v0.7.10 diff --git a/scripts/extract-release-notes.mjs b/scripts/extract-release-notes.mjs new file mode 100755 index 000000000..3bcf7f3f8 --- /dev/null +++ b/scripts/extract-release-notes.mjs @@ -0,0 +1,116 @@ +#!/usr/bin/env node +/** + * Extract a release-notes block from CHANGELOG.md for a given version, + * then unwrap hard-wrapped paragraphs. + * + * Why: GitHub renders release-note Markdown with GFM hard breaks, so + * every `\n` becomes `
`. The CHANGELOG is hard-wrapped at ~75 + * chars for readable diffs, which then renders as awkward visible + * line breaks on the release page. This script joins indented + * continuation lines into a single line per bullet so the GFM + * renderer produces clean paragraphs. + * + * Repo-level CHANGELOG.md viewing is unaffected (CommonMark treats + * newlines as spaces there). + * + * Usage: extract-release-notes.mjs + * e.g. extract-release-notes.mjs 0.7.10 + */ + +import { readFileSync } from 'fs'; + +const version = process.argv[2]; +if (!version) { + console.error('usage: extract-release-notes.mjs '); + process.exit(1); +} + +const escaped = version.replace(/\./g, '\\.'); +const headerRe = new RegExp(`^## \\[${escaped}\\]`); +const anyHeaderRe = /^## \[/; + +const lines = readFileSync('CHANGELOG.md', 'utf8').split('\n'); +const start = lines.findIndex((l) => headerRe.test(l)); +if (start === -1) { + console.error(`no '## [${version}]' entry found in CHANGELOG.md`); + process.exit(1); +} +const after = lines.findIndex((l, i) => i > start && anyHeaderRe.test(l)); +const block = lines.slice(start, after === -1 ? lines.length : after); + +// Find the indent of the most recent list item; a continuation line +// whose indent is GREATER than that belongs to that item, otherwise +// it might belong to an ancestor item further up the stack. +// +// Track a stack of `{ indent: number }` frames so we can attach a +// continuation to the right ancestor. This correctly handles the +// post-nested-list continuation pattern: +// +// - top-level +// - nested +// back to top-level <- 2-space indent, joins the top-level bullet +const out = []; +let buf = ''; // pending list-item text being built +let stack = []; // [{ indent: number }] open list items + +function flushBuf() { + if (buf !== '') { + out.push(buf); + buf = ''; + } +} + +function leadingSpaces(s) { + const m = s.match(/^(\s*)/); + return m ? m[1].length : 0; +} + +const listItemRe = /^(\s*)([-*+]|\d+\.)\s+/; + +for (const line of block) { + if (/^\s*$/.test(line)) { + flushBuf(); + out.push(''); + continue; + } + if (/^#/.test(line)) { + flushBuf(); + stack = []; + out.push(line); + continue; + } + const itemMatch = line.match(listItemRe); + if (itemMatch) { + flushBuf(); + const indent = itemMatch[1].length; + while (stack.length > 0 && stack[stack.length - 1].indent >= indent) { + stack.pop(); + } + stack.push({ indent }); + buf = line; + continue; + } + if (/^\s/.test(line)) { + // Continuation. Pop any list frames deeper than this indent — the + // continuation belongs to the nearest enclosing list item. + const indent = leadingSpaces(line); + while (stack.length > 1 && stack[stack.length - 1].indent >= indent) { + // Closes the deeper item — its buffered text is already in `buf` + // belonging to the most recent flush. We need to flush before + // re-buffering for the ancestor item. + flushBuf(); + stack.pop(); + } + const trimmed = line.replace(/^\s+/, ''); + buf = buf === '' ? trimmed : `${buf} ${trimmed}`; + continue; + } + // Top-level non-list, non-heading (e.g. `[0.7.10]: https://...`) + flushBuf(); + stack = []; + out.push(line); +} +flushBuf(); + +process.stdout.write(out.join('\n')); +if (!out[out.length - 1]?.endsWith('\n')) process.stdout.write('\n'); diff --git a/scripts/release.sh b/scripts/release.sh index da6bdae54..9edf8461f 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -30,13 +30,11 @@ if ! grep -q "^## \[${VERSION}\]" CHANGELOG.md; then exit 1 fi -NOTES=$(awk -v v="${VERSION}" ' - /^## \[/ { - if (p) exit - if ($0 ~ "^## \\[" v "\\]") p = 1 - } - p -' CHANGELOG.md) +# Extract notes with paragraph unwrapping — GitHub Releases render with +# GFM hard-breaks, so the CHANGELOG's hard-wrapped lines would show as +# visible `
` breaks otherwise. The helper joins continuation lines +# into a single line per bullet. +NOTES=$(node scripts/extract-release-notes.mjs "${VERSION}") if [ -z "${NOTES}" ]; then echo "error: failed to extract changelog notes for ${VERSION}" >&2 From 4bb95639cafac2aef755776e48b89b1e19aba3a3 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Tue, 19 May 2026 12:05:59 -0500 Subject: [PATCH 011/133] chore(release): refine release-notes extractor (#181) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes prompted by retroactively unwrapping the 0.7.6 / 0.7.7 / 0.7.9 release notes: - Add `--stdin` mode so the extractor can clean up an existing release body (via `gh release view ... --json body --jq '.body'`) without needing a matching CHANGELOG.md entry. The 0.7.9 release didn't have one — its body had been hand-rolled from the 0.7.8 entry on publish. - Stop treating `+` as a bullet marker. CommonMark allows it, but our CHANGELOG uses literal `+` inline (`MCP config + instructions`) and the script was misreading those as nested bullets. Keep `-`, `*`, and `N.` only. - Preserve fenced code blocks verbatim. The 0.7.6 entry has a triple- backtick ```bash block; the previous pass was joining its lines into one, producing unreadable code. Co-authored-by: Claude Opus 4.7 (1M context) --- scripts/extract-release-notes.mjs | 82 ++++++++++++++++++------------- 1 file changed, 48 insertions(+), 34 deletions(-) diff --git a/scripts/extract-release-notes.mjs b/scripts/extract-release-notes.mjs index 3bcf7f3f8..b909bcd28 100755 --- a/scripts/extract-release-notes.mjs +++ b/scripts/extract-release-notes.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node /** - * Extract a release-notes block from CHANGELOG.md for a given version, - * then unwrap hard-wrapped paragraphs. + * Extract a release-notes block from CHANGELOG.md for a given version + * (or unwrap text supplied on stdin), then join hard-wrapped paragraphs. * * Why: GitHub renders release-note Markdown with GFM hard breaks, so * every `\n` becomes `
`. The CHANGELOG is hard-wrapped at ~75 @@ -13,45 +13,47 @@ * Repo-level CHANGELOG.md viewing is unaffected (CommonMark treats * newlines as spaces there). * - * Usage: extract-release-notes.mjs - * e.g. extract-release-notes.mjs 0.7.10 + * Usage: + * extract-release-notes.mjs # read CHANGELOG.md + * extract-release-notes.mjs --stdin # read from stdin (any text) */ import { readFileSync } from 'fs'; -const version = process.argv[2]; -if (!version) { - console.error('usage: extract-release-notes.mjs '); +const arg = process.argv[2]; +if (!arg) { + console.error('usage: extract-release-notes.mjs | --stdin'); process.exit(1); } -const escaped = version.replace(/\./g, '\\.'); -const headerRe = new RegExp(`^## \\[${escaped}\\]`); -const anyHeaderRe = /^## \[/; - -const lines = readFileSync('CHANGELOG.md', 'utf8').split('\n'); -const start = lines.findIndex((l) => headerRe.test(l)); -if (start === -1) { - console.error(`no '## [${version}]' entry found in CHANGELOG.md`); - process.exit(1); +let block; +if (arg === '--stdin') { + block = readFileSync(0, 'utf8').replace(/\r\n?/g, '\n').split('\n'); +} else { + const version = arg; + const escaped = version.replace(/\./g, '\\.'); + const headerRe = new RegExp(`^## \\[${escaped}\\]`); + const anyHeaderRe = /^## \[/; + const lines = readFileSync('CHANGELOG.md', 'utf8').split('\n'); + const start = lines.findIndex((l) => headerRe.test(l)); + if (start === -1) { + console.error(`no '## [${version}]' entry found in CHANGELOG.md`); + process.exit(1); + } + const after = lines.findIndex((l, i) => i > start && anyHeaderRe.test(l)); + block = lines.slice(start, after === -1 ? lines.length : after); } -const after = lines.findIndex((l, i) => i > start && anyHeaderRe.test(l)); -const block = lines.slice(start, after === -1 ? lines.length : after); -// Find the indent of the most recent list item; a continuation line -// whose indent is GREATER than that belongs to that item, otherwise -// it might belong to an ancestor item further up the stack. -// -// Track a stack of `{ indent: number }` frames so we can attach a -// continuation to the right ancestor. This correctly handles the -// post-nested-list continuation pattern: +// Track a stack of `{ indent: number }` frames so a continuation line +// can attach to the right ancestor. Handles the post-nested-list +// continuation pattern: // // - top-level // - nested // back to top-level <- 2-space indent, joins the top-level bullet const out = []; -let buf = ''; // pending list-item text being built -let stack = []; // [{ indent: number }] open list items +let buf = ''; +let stack = []; function flushBuf() { if (buf !== '') { @@ -65,9 +67,27 @@ function leadingSpaces(s) { return m ? m[1].length : 0; } -const listItemRe = /^(\s*)([-*+]|\d+\.)\s+/; +// Bullets: `-`, `*`, `digit.` only. `+` is intentionally excluded — the +// CHANGELOG uses literal `+` inline (`config + instructions`) and we +// don't want to misread those as nested bullets. +const listItemRe = /^(\s*)([-*]|\d+\.)\s+/; +const fenceRe = /^\s*```/; + +let inFence = false; for (const line of block) { + // Fenced code blocks: pass through verbatim, no joining. + if (fenceRe.test(line)) { + flushBuf(); + stack = []; + out.push(line); + inFence = !inFence; + continue; + } + if (inFence) { + out.push(line); + continue; + } if (/^\s*$/.test(line)) { flushBuf(); out.push(''); @@ -91,13 +111,8 @@ for (const line of block) { continue; } if (/^\s/.test(line)) { - // Continuation. Pop any list frames deeper than this indent — the - // continuation belongs to the nearest enclosing list item. const indent = leadingSpaces(line); while (stack.length > 1 && stack[stack.length - 1].indent >= indent) { - // Closes the deeper item — its buffered text is already in `buf` - // belonging to the most recent flush. We need to flush before - // re-buffering for the ancestor item. flushBuf(); stack.pop(); } @@ -105,7 +120,6 @@ for (const line of block) { buf = buf === '' ? trimmed : `${buf} ${trimmed}`; continue; } - // Top-level non-list, non-heading (e.g. `[0.7.10]: https://...`) flushBuf(); stack = []; out.push(line); From 93e53e7c69b427386e8bdb3f099d442739d7049c Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Tue, 19 May 2026 16:23:09 -0500 Subject: [PATCH 012/133] feat(mcp): size-adaptive output budget for codegraph_explore (#185) (#187) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Output is now scaled to indexed file count. Small projects (<500 files) cap at ~18KB and skip the "Additional relevant files" / completeness / explore-budget reminders that earn their keep on larger codebases; medium (<5,000) caps at ~28KB; large (<15,000) keeps the historical ~35KB; very large goes up to ~38KB. A per-file char cap also prevents a single file with many adjacent symbols from collapsing into one whole-file dump (the pathological Alamofire `Session.swift` case reported in #185), and a per-file symbol- list cap stops the `#### path — sym(kind), ...` header from leaking multi-KB lists when many adjacent symbols cluster together. Measured against the README's benchmark repos: Alamofire (~100 files) ~62% smaller per call, Excalidraw (~600 files) ~35%, VS Code (~10k files) ~14%. Agent-trust floor preserved — Relationships, scored cluster selection, and structured-source output are all retained. Co-authored-by: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 22 ++ __tests__/explore-output-budget.test.ts | 191 +++++++++++++ src/mcp/tools.ts | 348 +++++++++++++++++++----- 3 files changed, 497 insertions(+), 64 deletions(-) create mode 100644 __tests__/explore-output-budget.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 28f07d564..828421d55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,28 @@ a [GitHub Release](https://github.com/colbymchenry/codegraph/releases) tagged This project follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Changed +- **MCP / explore**: `codegraph_explore` output is now adaptive to project + size. The tool used to apply a fixed 35KB cap regardless of how large the + codebase was, which on small projects (~100 files) produced bigger + responses than the agent's native grep+Read flow would have — exactly the + scenario reported in + [#185](https://github.com/colbymchenry/codegraph/issues/185). The budget + now scales with indexed file count: small projects (<500 files) cap at + ~18KB and skip the "Additional relevant files" / completeness / explore- + budget reminders that earn their keep on bigger codebases; medium + (<5,000) caps at ~28KB; large (<15,000) keeps the historical ~35KB; very + large goes up to ~38KB. A new per-file char cap also prevents a single + file with many adjacent symbols from collapsing into one whole-file dump + (the Alamofire `Session.swift` case from #185). Measured against the + same repos used in the README benchmark: Alamofire ~62% smaller per call, + Excalidraw ~35%, VS Code ~14%. Agent-trust floor still holds — the + Relationships section, scored cluster selection, and structured-source + output are all retained. Thanks to + [@essopsp](https://github.com/essopsp) for the repro. + ## [0.7.10] - 2026-05-19 ### Fixed diff --git a/__tests__/explore-output-budget.test.ts b/__tests__/explore-output-budget.test.ts new file mode 100644 index 000000000..36717f825 --- /dev/null +++ b/__tests__/explore-output-budget.test.ts @@ -0,0 +1,191 @@ +/** + * Adaptive output budget for codegraph_explore (#185). + * + * The explore tool used to apply a fixed 35KB output cap regardless of + * project size, which on small codebases was a net loss vs. native + * grep+Read. These tests pin the per-tier budget shape so future tuning + * doesn't silently drift the small-project case back into bloat. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { getExploreOutputBudget, getExploreBudget, ToolHandler } from '../src/mcp/tools'; +import CodeGraph from '../src/index'; + +describe('getExploreOutputBudget', () => { + it('returns a strictly smaller total cap for small projects than for huge ones', () => { + const small = getExploreOutputBudget(100); + const huge = getExploreOutputBudget(30000); + expect(small.maxOutputChars).toBeLessThan(huge.maxOutputChars); + expect(small.defaultMaxFiles).toBeLessThan(huge.defaultMaxFiles); + expect(small.maxCharsPerFile).toBeLessThan(huge.maxCharsPerFile); + }); + + it('caps total output well under 8000 tokens (~32k chars) on small projects', () => { + const small = getExploreOutputBudget(100); + expect(small.maxOutputChars).toBeLessThanOrEqual(20000); + }); + + it('keeps the historical 35k+ ceiling for medium-large projects so existing benchmarks do not regress', () => { + const large = getExploreOutputBudget(10000); + expect(large.maxOutputChars).toBeGreaterThanOrEqual(35000); + }); + + it('uses tier breakpoints matching getExploreBudget so call-count and output-budget agree on a project', () => { + // Anything in the same tier should pick the same total-output cap. + const tier1a = getExploreOutputBudget(50); + const tier1b = getExploreOutputBudget(499); + expect(tier1a.maxOutputChars).toBe(tier1b.maxOutputChars); + expect(getExploreBudget(50)).toBe(getExploreBudget(499)); + + const tier2a = getExploreOutputBudget(500); + const tier2b = getExploreOutputBudget(4999); + expect(tier2a.maxOutputChars).toBe(tier2b.maxOutputChars); + expect(getExploreBudget(500)).toBe(getExploreBudget(4999)); + + const tier3a = getExploreOutputBudget(5000); + const tier3b = getExploreOutputBudget(14999); + expect(tier3a.maxOutputChars).toBe(tier3b.maxOutputChars); + + // And crossing a breakpoint changes the cap. + expect(tier1a.maxOutputChars).not.toBe(tier2a.maxOutputChars); + expect(tier2a.maxOutputChars).not.toBe(tier3a.maxOutputChars); + }); + + it('gates off "Additional relevant files", completeness signal, and budget note on small projects', () => { + const small = getExploreOutputBudget(100); + expect(small.includeAdditionalFiles).toBe(false); + expect(small.includeCompletenessSignal).toBe(false); + expect(small.includeBudgetNote).toBe(false); + }); + + it('keeps all meta-text on for projects that earn the breadth signal (>=500 files)', () => { + const medium = getExploreOutputBudget(1000); + expect(medium.includeAdditionalFiles).toBe(true); + expect(medium.includeCompletenessSignal).toBe(true); + expect(medium.includeBudgetNote).toBe(true); + }); + + it('keeps the Relationships section on for every tier — it is the cheapest structural signal', () => { + expect(getExploreOutputBudget(50).includeRelationships).toBe(true); + expect(getExploreOutputBudget(1000).includeRelationships).toBe(true); + expect(getExploreOutputBudget(10000).includeRelationships).toBe(true); + expect(getExploreOutputBudget(30000).includeRelationships).toBe(true); + }); + + it('caps the per-file header symbol list more tightly on small projects', () => { + // Without this cap, a file like Alamofire's Session.swift produced + // a 3.4KB symbol list in the `#### path — sym, sym, ...` header, + // dwarfing the per-file body cap. + const small = getExploreOutputBudget(100); + const huge = getExploreOutputBudget(30000); + expect(small.maxSymbolsInFileHeader).toBeLessThan(huge.maxSymbolsInFileHeader); + expect(small.maxSymbolsInFileHeader).toBeGreaterThan(0); + }); + + it('uses a tighter clustering gap threshold on small projects to break runaway single clusters', () => { + const small = getExploreOutputBudget(100); + const huge = getExploreOutputBudget(30000); + expect(small.gapThreshold).toBeLessThanOrEqual(huge.gapThreshold); + }); + + it('handles the boundary file counts exactly (off-by-one regression guard)', () => { + // 499 -> small tier, 500 -> medium tier + expect(getExploreOutputBudget(499).maxOutputChars).toBe(getExploreOutputBudget(100).maxOutputChars); + expect(getExploreOutputBudget(500).maxOutputChars).toBe(getExploreOutputBudget(1000).maxOutputChars); + // 4999 -> medium, 5000 -> large + expect(getExploreOutputBudget(4999).maxOutputChars).toBe(getExploreOutputBudget(1000).maxOutputChars); + expect(getExploreOutputBudget(5000).maxOutputChars).toBe(getExploreOutputBudget(10000).maxOutputChars); + // 14999 -> large, 15000 -> xlarge + expect(getExploreOutputBudget(14999).maxOutputChars).toBe(getExploreOutputBudget(10000).maxOutputChars); + expect(getExploreOutputBudget(15000).maxOutputChars).toBe(getExploreOutputBudget(30000).maxOutputChars); + }); +}); + +/** + * End-to-end check that the budget is actually applied by handleExplore. + * + * Builds a tiny synthetic project (<500 files, so the small tier), indexes + * it, and confirms the output: + * - stays under the small-tier maxOutputChars cap + * - omits the meta-text the small tier gates off (completeness signal, + * budget note, "Additional relevant files") + * + * Regression guard for #185 — protects against future edits to handleExplore + * silently re-introducing the fixed 35KB cap on small projects. + */ +describe('codegraph_explore output respects the adaptive budget', () => { + let testDir: string; + let cg: CodeGraph; + let handler: ToolHandler; + + beforeAll(async () => { + testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-explore-budget-')); + const srcDir = path.join(testDir, 'src'); + fs.mkdirSync(srcDir); + + // A handful of files with one fat target file. The fat file mimics the + // Alamofire Session.swift case: many methods stacked on top of each other, + // which collapsed into one giant cluster pre-#185. + const fatLines: string[] = ['export class Session {']; + for (let i = 0; i < 30; i++) { + fatLines.push(` method${i}(arg: string): string {`); + fatLines.push(` return this.helper${i}(arg) + "${i}";`); + fatLines.push(` }`); + fatLines.push(` private helper${i}(arg: string): string {`); + fatLines.push(` return arg.repeat(${i + 1});`); + fatLines.push(` }`); + } + fatLines.push('}'); + fs.writeFileSync(path.join(srcDir, 'session.ts'), fatLines.join('\n')); + + // A few small supporting files so the project has >1 indexed file. + for (let i = 0; i < 5; i++) { + fs.writeFileSync( + path.join(srcDir, `support${i}.ts`), + `import { Session } from './session';\nexport function callSession${i}(s: Session) { return s.method${i}('hi'); }\n` + ); + } + + cg = CodeGraph.initSync(testDir, { + config: { include: ['**/*.ts'], exclude: [] }, + }); + await cg.indexAll(); + handler = new ToolHandler(cg); + }); + + afterAll(() => { + if (cg) cg.destroy(); + if (testDir && fs.existsSync(testDir)) { + fs.rmSync(testDir, { recursive: true, force: true }); + } + }); + + it('keeps total output under the small-project cap', async () => { + const result = await handler.execute('codegraph_explore', { query: 'Session method helper' }); + const text = result.content?.[0]?.text ?? ''; + const smallBudget = getExploreOutputBudget(100); + // Allow a small overshoot for the trailing markers — the cap is enforced + // per-file rather than as an absolute output ceiling. + expect(text.length).toBeLessThan(smallBudget.maxOutputChars + 500); + }); + + it('omits the meta-text gated off for small projects', async () => { + const result = await handler.execute('codegraph_explore', { query: 'Session method helper' }); + const text = result.content?.[0]?.text ?? ''; + expect(text).not.toContain('### Additional relevant files'); + expect(text).not.toContain('Complete source code is included above'); + expect(text).not.toContain('Explore budget:'); + }); + + it('still includes the Relationships section — it is the cheapest structural signal', async () => { + const result = await handler.execute('codegraph_explore', { query: 'Session method helper' }); + const text = result.content?.[0]?.text ?? ''; + // Either there are relationships, or no edges were significant — both are fine. + // We just want to confirm we did not accidentally gate it off. + const hasRelationships = text.includes('### Relationships'); + const sourceFollowsHeader = text.indexOf('### Source Code') > 0; + expect(hasRelationships || sourceFollowsHeader).toBe(true); + }); +}); diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index 9e9ef9d33..217679069 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -44,6 +44,104 @@ export function getExploreBudget(fileCount: number): number { return 5; } +/** + * Adaptive output budget for `codegraph_explore`, scaled to project size. + * + * Smaller codebases get a tighter total cap, fewer default files, smaller + * per-file cap, and tighter clustering — so a focused query on a 100-file + * project doesn't dump a whole file's worth of source into the agent's + * context. Larger codebases keep the generous defaults because the + * agent's native discovery cost (grep + find + many Reads) genuinely + * dwarfs a fat explore call at that scale. + * + * Meta-text (relationships map, "additional relevant files" list, + * completeness signal, budget note) is gated off for tiny projects + * where one rich call is the whole story and the extra prose is just + * overhead. + * + * Tier breakpoints mirror `getExploreBudget` so a project sits in the + * same tier across both knobs. + */ +export interface ExploreOutputBudget { + /** Hard cap on total output characters. */ + maxOutputChars: number; + /** Default `maxFiles` when the caller didn't specify one. */ + defaultMaxFiles: number; + /** Cap on contiguous source returned per file (across all its clusters). */ + maxCharsPerFile: number; + /** Cluster gap threshold in lines — tighter clustering on small projects. */ + gapThreshold: number; + /** Max symbols listed in the per-file header (`#### path — sym(kind), ...`). */ + maxSymbolsInFileHeader: number; + /** Max edges shown per relationship kind in the Relationships section. */ + maxEdgesPerRelationshipKind: number; + /** Include the "Relationships" section. */ + includeRelationships: boolean; + /** Include the "Additional relevant files (not shown)" trailing list. */ + includeAdditionalFiles: boolean; + /** Include the "Complete source code is included above…" reminder. */ + includeCompletenessSignal: boolean; + /** Include the explore-budget reminder at the end. */ + includeBudgetNote: boolean; +} + +export function getExploreOutputBudget(fileCount: number): ExploreOutputBudget { + if (fileCount < 500) { + return { + maxOutputChars: 18000, + defaultMaxFiles: 5, + maxCharsPerFile: 3800, + gapThreshold: 8, + maxSymbolsInFileHeader: 6, + maxEdgesPerRelationshipKind: 6, + includeRelationships: true, + includeAdditionalFiles: false, + includeCompletenessSignal: false, + includeBudgetNote: false, + }; + } + if (fileCount < 5000) { + return { + maxOutputChars: 28000, + defaultMaxFiles: 9, + maxCharsPerFile: 5000, + gapThreshold: 12, + maxSymbolsInFileHeader: 10, + maxEdgesPerRelationshipKind: 10, + includeRelationships: true, + includeAdditionalFiles: true, + includeCompletenessSignal: true, + includeBudgetNote: true, + }; + } + if (fileCount < 15000) { + return { + maxOutputChars: 35000, + defaultMaxFiles: 12, + maxCharsPerFile: 7000, + gapThreshold: 15, + maxSymbolsInFileHeader: 15, + maxEdgesPerRelationshipKind: 15, + includeRelationships: true, + includeAdditionalFiles: true, + includeCompletenessSignal: true, + includeBudgetNote: true, + }; + } + return { + maxOutputChars: 38000, + defaultMaxFiles: 14, + maxCharsPerFile: 7000, + gapThreshold: 15, + maxSymbolsInFileHeader: 15, + maxEdgesPerRelationshipKind: 15, + includeRelationships: true, + includeAdditionalFiles: true, + includeCompletenessSignal: true, + includeBudgetNote: true, + }; +} + /** * Mark a Claude session as having consulted MCP tools. * This enables Grep/Glob/Bash commands that would otherwise be blocked. @@ -656,24 +754,35 @@ export class ToolHandler { return this.textResult(this.truncateOutput(formatted)); } - /** Maximum output for explore tool — sized to stay under MCP client token limits (~10k tokens) */ - private static readonly EXPLORE_MAX_OUTPUT = 35000; - /** * Handle codegraph_explore — deep exploration in a single call * * Strategy: find relevant symbols via graph traversal, group by file, * then read contiguous file sections covering all symbols per file. * This replaces multiple codegraph_node + Read calls. + * + * Output size is adaptive to project file count via + * `getExploreOutputBudget` — see #185 for why a fixed 35k cap was a + * tax on small projects while earning its keep on large ones. */ private async handleExplore(args: Record): Promise { const query = this.validateString(args.query, 'query'); if (typeof query !== 'string') return query; const cg = this.getCodeGraph(args.projectPath as string | undefined); - const maxFiles = clamp((args.maxFiles as number) || 12, 1, 20); const projectRoot = cg.getProjectRoot(); + // Resolve adaptive output budget from project size. Falls back to the + // largest-tier defaults if stats aren't available, which preserves + // pre-#185 behavior for callers that hit the rare stats failure. + let budget: ExploreOutputBudget; + try { + budget = getExploreOutputBudget(cg.getStats().fileCount); + } catch { + budget = getExploreOutputBudget(Infinity); + } + const maxFiles = clamp((args.maxFiles as number) || budget.defaultMaxFiles, 1, 20); + // Step 1: Find relevant context with generous parameters. // Use a large maxNodes budget — explore has its own 35k char output limit // that prevents context bloat, so more nodes just means better coverage @@ -765,7 +874,7 @@ export class ToolHandler { e.kind !== 'contains' // skip contains — it's implied by file grouping ); - if (significantEdges.length > 0) { + if (budget.includeRelationships && significantEdges.length > 0) { lines.push('### Relationships'); lines.push(''); @@ -782,14 +891,14 @@ export class ToolHandler { } for (const [kind, edges] of byKind) { - // Show up to 15 relationships per kind - const shown = edges.slice(0, 15); + const cap = budget.maxEdgesPerRelationshipKind; + const shown = edges.slice(0, cap); lines.push(`**${kind}:**`); for (const e of shown) { lines.push(`- ${e.source} → ${e.target}`); } - if (edges.length > 15) { - lines.push(`- ... and ${edges.length - 15} more`); + if (edges.length > cap) { + lines.push(`- ... and ${edges.length - cap} more`); } lines.push(''); } @@ -801,10 +910,11 @@ export class ToolHandler { let totalChars = lines.join('\n').length; let filesIncluded = 0; + let anyFileTrimmed = false; for (const [filePath, group] of sortedFiles) { if (filesIncluded >= maxFiles) break; - if (totalChars > ToolHandler.EXPLORE_MAX_OUTPUT * 0.9) break; + if (totalChars > budget.maxOutputChars * 0.9) break; const absPath = validatePathWithinRoot(projectRoot, filePath); if (!absPath || !existsSync(absPath)) continue; @@ -820,14 +930,26 @@ export class ToolHandler { const lang = group.nodes[0]?.language || ''; // Cluster nearby symbols to avoid reading huge gaps between distant symbols. - // Sort by start line, then merge overlapping/adjacent ranges (within 15 lines). - // Include both node ranges AND edge source locations so template sections - // with component usages/calls are covered (not just script block symbols). - const ranges: Array<{ start: number; end: number; name: string; kind: string }> = group.nodes + // Sort by start line, then merge overlapping/adjacent ranges (within the + // adaptive gap threshold). Include both node ranges AND edge source + // locations so template sections with component usages/calls are + // covered (not just script block symbols). + // + // Each range carries an `importance` score so we can rank clusters + // when the per-file budget forces us to drop some: entry-point nodes + // are worth 10, directly-connected nodes 3, peripheral nodes 1, and + // bare edge-source lines 2 (less than a connected node but more than + // a peripheral one — they hint at a reference but aren't a definition). + const ranges: Array<{ start: number; end: number; name: string; kind: string; importance: number }> = group.nodes .filter(n => n.startLine > 0 && n.endLine > 0) // Skip file/component nodes that span the entire file — they'd create one giant cluster .filter(n => !(n.kind === 'component' && n.startLine === 1 && n.endLine >= fileLines.length - 1)) - .map(n => ({ start: n.startLine, end: n.endLine, name: n.name, kind: n.kind })); + .map(n => { + let importance = 1; + if (entryNodeIds.has(n.id)) importance = 10; + else if (connectedToEntry.has(n.id)) importance = 3; + return { start: n.startLine, end: n.endLine, name: n.name, kind: n.kind, importance }; + }); // Add edge source locations in this file — captures template references // (component usages, event handlers) that aren't nodes themselves. @@ -844,7 +966,7 @@ export class ToolHandler { // Look up target name from subgraph first, fall back to edge kind const targetNode = subgraph.nodes.get(edge.target); const targetName = targetNode?.name ?? edge.kind; - ranges.push({ start: edge.line, end: edge.line, name: targetName, kind: edge.kind }); + ranges.push({ start: edge.line, end: edge.line, name: targetName, kind: edge.kind, importance: 2 }); } } @@ -852,46 +974,129 @@ export class ToolHandler { if (ranges.length === 0) continue; - const GAP_THRESHOLD = 15; // merge sections within 15 lines of each other - const clusters: Array<{ start: number; end: number; symbols: string[] }> = []; - let current = { start: ranges[0]!.start, end: ranges[0]!.end, symbols: [`${ranges[0]!.name}(${ranges[0]!.kind})`] }; + const gapThreshold = budget.gapThreshold; + const clusters: Array<{ start: number; end: number; symbols: string[]; score: number }> = []; + let current = { + start: ranges[0]!.start, + end: ranges[0]!.end, + symbols: [`${ranges[0]!.name}(${ranges[0]!.kind})`], + score: ranges[0]!.importance, + }; for (let i = 1; i < ranges.length; i++) { const r = ranges[i]!; - if (r.start <= current.end + GAP_THRESHOLD) { + if (r.start <= current.end + gapThreshold) { current.end = Math.max(current.end, r.end); current.symbols.push(`${r.name}(${r.kind})`); + current.score += r.importance; } else { clusters.push(current); - current = { start: r.start, end: r.end, symbols: [`${r.name}(${r.kind})`] }; + current = { + start: r.start, + end: r.end, + symbols: [`${r.name}(${r.kind})`], + score: r.importance, + }; } } clusters.push(current); - // Build file section output from clusters + // Build file section output from clusters, capped by per-file budget. + // The pathological case (#185): a file like Session.swift where every + // method is adjacent collapses into one cluster spanning the whole + // file, and dumping that into the agent's context is most of the + // token cost on small projects. We pick clusters in score order + // (importance per line, so we don't prefer one giant low-density + // cluster over several focused ones) until the per-file char cap is + // hit. Truly enormous single clusters get tail-trimmed with a marker. const contextPadding = 3; + const buildSection = (c: { start: number; end: number }): string => { + const startIdx = Math.max(0, c.start - 1 - contextPadding); + const endIdx = Math.min(fileLines.length, c.end + contextPadding); + return fileLines.slice(startIdx, endIdx).join('\n'); + }; + const GAP_MARKER = '\n\n// ... (gap) ...\n\n'; + + // Score clusters by score-per-line (density) so a 30-line cluster + // with two entry symbols outranks a 400-line cluster with two + // peripheral symbols. Stable tiebreak by score, then by smaller + // span (cheaper to include). + const rankedClusters = clusters + .map((c, i) => ({ idx: i, span: c.end - c.start + 1, c })) + .sort((a, b) => { + const densityA = a.c.score / a.span; + const densityB = b.c.score / b.span; + if (densityB !== densityA) return densityB - densityA; + if (b.c.score !== a.c.score) return b.c.score - a.c.score; + return a.span - b.span; + }); + + const chosenIndices = new Set(); + let projectedChars = 0; + for (const rc of rankedClusters) { + const sectionLen = buildSection(rc.c).length + (chosenIndices.size > 0 ? GAP_MARKER.length : 0); + // Always take the top-ranked cluster, even if oversize, so we don't + // return an empty file section (agent would then re-Read the file, + // negating the savings). + if (chosenIndices.size === 0) { + chosenIndices.add(rc.idx); + projectedChars += sectionLen; + continue; + } + if (projectedChars + sectionLen > budget.maxCharsPerFile) continue; + chosenIndices.add(rc.idx); + projectedChars += sectionLen; + } + + // Emit chosen clusters in source order so the file reads top-to-bottom. let fileSection = ''; const allSymbols: string[] = []; - - for (const cluster of clusters) { - const startIdx = Math.max(0, cluster.start - 1 - contextPadding); - const endIdx = Math.min(fileLines.length, cluster.end + contextPadding); - const section = fileLines.slice(startIdx, endIdx).join('\n'); - - if (fileSection.length > 0) { - fileSection += '\n\n// ... (gap) ...\n\n'; - } + let fileTrimmed = false; + for (let i = 0; i < clusters.length; i++) { + if (!chosenIndices.has(i)) continue; + const cluster = clusters[i]!; + const section = buildSection(cluster); + if (fileSection.length > 0) fileSection += GAP_MARKER; fileSection += section; allSymbols.push(...cluster.symbols); } - // Skip if this section would blow the output limit - if (totalChars + fileSection.length + 200 > ToolHandler.EXPLORE_MAX_OUTPUT) { - const budget = ToolHandler.EXPLORE_MAX_OUTPUT - totalChars - 200; - if (budget < 500) break; - const trimmed = fileSection.slice(0, budget) + '\n// ... trimmed ...'; + // If a single chosen cluster is still oversize (long monolithic + // function), tail-trim it. Better one trimmed view than nothing. + if (fileSection.length > budget.maxCharsPerFile) { + fileSection = fileSection.slice(0, budget.maxCharsPerFile) + '\n// ... trimmed ...'; + fileTrimmed = true; + } + if (chosenIndices.size < clusters.length || fileTrimmed) { + anyFileTrimmed = true; + } - lines.push(`#### ${filePath} — ${allSymbols.join(', ')}`); + // Dedupe + cap the symbols list shown in the per-file header. Some + // files (Session.swift in Alamofire) produced 3.4KB symbol lists + // from cluster scoring + edge-source lines, dwarfing the per-file + // body cap. Show top names by frequency, with a "+N more" tail. + const symbolCounts = new Map(); + for (const s of allSymbols) { + symbolCounts.set(s, (symbolCounts.get(s) ?? 0) + 1); + } + const sortedSymbols = [...symbolCounts.entries()] + .sort((a, b) => b[1] - a[1]) + .map(([name]) => name); + const headerCap = budget.maxSymbolsInFileHeader; + const headerSymbols = sortedSymbols.slice(0, headerCap); + const omittedCount = sortedSymbols.length - headerSymbols.length; + const headerSuffix = omittedCount > 0 + ? `${headerSymbols.join(', ')}, +${omittedCount} more` + : headerSymbols.join(', '); + const fileHeader = `#### ${filePath} — ${headerSuffix}`; + + // Respect the total output cap on a file-by-file basis. + if (totalChars + fileSection.length + 200 > budget.maxOutputChars) { + const remaining = budget.maxOutputChars - totalChars - 200; + if (remaining < 500) break; + const trimmed = fileSection.slice(0, remaining) + '\n// ... trimmed ...'; + + lines.push(fileHeader); lines.push(''); lines.push('```' + lang); lines.push(trimmed); @@ -899,10 +1104,11 @@ export class ToolHandler { lines.push(''); totalChars += trimmed.length + 200; filesIncluded++; + anyFileTrimmed = true; break; } - lines.push(`#### ${filePath} — ${allSymbols.join(', ')}`); + lines.push(fileHeader); lines.push(''); lines.push('```' + lang); lines.push(fileSection); @@ -913,37 +1119,51 @@ export class ToolHandler { filesIncluded++; } - // Add remaining files as references (from both relevant and peripheral files) - const remainingRelevant = sortedFiles.slice(filesIncluded); - const peripheralFiles = [...fileGroups.entries()] - .filter(([, group]) => group.score < 3) - .sort((a, b) => b[1].score - a[1].score); - const remainingFiles = [...remainingRelevant, ...peripheralFiles]; - if (remainingFiles.length > 0) { - lines.push('### Additional relevant files (not shown)'); - lines.push(''); - for (const [filePath, group] of remainingFiles.slice(0, 10)) { - const symbols = group.nodes.map(n => `${n.name}:${n.startLine}`).join(', '); - lines.push(`- ${filePath}: ${symbols}`); - } - if (remainingFiles.length > 10) { - lines.push(`- ... and ${remainingFiles.length - 10} more files`); + // Add remaining files as references (from both relevant and peripheral files). + // Small projects (per budget) skip this — the relevant story already fits + // in the source section, and a trailing pointer list is pure overhead. + if (budget.includeAdditionalFiles) { + const remainingRelevant = sortedFiles.slice(filesIncluded); + const peripheralFiles = [...fileGroups.entries()] + .filter(([, group]) => group.score < 3) + .sort((a, b) => b[1].score - a[1].score); + const remainingFiles = [...remainingRelevant, ...peripheralFiles]; + if (remainingFiles.length > 0) { + lines.push('### Additional relevant files (not shown)'); + lines.push(''); + for (const [filePath, group] of remainingFiles.slice(0, 10)) { + const symbols = group.nodes.map(n => `${n.name}:${n.startLine}`).join(', '); + lines.push(`- ${filePath}: ${symbols}`); + } + if (remainingFiles.length > 10) { + lines.push(`- ... and ${remainingFiles.length - 10} more files`); + } } } - // Add completeness signal so agents know they don't need to re-read these files - lines.push(''); - lines.push('---'); - lines.push(`> **Complete source code is included above for ${filesIncluded} files.** You do NOT need to re-read these files — the relevant sections are already shown in full. Only use Read/Grep for files listed under "Additional relevant files" if you need more detail.`); + // Add completeness signal so agents know they don't need to re-read these files. + // On small projects the budget gates this off — but if we actually had to + // trim or drop clusters, surface a brief note so the agent knows it can + // still Read for more detail. + if (budget.includeCompletenessSignal) { + lines.push(''); + lines.push('---'); + lines.push(`> **Complete source code is included above for ${filesIncluded} files.** You do NOT need to re-read these files — the relevant sections are already shown in full. Only use Read/Grep for files listed under "Additional relevant files" if you need more detail.`); + } else if (anyFileTrimmed) { + lines.push(''); + lines.push(`> Some file sections were trimmed for size. Use \`codegraph_node\` or Read for the full source if needed.`); + } // Add explore budget note based on project size - try { - const stats = cg.getStats(); - const budget = getExploreBudget(stats.fileCount); - lines.push(''); - lines.push(`> **Explore budget: ${budget} calls max for this project (${stats.fileCount.toLocaleString()} files indexed).** Stop exploring and synthesize your answer once you've used ${budget} calls — do NOT make additional explore calls beyond this budget.`); - } catch { - // Stats unavailable — skip budget note + if (budget.includeBudgetNote) { + try { + const stats = cg.getStats(); + const callBudget = getExploreBudget(stats.fileCount); + lines.push(''); + lines.push(`> **Explore budget: ${callBudget} calls max for this project (${stats.fileCount.toLocaleString()} files indexed).** Stop exploring and synthesize your answer once you've used ${callBudget} calls — do NOT make additional explore calls beyond this budget.`); + } catch { + // Stats unavailable — skip budget note + } } return this.textResult(lines.join('\n')); From 2c1a314b84fd3633624f10f752163f9629c105e2 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Tue, 19 May 2026 17:16:12 -0500 Subject: [PATCH 013/133] feat(mcp): line numbers in explore output + per-file cluster fixes (#188) * feat(mcp): line numbers in explore output + per-file cluster fixes Follow-up to #185. Three changes to codegraph_explore: 1. Source sections now carry cat -n style line-number prefixes (\t), so the agent can cite file:line straight from the payload instead of re-Reading the file just to recover a line number. Isolated A/B: the no-line-numbers arm spent 2 Reads + a grep to find a line number the line-numbered arm cited with zero follow-up calls. Payload cost ~3-5%. Toggle off with CODEGRAPH_EXPLORE_LINENUMS=0. 2. Per-file cluster selection now ranks clusters containing a query entry point ahead of dense declaration blocks. Density-only ranking buried the relevant methods (perform/didCreateURLRequest/task in Alamofire's Session.swift) under the top-of-file class header + property list. 3. Whole-file "envelope" nodes (a class/struct/etc. spanning >50% of the file) are excluded from clustering. The Session class spans ~1,400 lines; keeping it collapsed every method into one giant cluster that tail-trimmed down to just the class header, hiding the methods. Net vs the 0.7.10 baseline, line numbers on: Alamofire -60%, Excalidraw -32%, VS Code -12% per explore call. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(mcp): language-neutral omission markers in explore output The gap separator and the two tail-trim markers used C-style `//` comments, which aren't comments in Python, Ruby, etc. Switch to plain `... (gap) ...` / `... (trimmed) ...` so they read correctly inside any language's fenced source block. With line numbers on, the line-number jump already corroborates a gap. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(mcp): language-neutral truncation marker in codegraph_context Sibling to the explore marker fix: codegraph_context's code-block truncation used a C-style `// ... truncated ...`. Switch to `... (truncated) ...` so it reads correctly in any language's fenced source block. Co-Authored-By: Claude Opus 4.7 (1M context) * chore(release): bump version to 0.7.11 --------- Co-authored-by: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 35 ++++++++-- __tests__/explore-output-budget.test.ts | 43 ++++++++++++ package-lock.json | 4 +- package.json | 2 +- src/context/index.ts | 6 +- src/mcp/tools.ts | 87 ++++++++++++++++++++----- 6 files changed, 150 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 828421d55..7c32c1522 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,18 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added +- **MCP / explore**: `codegraph_explore` source sections now carry line + numbers (cat -n style `\t`, matching the Read tool). This lets + the agent cite `file:line` straight from the explore payload instead of + re-opening the file just to find a line number — the dominant residual + cost on precise-tracing questions. In an isolated A/B (answer a + "which exact line" question with the relevant code already in the + payload), the no-line-numbers arm spent 2 file Reads + a grep recovering + the line number while the line-numbered arm answered with zero follow-up + tool calls. Payload cost is small (~3-5%). Set + `CODEGRAPH_EXPLORE_LINENUMS=0` to disable. + ### Changed - **MCP / explore**: `codegraph_explore` output is now adaptive to project size. The tool used to apply a fixed 35KB cap regardless of how large the @@ -22,12 +34,23 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). (<5,000) caps at ~28KB; large (<15,000) keeps the historical ~35KB; very large goes up to ~38KB. A new per-file char cap also prevents a single file with many adjacent symbols from collapsing into one whole-file dump - (the Alamofire `Session.swift` case from #185). Measured against the - same repos used in the README benchmark: Alamofire ~62% smaller per call, - Excalidraw ~35%, VS Code ~14%. Agent-trust floor still holds — the - Relationships section, scored cluster selection, and structured-source - output are all retained. Thanks to - [@essopsp](https://github.com/essopsp) for the repro. + (the Alamofire `Session.swift` case from #185). Per-file cluster + selection ranks clusters that contain a query entry point ahead of dense + declaration blocks, and whole-file "envelope" nodes (a class/struct that + spans most of the file) are excluded from clustering so the methods the + query asked about aren't buried under the container's opening lines. + Measured against the same repos used in the README benchmark, end state + with line numbers on: Alamofire ~60% smaller per call, Excalidraw ~32%, + VS Code ~12%. Agent-trust floor still holds — the Relationships section, + scored cluster selection, and structured-source output are all retained. + Thanks to [@essopsp](https://github.com/essopsp) for the repro. + +### Fixed +- **MCP**: source-omission markers in `codegraph_explore` and + `codegraph_context` output are now language-neutral (`... (gap) ...`, + `... (trimmed) ...`, `... (truncated) ...`) instead of C-style `//` + comments, which were misleading inside Python, Ruby, and other non-C + fenced source blocks. ## [0.7.10] - 2026-05-19 diff --git a/__tests__/explore-output-budget.test.ts b/__tests__/explore-output-budget.test.ts index 36717f825..65ddc6488 100644 --- a/__tests__/explore-output-budget.test.ts +++ b/__tests__/explore-output-budget.test.ts @@ -188,4 +188,47 @@ describe('codegraph_explore output respects the adaptive budget', () => { const sourceFollowsHeader = text.indexOf('### Source Code') > 0; expect(hasRelationships || sourceFollowsHeader).toBe(true); }); + + it('prefixes source lines with line numbers by default (cat -n style)', async () => { + delete process.env.CODEGRAPH_EXPLORE_LINENUMS; + const result = await handler.execute('codegraph_explore', { query: 'Session method helper' }); + const text = result.content?.[0]?.text ?? ''; + // At least one fenced source line should look like `\t`. + expect(/\n\d+\t/.test(text)).toBe(true); + }); + + it('omits line numbers when CODEGRAPH_EXPLORE_LINENUMS=0', async () => { + process.env.CODEGRAPH_EXPLORE_LINENUMS = '0'; + try { + const result = await handler.execute('codegraph_explore', { query: 'Session method helper' }); + const text = result.content?.[0]?.text ?? ''; + // The synthetic source has no tab-prefixed numeric lines of its own, + // so none should appear when the toggle is off. + expect(/\n\d+\t(?:export| )/.test(text)).toBe(false); + } finally { + delete process.env.CODEGRAPH_EXPLORE_LINENUMS; + } + }); + + it('uses language-neutral omission markers (no C-style // in the output)', async () => { + // The gap/trimmed separators must not assume `//` is a comment — that's + // wrong in Python, Ruby, etc. They render inside fenced source blocks. + const result = await handler.execute('codegraph_explore', { query: 'Session method helper' }); + const text = result.content?.[0]?.text ?? ''; + expect(text).not.toContain('// ... (gap)'); + expect(text).not.toContain('// ... trimmed'); + }); + + it('does not collapse a whole-file class into just its header (envelope filter)', async () => { + // The synthetic `Session` class spans the entire file. Without the + // envelope filter it would form one giant cluster that tail-trims to + // the class declaration, hiding the methods. Confirm real method bodies + // make it into the output. Regression guard for the #185 follow-up. + const result = await handler.execute('codegraph_explore', { query: 'Session method helper' }); + const text = result.content?.[0]?.text ?? ''; + // A method body line (`methodN(arg: string)`) should appear, not just + // the `export class Session {` opener. + const hasMethodBody = /method\d+\(arg: string\)/.test(text); + expect(hasMethodBody).toBe(true); + }); }); diff --git a/package-lock.json b/package-lock.json index dfcebafad..2d4e515a8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@colbymchenry/codegraph", - "version": "0.7.10", + "version": "0.7.11", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@colbymchenry/codegraph", - "version": "0.7.10", + "version": "0.7.11", "license": "MIT", "dependencies": { "@clack/prompts": "^1.3.0", diff --git a/package.json b/package.json index 2731804b3..60dc5c710 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@colbymchenry/codegraph", - "version": "0.7.10", + "version": "0.7.11", "description": "Supercharge Claude Code with semantic code intelligence. 94% fewer tool calls • 77% faster exploration • 100% local.", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/src/context/index.ts b/src/context/index.ts index 941923776..7298cd411 100644 --- a/src/context/index.ts +++ b/src/context/index.ts @@ -1006,9 +1006,11 @@ export class ContextBuilder { const code = await this.extractNodeCode(node); if (code) { - // Truncate if too long + // Truncate if too long. Language-neutral marker (no `//` — not a + // comment in Python, Ruby, etc.); this renders inside a fenced + // source block whose language varies. const truncated = code.length > maxBlockSize - ? code.slice(0, maxBlockSize) + '\n// ... truncated ...' + ? code.slice(0, maxBlockSize) + '\n... (truncated) ...' : code; blocks.push({ diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index 217679069..7b0d55b05 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -142,6 +142,38 @@ export function getExploreOutputBudget(fileCount: number): ExploreOutputBudget { }; } +/** + * Whether `codegraph_explore` should prefix source lines with their line + * numbers (cat -n style: `\t`). + * + * Line numbers let the agent cite `file:line` straight from the explore + * payload instead of re-Reading the file just to find a line number — the + * dominant residual cost on precise-tracing questions (#185 follow-up). + * + * Defaults ON. Set `CODEGRAPH_EXPLORE_LINENUMS=0` to disable (used by the + * A/B harness to measure the payload-cost vs. read-savings tradeoff). + */ +function exploreLineNumbersEnabled(): boolean { + return process.env.CODEGRAPH_EXPLORE_LINENUMS !== '0'; +} + +/** + * Prefix each line of a source slice with its 1-based line number, matching + * the Read tool's `cat -n` convention (number + tab) so the agent treats it + * the same way it treats Read output. + * + * @param slice contiguous source text (already extracted from the file) + * @param firstLineNumber the 1-based line number of the slice's first line + */ +function numberSourceLines(slice: string, firstLineNumber: number): string { + const out: string[] = []; + const split = slice.split('\n'); + for (let i = 0; i < split.length; i++) { + out.push(`${firstLineNumber + i}\t${split[i]}`); + } + return out.join('\n'); +} + /** * Mark a Claude session as having consulted MCP tools. * This enables Grep/Glob/Bash commands that would otherwise be blocked. @@ -940,10 +972,19 @@ export class ToolHandler { // are worth 10, directly-connected nodes 3, peripheral nodes 1, and // bare edge-source lines 2 (less than a connected node but more than // a peripheral one — they hint at a reference but aren't a definition). + // Container kinds whose body can span most/all of a file. When such a + // node covers most of the file we drop it from the ranges: keeping it + // would merge every method inside it into one giant cluster spanning + // the whole file, which then tail-trims down to just the container's + // opening lines (its header/declarations) and buries the methods the + // query actually asked about (#185 follow-up — Session.swift in + // Alamofire is the canonical case: the `Session` class spans ~1,400 + // lines). We want the granular symbols inside, not the envelope. + const ENVELOPE_KINDS = new Set(['file', 'module', 'class', 'struct', 'interface', 'enum', 'namespace', 'protocol', 'trait', 'component']); const ranges: Array<{ start: number; end: number; name: string; kind: string; importance: number }> = group.nodes .filter(n => n.startLine > 0 && n.endLine > 0) - // Skip file/component nodes that span the entire file — they'd create one giant cluster - .filter(n => !(n.kind === 'component' && n.startLine === 1 && n.endLine >= fileLines.length - 1)) + // Drop whole-file envelope nodes (containers covering >50% of the file). + .filter(n => !(ENVELOPE_KINDS.has(n.kind) && (n.endLine - n.startLine + 1) > fileLines.length * 0.5)) .map(n => { let importance = 1; if (entryNodeIds.has(n.id)) importance = 10; @@ -975,12 +1016,13 @@ export class ToolHandler { if (ranges.length === 0) continue; const gapThreshold = budget.gapThreshold; - const clusters: Array<{ start: number; end: number; symbols: string[]; score: number }> = []; + const clusters: Array<{ start: number; end: number; symbols: string[]; score: number; maxImportance: number }> = []; let current = { start: ranges[0]!.start, end: ranges[0]!.end, symbols: [`${ranges[0]!.name}(${ranges[0]!.kind})`], score: ranges[0]!.importance, + maxImportance: ranges[0]!.importance, }; for (let i = 1; i < ranges.length; i++) { @@ -989,6 +1031,7 @@ export class ToolHandler { current.end = Math.max(current.end, r.end); current.symbols.push(`${r.name}(${r.kind})`); current.score += r.importance; + current.maxImportance = Math.max(current.maxImportance, r.importance); } else { clusters.push(current); current = { @@ -996,6 +1039,7 @@ export class ToolHandler { end: r.end, symbols: [`${r.name}(${r.kind})`], score: r.importance, + maxImportance: r.importance, }; } } @@ -1005,25 +1049,36 @@ export class ToolHandler { // The pathological case (#185): a file like Session.swift where every // method is adjacent collapses into one cluster spanning the whole // file, and dumping that into the agent's context is most of the - // token cost on small projects. We pick clusters in score order - // (importance per line, so we don't prefer one giant low-density - // cluster over several focused ones) until the per-file char cap is - // hit. Truly enormous single clusters get tail-trimmed with a marker. + // token cost on small projects. We pick clusters in priority order + // until the per-file char cap is hit. Truly enormous single clusters + // get tail-trimmed with a marker. const contextPadding = 3; + const withLineNumbers = exploreLineNumbersEnabled(); const buildSection = (c: { start: number; end: number }): string => { const startIdx = Math.max(0, c.start - 1 - contextPadding); const endIdx = Math.min(fileLines.length, c.end + contextPadding); - return fileLines.slice(startIdx, endIdx).join('\n'); + const slice = fileLines.slice(startIdx, endIdx).join('\n'); + // startIdx is 0-based, so the slice's first line is line startIdx + 1. + return withLineNumbers ? numberSourceLines(slice, startIdx + 1) : slice; }; - const GAP_MARKER = '\n\n// ... (gap) ...\n\n'; - - // Score clusters by score-per-line (density) so a 30-line cluster - // with two entry symbols outranks a 400-line cluster with two - // peripheral symbols. Stable tiebreak by score, then by smaller - // span (cheaper to include). + // Language-neutral separator (no `//` — not a comment in Python, Ruby, + // etc.). With line numbers on, the line-number jump also signals the gap. + const GAP_MARKER = '\n\n... (gap) ...\n\n'; + + // Rank clusters for inclusion under the per-file cap. Entry-point + // clusters come first: a cluster containing a query entry point + // (importance 10) must outrank a dense block of mere declarations, + // otherwise on a large file like Session.swift the top-of-file class + // header + property list (many adjacent low-importance nodes, high + // density) wins the budget and buries the actual methods the query + // asked about (perform/didCreateURLRequest/task live deep in the + // file). Within the same importance tier, prefer density (score per + // line) so we still favor focused clusters over sprawling ones, then + // smaller span as a cheap-to-include tiebreak. const rankedClusters = clusters .map((c, i) => ({ idx: i, span: c.end - c.start + 1, c })) .sort((a, b) => { + if (b.c.maxImportance !== a.c.maxImportance) return b.c.maxImportance - a.c.maxImportance; const densityA = a.c.score / a.span; const densityB = b.c.score / b.span; if (densityB !== densityA) return densityB - densityA; @@ -1064,7 +1119,7 @@ export class ToolHandler { // If a single chosen cluster is still oversize (long monolithic // function), tail-trim it. Better one trimmed view than nothing. if (fileSection.length > budget.maxCharsPerFile) { - fileSection = fileSection.slice(0, budget.maxCharsPerFile) + '\n// ... trimmed ...'; + fileSection = fileSection.slice(0, budget.maxCharsPerFile) + '\n... (trimmed) ...'; fileTrimmed = true; } if (chosenIndices.size < clusters.length || fileTrimmed) { @@ -1094,7 +1149,7 @@ export class ToolHandler { if (totalChars + fileSection.length + 200 > budget.maxOutputChars) { const remaining = budget.maxOutputChars - totalChars - 200; if (remaining < 500) break; - const trimmed = fileSection.slice(0, remaining) + '\n// ... trimmed ...'; + const trimmed = fileSection.slice(0, remaining) + '\n... (trimmed) ...'; lines.push(fileHeader); lines.push(''); From 1cbca5a51e94341046e8ce89dbae5d20f237f84a Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Wed, 20 May 2026 08:25:43 -0500 Subject: [PATCH 014/133] docs: add Star History chart to README --- README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/README.md b/README.md index 910d78018..49cf8d54c 100644 --- a/README.md +++ b/README.md @@ -492,6 +492,16 @@ The `.codegraph/config.json` file controls indexing: **Missing symbols** — The MCP server auto-syncs on save (wait a couple seconds). Run `codegraph sync` manually if needed. Check that the file's language is supported and isn't excluded by config patterns. +## Star History + + + + + + Star History Chart + + + ## License MIT From 7fe64b32be0a08b35d737e76dcbb79c79ddea408 Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Wed, 20 May 2026 09:39:17 -0500 Subject: [PATCH 015/133] feat(eval): add agent-eval harness and /audit + /publish Claude skills MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the old interactive publish.js script with two Claude skills and a full agent-evaluation harness: - `.claude/skills/audit/` — `/audit` skill drives `scripts/agent-eval/audit.sh` to benchmark retrieval quality (with vs. without codegraph) on a chosen real-world repo from the new `corpus.json` (17 repos across 14 languages). - `.claude/skills/publish/` — `/publish` skill orchestrates the full release workflow (preflight → changelog → confirmation gate → bump/build → npm publish → GitHub release), replacing `publish.js`. - `scripts/agent-eval/` — headless (`run-agent.sh`, `run-all.sh`) and interactive tmux (`itrun.sh`) harnesses with stream-json parsers (`parse-run.mjs`, `parse-session.mjs`) that report tool calls, token usage, and a VERDICT line summarising codegraph_explore vs. Read/Grep counts. - `run-interactive-test.md` — documents the two harnesses, idle-detection approach, and what "good" agent behavior looks like after explore-first guidance. --- .claude/skills/audit/SKILL.md | 74 +++++++++++++++ .claude/skills/audit/corpus.json | 63 +++++++++++++ .claude/skills/publish/SKILL.md | 136 +++++++++++++++++++++++++++ publish.js | 65 ------------- run-interactive-test.md | 131 ++++++++++++++++++++++++++ scripts/agent-eval/audit.sh | 68 ++++++++++++++ scripts/agent-eval/itrun.sh | 107 +++++++++++++++++++++ scripts/agent-eval/parse-run.mjs | 45 +++++++++ scripts/agent-eval/parse-session.mjs | 93 ++++++++++++++++++ scripts/agent-eval/run-agent.sh | 34 +++++++ scripts/agent-eval/run-all.sh | 67 +++++++++++++ 11 files changed, 818 insertions(+), 65 deletions(-) create mode 100644 .claude/skills/audit/SKILL.md create mode 100644 .claude/skills/audit/corpus.json create mode 100644 .claude/skills/publish/SKILL.md delete mode 100644 publish.js create mode 100644 run-interactive-test.md create mode 100755 scripts/agent-eval/audit.sh create mode 100755 scripts/agent-eval/itrun.sh create mode 100644 scripts/agent-eval/parse-run.mjs create mode 100644 scripts/agent-eval/parse-session.mjs create mode 100755 scripts/agent-eval/run-agent.sh create mode 100755 scripts/agent-eval/run-all.sh diff --git a/.claude/skills/audit/SKILL.md b/.claude/skills/audit/SKILL.md new file mode 100644 index 000000000..ee13ebe17 --- /dev/null +++ b/.claude/skills/audit/SKILL.md @@ -0,0 +1,74 @@ +--- +name: audit +description: Benchmark CodeGraph retrieval quality on a real codebase by comparing agent behavior with vs without CodeGraph. Use when the user runs /audit or asks to test, benchmark, audit, or validate a codegraph version (the local dev build or a published npm version) against a language's repo. +--- + +# CodeGraph Quality Audit + +Measures how much CodeGraph helps an agent versus plain grep/read, for a chosen +codegraph version on a chosen real-world repo. Drives the harness in +`scripts/agent-eval/`. + +## Prerequisites +- `tmux` 3+, a logged-in `claude` CLI, `node`, `git` (macOS/Linux). +- Run from the codegraph repo root. + +## Workflow + +Copy this checklist: +``` +- [ ] 1. Pick version (local or npm) +- [ ] 2. Pick language +- [ ] 3. Pick repo by size +- [ ] 4. Pick harness (headless / tmux / both) +- [ ] 5. Run audit.sh in the background +- [ ] 6. Report results +``` + +**Step 1 — version.** Ask with `AskUserQuestion`: which codegraph version to test. +Offer "Local dev build" and "Latest published"; the free-text "Other" lets the +user type a specific version (e.g. `0.7.10`). Map the answer to a VERSION token: +- "Local dev build" → `local` +- "Latest published" → `latest` +- a typed version → that string (e.g. `0.7.10`) + +**Step 2 — language.** Read `.claude/skills/audit/corpus.json`. Ask with +`AskUserQuestion` which language to test, listing the languages that have entries. + +**Step 3 — repo.** From the chosen language's entries, ask which repo. Label each +option with its size and file count, e.g. `excalidraw — Medium (~600 files)`. +Each entry carries the `repo` URL and a representative `question`. + +**Step 4 — harness.** Ask with `AskUserQuestion` which harness to run, and map +the answer to a MODE token: +- "Headless" → `headless` — `claude -p` with stream-json: exact tokens/cost and a + clean tool sequence (2 runs, fast, no TTY). +- "Interactive (tmux)" → `tmux` — drives the real Claude TUI in tmux: faithful + Explore-subagent behavior, metrics from session logs (2 runs, slower). +- "Both" → `all` — headless + interactive (4 runs). + +**Step 5 — run.** Launch in the background (sets the version, clones if missing, +wipes + re-indexes, runs the chosen arms — several minutes): +```bash +scripts/agent-eval/audit.sh "" +``` + +**Step 6 — report.** When the job finishes, read the log and report per arm: +- Headless (`parse-run.mjs`): total tool calls, file `Read`s, Grep/Bash, + codegraph-tool calls, duration, **total cost**. +- Interactive (`parse-session.mjs`): the `VERDICT: codegraph_explore used Nx | + Read N | Grep/Bash N` and `TOKENS:` lines. + +Lead with cost + tool/Read counts — they are the reliable signals; raw token +in/out are confounded by subagent delegation and prompt caching. State whether +codegraph reduced effort and whether both arms reached a correct answer. + +## Notes +- The index is rebuilt every run (`audit.sh` wipes `.codegraph`) — different + versions extract differently, so an index must be served by the same binary + that built it. +- `audit.sh` temporarily mutates the global `codegraph` install for the test, + then restores your dev link via `local-install.sh`. +- Corpus repos are cloned to `/tmp/codegraph-corpus` (reused if already present). +- Add or edit repos in `corpus.json` (fields: `name`, `repo`, `size`, `files`, + `question`). diff --git a/.claude/skills/audit/corpus.json b/.claude/skills/audit/corpus.json new file mode 100644 index 000000000..4b48dab03 --- /dev/null +++ b/.claude/skills/audit/corpus.json @@ -0,0 +1,63 @@ +{ + "_comment": "Test corpus for /audit. Add entries freely. size: Small (<~150 files), Medium (~150-1500), Large (>~1500). 'question' is a representative architectural question that exercises cross-file understanding.", + "TypeScript": [ + { "name": "ky", "repo": "https://github.com/sindresorhus/ky", "size": "Small", "files": "~25", "question": "How does ky implement request retries and timeouts?" }, + { "name": "excalidraw", "repo": "https://github.com/excalidraw/excalidraw", "size": "Medium", "files": "~600", "question": "How does Excalidraw render and update canvas elements?" }, + { "name": "vscode", "repo": "https://github.com/microsoft/vscode", "size": "Large", "files": "~10000", "question": "How does the extension host communicate with the main process?" } + ], + "JavaScript": [ + { "name": "express", "repo": "https://github.com/expressjs/express", "size": "Small", "files": "~50", "question": "How does Express route a request through its middleware stack?" } + ], + "Go": [ + { "name": "cobra", "repo": "https://github.com/spf13/cobra", "size": "Small", "files": "~50", "question": "How does cobra parse commands and flags?" }, + { "name": "gin", "repo": "https://github.com/gin-gonic/gin", "size": "Medium", "files": "~150", "question": "How does gin route requests through its middleware chain?" }, + { "name": "terraform", "repo": "https://github.com/hashicorp/terraform", "size": "Large", "files": "~4000", "question": "How does Terraform build and walk the resource dependency graph?" } + ], + "Python": [ + { "name": "click", "repo": "https://github.com/pallets/click", "size": "Small", "files": "~60", "question": "How does click parse command-line arguments into commands?" }, + { "name": "flask", "repo": "https://github.com/pallets/flask", "size": "Medium", "files": "~90", "question": "How does Flask dispatch a request to a view function?" }, + { "name": "django", "repo": "https://github.com/django/django", "size": "Large", "files": "~2700", "question": "How does Django's ORM build and execute a query from a QuerySet?" } + ], + "Rust": [ + { "name": "clap", "repo": "https://github.com/clap-rs/clap", "size": "Medium", "files": "~200", "question": "How does clap parse arguments against a derived command definition?" }, + { "name": "tokio", "repo": "https://github.com/tokio-rs/tokio", "size": "Large", "files": "~700", "question": "How does tokio schedule and run async tasks on its runtime?" }, + { "name": "deno", "repo": "https://github.com/denoland/deno", "size": "Large", "files": "~1500", "question": "How does Deno load and execute a TypeScript module?" } + ], + "Java": [ + { "name": "gson", "repo": "https://github.com/google/gson", "size": "Medium", "files": "~200", "question": "How does Gson serialize an object to JSON?" }, + { "name": "okhttp", "repo": "https://github.com/square/okhttp", "size": "Medium", "files": "~640", "question": "How does OkHttp process a request through its interceptor chain?" }, + { "name": "guava", "repo": "https://github.com/google/guava", "size": "Large", "files": "~3000", "question": "How does Guava's CacheBuilder build and configure a cache?" } + ], + "Kotlin": [ + { "name": "koin", "repo": "https://github.com/InsertKoinIO/koin", "size": "Medium", "files": "~300", "question": "How does Koin resolve and inject dependencies?" }, + { "name": "leakcanary", "repo": "https://github.com/square/leakcanary", "size": "Medium", "files": "~250", "question": "How does LeakCanary detect and analyze a memory leak?" } + ], + "Swift": [ + { "name": "alamofire", "repo": "https://github.com/Alamofire/Alamofire", "size": "Small", "files": "~100", "question": "How does Alamofire build, send, and validate a request?" } + ], + "C#": [ + { "name": "serilog", "repo": "https://github.com/serilog/serilog", "size": "Medium", "files": "~250", "question": "How does Serilog route a log event to its sinks?" }, + { "name": "jellyfin", "repo": "https://github.com/jellyfin/jellyfin", "size": "Large", "files": "~2500", "question": "How does Jellyfin scan and identify items in a media library?" } + ], + "Ruby": [ + { "name": "sinatra", "repo": "https://github.com/sinatra/sinatra", "size": "Small", "files": "~60", "question": "How does Sinatra match a request to a route handler?" }, + { "name": "discourse", "repo": "https://github.com/discourse/discourse", "size": "Large", "files": "~3000", "question": "How does Discourse create and render a new post?" } + ], + "PHP": [ + { "name": "slim", "repo": "https://github.com/slimphp/Slim", "size": "Small", "files": "~80", "question": "How does Slim handle a request through its middleware?" }, + { "name": "laravel", "repo": "https://github.com/laravel/framework", "size": "Large", "files": "~3000", "question": "How does Laravel resolve and dispatch a route to a controller?" } + ], + "C": [ + { "name": "redis", "repo": "https://github.com/redis/redis", "size": "Large", "files": "~600", "question": "How does Redis parse and dispatch a client command?" } + ], + "C++": [ + { "name": "json", "repo": "https://github.com/nlohmann/json", "size": "Small", "files": "~100", "question": "How does nlohmann::json parse a JSON string into a value?" }, + { "name": "grpc", "repo": "https://github.com/grpc/grpc", "size": "Large", "files": "~3000", "question": "How does gRPC dispatch an incoming RPC to its handler?" } + ], + "Dart": [ + { "name": "flutter", "repo": "https://github.com/flutter/flutter", "size": "Large", "files": "~6000", "question": "How does Flutter build and lay out a widget tree?" } + ], + "Svelte": [ + { "name": "shadcn-svelte", "repo": "https://github.com/huntabyte/shadcn-svelte", "size": "Medium", "files": "~600", "question": "How do shadcn-svelte components compose and apply their styling?" } + ] +} diff --git a/.claude/skills/publish/SKILL.md b/.claude/skills/publish/SKILL.md new file mode 100644 index 000000000..84c6d4b3e --- /dev/null +++ b/.claude/skills/publish/SKILL.md @@ -0,0 +1,136 @@ +--- +name: publish +description: Publishes a new minor or major release of this npm package (codegraph). Reads the latest version from npm, generates a user-perspective CHANGELOG entry from commits since the last tag, bumps package.json, publishes to npm, and creates the matching GitHub release. Use when the user runs /publish or asks to cut, ship, or publish a release / new version. +--- + +# Publish a release + +Cut a **minor or major** release: generate the changelog, bump, publish to npm, and create the GitHub release. Patch releases are intentionally not offered here. + +This skill performs the actual publish (npm publish, git push, GitHub release) — that is the whole point of invoking it, so the general "hand the user the commands" rule does **not** apply inside `/publish`. The **confirmation gate in Step 5 is the safeguard**: never run a step past it without explicit approval. + +Run from the repo root. + +## Workflow + +Copy this checklist and work through it in order: + +``` +- [ ] 1. Preflight: branch, sync, auth +- [ ] 2. Read base version from npm, compute candidates +- [ ] 3. Ask the user: minor or major +- [ ] 4. Generate the CHANGELOG entry from commits since the last tag +- [ ] 5. CONFIRMATION GATE — show changelog + plan, get explicit approval +- [ ] 6. Write CHANGELOG.md, bump, build +- [ ] 7. Commit + push +- [ ] 8. npm publish +- [ ] 9. scripts/release.sh (GitHub release) +- [ ] 10. Verify on the npm registry +``` + +### Step 1 — Preflight + +```bash +git rev-parse --abbrev-ref HEAD # expect: main +git fetch origin +git status --porcelain # working tree should be clean +git rev-list --left-right --count origin/main...HEAD # " " +npm whoami # npm auth (publish will fail without it) +gh auth status # gh auth (release.sh needs it) +``` + +- If not on `main`, stop and ask the user to confirm releasing from this branch. +- If behind origin, `git pull --ff-only` so the final push is a fast-forward. +- If the tree has **unrelated** uncommitted changes, stop and ask — the release commit only stages 3 files, but a dirty tree usually means something's mid-flight. +- If `npm whoami` or `gh auth status` fails, stop and tell the user to authenticate. + +### Step 2 — Base version + candidates + +The latest **published** version is the source of truth, not local `package.json`. + +```bash +PKG=$(node -p "require('./package.json').name") +BASE=$(npm view "$PKG" version) +node -e "const [a,b]=process.argv[1].split('.').map(Number);console.log('minor ->',a+'.'+(b+1)+'.0');console.log('major ->',(a+1)+'.0.0')" "$BASE" +``` + +Note if local `package.json` differs from `BASE` (an unpublished bump) — surface it, but still base the new version on npm. + +### Step 3 — Ask minor or major + +Use the **AskUserQuestion** tool with the two computed candidates as options (show the resulting version in each label, e.g. "minor → 0.8.0"). Set the new version from the answer. + +### Step 4 — Generate the changelog entry + +```bash +LAST=$(git describe --tags --abbrev=0 --match 'v*' 2>/dev/null) +git log --no-merges "${LAST}..HEAD" --pretty=format:'%h %s' +``` + +Read the commit subjects; for any whose user impact is unclear, inspect the diff (`git show ` or `git diff "${LAST}..HEAD" -- `). Then **write the entry yourself** following the repo's conventions in `CLAUDE.md` → "Writing changelog entries": + +- Header: `## [X.Y.Z] - YYYY-MM-DD` (get the date with `date +%F`). +- Group under `### Added`, `### Changed`, `### Fixed`, `### Removed`, `### Deprecated`, `### Security` — **omit empty sections**. +- Write from the **user's perspective** (observable capability/symptom), not the implementation. Collapse noisy commits ("fix typo", "address review") into the feature they belong to or drop them. +- Plan the bottom link reference: `[X.Y.Z]: https://github.com/colbymchenry/codegraph/releases/tag/vX.Y.Z`. + +Do not write to any file yet — draft it for review first. + +### Step 5 — CONFIRMATION GATE + +Show the user, in chat: +1. The new version (`BASE` → `X.Y.Z`, minor/major). +2. The full drafted changelog entry. +3. The exact actions Steps 6–9 will take (commit + push + npm publish + GitHub release). + +Then **STOP**. Proceed only on explicit approval ("yes" / "proceed"). If the user requests prose changes, revise the draft and re-show. Do not run any command below until approved. + +### Step 6 — Write changelog, bump, build + +1. Use the **Edit** tool to insert the drafted `## [X.Y.Z]` block at the **top** of `CHANGELOG.md` (under the intro, above the previous version), and add the link reference with the other `[x.y.z]:` links at the bottom. +2. Bump (also updates `package-lock.json`; `--allow-same-version` keeps re-runs safe): + ```bash + npm version X.Y.Z --no-git-tag-version --allow-same-version + ``` +3. Build (fail fast before any push/publish): + ```bash + npm run build + ``` + +### Step 7 — Commit + push + +`release.sh` tags HEAD, so the bump must be committed first. + +```bash +git add package.json package-lock.json CHANGELOG.md +git commit -m "release: X.Y.Z" +git push +``` + +### Step 8 — Publish to npm + +```bash +npm publish --access public +``` + +### Step 9 — GitHub release + +`scripts/release.sh` reads the `## [X.Y.Z]` block from CHANGELOG.md, tags `vX.Y.Z`, pushes the tag, and creates the GitHub release. It is idempotent. + +```bash +./scripts/release.sh +``` + +### Step 10 — Verify + +Confirm against the **registry**, not the website (the website caches): + +```bash +npm view "$PKG" version # must equal X.Y.Z +``` + +Report the release URL (`scripts/release.sh` prints it) and the published version. + +## If something fails midway + +Re-running is safe: `npm version --allow-same-version` no-ops if already bumped, `git commit` skips if nothing's staged (check `git diff --cached --quiet`), `git push` no-ops if up to date, and `scripts/release.sh` skips tag/release steps already done. Re-run from the failed step. diff --git a/publish.js b/publish.js deleted file mode 100644 index cbbabd75c..000000000 --- a/publish.js +++ /dev/null @@ -1,65 +0,0 @@ -#!/usr/bin/env node -const { execSync } = require('child_process'); -const fs = require('fs'); -const path = require('path'); -const readline = require('readline'); - -const PKG_PATH = path.join(__dirname, 'package.json'); -const pkg = JSON.parse(fs.readFileSync(PKG_PATH, 'utf-8')); -const [major, minor, patch] = pkg.version.split('.').map(Number); - -const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); - -function ask(question) { - return new Promise((resolve) => rl.question(question, resolve)); -} - -async function main() { - console.log(`\nCurrent version: ${pkg.version}\n`); - console.log(' 1) patch -> ' + `${major}.${minor}.${patch + 1}`); - console.log(' 2) minor -> ' + `${major}.${minor + 1}.0`); - console.log(' 3) major -> ' + `${major + 1}.0.0`); - console.log(''); - - const choice = await ask('Bump version (1/2/3): '); - - let bump; - switch (choice.trim()) { - case '1': bump = 'patch'; break; - case '2': bump = 'minor'; break; - case '3': bump = 'major'; break; - default: - console.log('Invalid choice. Exiting.'); - rl.close(); - process.exit(1); - } - - // Bump version in package.json - execSync(`npm version ${bump} --no-git-tag-version`, { stdio: 'inherit' }); - - const updated = JSON.parse(fs.readFileSync(PKG_PATH, 'utf-8')); - console.log(`\nVersion bumped to ${updated.version}`); - - const confirm = await ask(`Publish ${updated.name}@${updated.version} to npm? (y/n): `); - if (confirm.trim().toLowerCase() !== 'y') { - console.log('Aborted.'); - rl.close(); - process.exit(0); - } - - // Build and publish - console.log('\nBuilding...'); - execSync('npm run build', { stdio: 'inherit' }); - - console.log('\nPublishing...'); - execSync('npm publish --access public', { stdio: 'inherit' }); - - console.log(`\nPublished ${updated.name}@${updated.version}`); - rl.close(); -} - -main().catch((err) => { - console.error(err); - rl.close(); - process.exit(1); -}); diff --git a/run-interactive-test.md b/run-interactive-test.md new file mode 100644 index 000000000..448c9e62a --- /dev/null +++ b/run-interactive-test.md @@ -0,0 +1,131 @@ +# Running the agent-behavior test (how agents actually use codegraph) + +This explains how to measure **how a Claude Code agent uses the codegraph MCP +tools** on a real repo — which tools it calls (does it lead with +`codegraph_explore`?), how many follow-up `Read`/`Grep`s it does, and the token +cost. Use it when changing tool guidance (`server-instructions.ts`, +`instructions-template.ts`, tool descriptions) or retrieval, to verify the +change actually shifts agent behavior. + +Scripts live in `scripts/agent-eval/`. + +## Why two harnesses (read this first) + +| | Interactive (`itrun.sh`) | Headless (`run-agent.sh`) | +|---|---|---| +| Drives | the real TUI via tmux | `claude -p` print mode | +| Subagent it picks | **Explore** (matches real UX) | general-purpose (diverges) | +| Metrics | tool breakdown (from session logs) + `Done(…)` token summary | exact per-tool calls + tokens/cost (stream-json) | +| Cost | Claude Max subscription | API $ (`total_cost_usd`) | + +**Headless `claude -p` does NOT reproduce what users see** — it silently picks +the general-purpose subagent, while interactive sessions delegate to the +read-first **Explore** subagent. So for "what does my session actually do," use +the interactive harness. For a clean per-tool/token breakdown in one shot, use +headless (and ask for the Explore subagent in the prompt if you want that path). + +## Prerequisites + +- **tmux 3.0+** +- A logged-in `claude` CLI (Claude Max or API). +- codegraph configured as an MCP server (`claude mcp list` shows `codegraph`). + The interactive harness uses your global config, so it runs whatever + `codegraph` resolves to — point that at your dev build (`npm link` / the + symlinked global) to test local changes. +- A target repo, cloned and indexed: + ```bash + git clone --depth 1 https://github.com/square/okhttp /tmp/corpus/okhttp + cd /tmp/corpus/okhttp && codegraph init -i + ``` + Good scale spread for a sweep: Alamofire (~100 files), Excalidraw (~600), + OkHttp (~640), VS Code (~10k). + +## Interactive test (the faithful one) + +```bash +scripts/agent-eval/itrun.sh