From ca33c02e1bc66d8057ce11959685971eef28109d Mon Sep 17 00:00:00 2001 From: minhn4 Date: Mon, 27 Jul 2026 16:15:42 +0700 Subject: [PATCH] feat: wire CodeGraph into CoDev Code via custom target or config shim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit codegraph has no built-in target for the codev-code fork. setupCodegraph now wires it per run through one of two paths that produce byte-identical config: when the installed codegraph supports custom targets (upstream PR #1459), register the codev spec via `codegraph targets add` and append `codev` to the install CSV; otherwise write the mcp.codegraph entry into ~/.config/codev/codev.json(c) directly with surgical jsonc edits. Since codev-hub always npm-installs the latest codegraph before wiring, the custom-target path activates automatically the moment upstream releases it — the shim and probe then become a pure code deletion. Supporting changes: - configureOpenCodeKind now carries the existing top-level `mcp` map across its whole-file rewrite. Without this, every gateway-key auto-refresh and `codevhub model` switch silently unwired MCP servers — a live bug for OpenCode + CodeGraph users today, not just the fork. - Gating moves from `codegraphTargets(...).length > 0` to codegraphEligible(), which includes codev-code: the always-on flagship selection previously skipped installing the codegraph package entirely, and the new wiring would otherwise reference a missing binary. - `codevhub remove` sweeps the fork's mcp.codegraph entry after `codegraph uninstall` (no-op when a custom-target-aware uninstall already removed it), preserving sibling servers and user-owned files. Co-Authored-By: Claude Fable 5 --- AGENTS.md | 12 +- src/SetupApp.tsx | 16 +- src/components/Install.tsx | 16 +- src/lib/codegraph.ts | 279 ++++++++++++++++++++++++++++--- src/lib/configure.ts | 39 ++++- src/lib/remove.ts | 19 ++- tests/lib/codegraph.test.ts | 321 ++++++++++++++++++++++++++++++++++++ tests/lib/configure.test.ts | 78 +++++++++ tests/lib/remove.test.ts | 36 ++++ 9 files changed, 774 insertions(+), 42 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4ca1949..4699983 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -173,14 +173,18 @@ Testing: logging is a silent no-op until `initLogging` runs, so ordinary tests n `src/lib/codegraph.ts` integrates the external [CodeGraph](https://www.npmjs.com/package/@colbymchenry/codegraph) tool (a CLI + MCP server). Two surfaces: -1. **Install wiring.** Tools map to CodeGraph `--target` ids via `toolToCodegraphTarget` (the three CLI agents, plus both Claude Code *extension* variants → `claude`; Continue → none). The work is split in two: - - **Install** (`ensureCodegraphInstalled` = `npm i -g @colbymchenry/codegraph`, always) runs *before* finalize, as a visible row in the `Install` `TaskList` (labeled with the npm package name, like the agent rows) (`src/components/Install.tsx`, keyed `CODEGRAPH_TASK_KEY`). In **install mode** it sits alongside the agent rows (parallel install). In **config mode** the agents are already installed, so `Install` is rendered with `includeAgents={false}` — a CodeGraph-only step titled "Installing CodeGraph", shown right after login (only when `codegraphTargets(tools).length > 0`; otherwise config skips straight to the post-login side-effects / key-choice). The `CODEGRAPH_TASK_KEY` sentinel is **not** a `Tool` — in install mode `handleInstallDone` splits it out of the survivor set (Configure/shims would choke on it) and excludes it from the all-failed fail-stop; in config mode the survivor set is just `tools` (the CodeGraph row is best-effort and never gates the agents). +1. **Install wiring.** Tools map to CodeGraph's *built-in* `--target` ids via `toolToCodegraphTarget` (the three CLI agents, plus both Claude Code *extension* variants → `claude`; Continue → none; `codev-code` → null, see below). The work is split in two: + - **Install** (`ensureCodegraphInstalled` = `npm i -g @colbymchenry/codegraph`, always) runs *before* finalize, as a visible row in the `Install` `TaskList` (labeled with the npm package name, like the agent rows) (`src/components/Install.tsx`, keyed `CODEGRAPH_TASK_KEY`). In **install mode** it sits alongside the agent rows (parallel install). In **config mode** the agents are already installed, so `Install` is rendered with `includeAgents={false}` — a CodeGraph-only step titled "Installing CodeGraph", shown right after login (only when `codegraphEligible(tools)`; otherwise config skips straight to the post-login side-effects / key-choice). The `CODEGRAPH_TASK_KEY` sentinel is **not** a `Tool` — in install mode `handleInstallDone` splits it out of the survivor set (Configure/shims would choke on it) and excludes it from the all-failed fail-stop; in config mode the survivor set is just `tools` (the CodeGraph row is best-effort and never gates the agents). - **MCP wiring** (`setupCodegraph` → `runCodegraphInstall` = `codegraph install --target --location global --yes`) runs in `runFinalizeSideEffects`. `setupCodegraph` assumes CodeGraph is already installed — it only wires. - The whole thing is **best-effort**: the install row soft-fails as a yellow ▲ (never a ✗, never affects the fail-stop), and a wiring failure becomes a `warning` result rendered as a ▲ row — neither aborts the CoDev flow. An empty target set returns `skipped` and renders nothing. CodeGraph's own `--yes` install skips putting itself on PATH, which is why CoDev installs the package itself (the MCP configs reference a bare `codegraph` command that must resolve at agent-launch time). + The whole thing is **best-effort**: the install row soft-fails as a yellow ▲ (never a ✗, never affects the fail-stop), and a wiring failure becomes a `warning` result rendered as a ▲ row — neither aborts the CoDev flow. An empty-eligibility selection returns `skipped` and renders nothing. CodeGraph's own `--yes` install skips putting itself on PATH, which is why CoDev installs the package itself (the MCP configs reference a bare `codegraph` command that must resolve at agent-launch time). + + **CoDev Code wiring is special.** codegraph has no built-in target for the fork, so `setupCodegraph` wires it per run via one of two paths that produce byte-identical config (the `mcp.codegraph` entry in `codevCodeConfigPath()`, i.e. `~/.config/codev/codev.json(c)`): **Path A** — when `supportsCustomTargets()` (probe: `codegraph targets list`, read-only; the capability is codegraph PR #1459) reports support, register `CODEV_TARGET_SPEC` via `codegraph targets add` and append `codev` to the one install CSV; **Path B** — otherwise `wireCodevCodeMcp()` edits the config directly (surgical jsonc-parser `modify`/`applyEdits`; idempotent — an already-correct entry writes nothing; refuses malformed files). A registration failure on a capable binary silently falls back to the shim. Because codev-hub npm-installs the latest codegraph right before wiring, Path A activates machine-by-machine the moment upstream releases custom targets — once the released floor supports them, delete Path B and the probe (pure code removal, no migration). Gating uses `codegraphEligible(tools)` (built-in targets **or** codev-code), never `codegraphTargets(...).length` — codev-code is the always-on flagship tool, and gating on targets alone would write an MCP entry referencing a binary that was never installed. + + **Config-rewrite preservation.** `configureOpenCodeKind` (OpenCode + the fork) whole-file-replaces its config, and runs not just at install time but on every gateway-key auto-refresh (`refresh.ts#ensureFreshGatewayKey`) and `codevhub model` switch. It therefore carries the existing top-level `mcp` map (via `readPreservedMcp` — best-effort, only object-valued maps) across the rewrite; without that, every refresh would silently unwire CodeGraph (either path) and any MCP servers the user added. The `mcp` entry never lands in `*.backup` (taken before CoDev's first write), so restore still returns the true pre-CoDev state. 2. **Command passthrough.** `codevhub codegraph ` forwards verbatim to `codegraph ` via `forwardToCodegraph` (e.g. `codevhub codegraph init -y`). It mirrors `src/lib/run.ts#runAgent` (inherited stdio, SIGINT/SIGTERM swallowing, win32 `shell:true`) minus the shim-dir stripping and upload daemon — CodeGraph isn't a chat agent and isn't shimmed. ENOENT prints an install hint. -3. **Removal.** `codevhub remove` (`src/lib/remove.ts#runRemove`) runs `runCodegraphUninstall` (`codegraph uninstall --location global --yes`) before the config restores, to revert CodeGraph's MCP wiring across agents. It does NOT npm-uninstall the codegraph package (matching how remove leaves the codev-ai package). It's best-effort via a new `"warning"` `StepStatus`: if the codegraph package was already removed the command errors (ENOENT), and the step is a ▲ warning that's excluded from `anyFailed` — so the remove still succeeds. `RemoveApp` renders warning steps in both the success and failure views. +3. **Removal.** `codevhub remove` (`src/lib/remove.ts#runRemove`) runs `runCodegraphUninstall` (`codegraph uninstall --location global --yes`) before the config restores, to revert CodeGraph's MCP wiring across agents, then sweeps CoDev Code's `mcp.codegraph` entry directly via `unwireCodevCodeMcp()` (an older codegraph doesn't know the custom target, and the entry may be shim-written; the sweep no-ops when a custom-target-aware uninstall already removed it, and drops an emptied `mcp` wrapper). It does NOT npm-uninstall the codegraph package (matching how remove leaves the codev-ai package). It's best-effort via a new `"warning"` `StepStatus`: if the codegraph package was already removed the command errors (ENOENT), and the step is a ▲ warning that's excluded from `anyFailed` — so the remove still succeeds. `RemoveApp` renders warning steps in both the success and failure views. Spawn/exec are routed through stubbable indirections for tests: `codegraphRunner.spawn` (passthrough) and `lib/npm.ts#execAsync` (install). The Install/Config integration tests spy on both `ensureCodegraphInstalled` and `setupCodegraph` so neither the Install step nor finalize shells out. diff --git a/src/SetupApp.tsx b/src/SetupApp.tsx index 6849ef6..8576ad6 100644 --- a/src/SetupApp.tsx +++ b/src/SetupApp.tsx @@ -44,6 +44,7 @@ import { smokeTestModel, validateApiKey } from "@/lib/backend.js"; import { CODEGRAPH_TASK_KEY, type CodegraphSetupResult, + codegraphEligible, codegraphTargets, formatCodegraphTargets, setupCodegraph, @@ -320,11 +321,12 @@ export function SetupApp({ mode }: SetupAppProps) { } // Config mode skips the *agent* install (they're treated as already // installed, so the survivor set equals `tools`), but still installs - // CodeGraph. When any selected agent maps to a CodeGraph target, show - // the CodeGraph-only Install step right after login; otherwise run the - // post-login side-effects directly. authData is passed explicitly - // because the setAuth above hasn't flushed to state yet this tick. - if (codegraphTargets(tools).length > 0) { + // CodeGraph. When any selected agent is CodeGraph-eligible (built-in + // target or CoDev Code), show the CodeGraph-only Install step right + // after login; otherwise run the post-login side-effects directly. + // authData is passed explicitly because the setAuth above hasn't + // flushed to state yet this tick. + if (codegraphEligible(tools)) { setStep("installing"); } else { runPostInstallSideEffects(tools, authData); @@ -615,7 +617,7 @@ export function SetupApp({ mode }: SetupAppProps) { )} {(mode === "install" || - (mode === "config" && codegraphTargets(tools).length > 0)) && + (mode === "config" && codegraphEligible(tools))) && POST_LOGIN.includes(step) && ( ))} {(step === "finalizing" || step === "done") && - codegraphTargets(installedTools).length > 0 && + codegraphEligible(installedTools) && codegraphResult?.status !== "skipped" && ( 0) { + // When any selected agent is CodeGraph-eligible (maps to a built-in target, + // or is CoDev Code, wired via custom target / config shim), install + // CodeGraph alongside the agents (it runs in parallel with them). + // Best-effort: a failure is a soft ▲ warning, never a hard ✗ — CodeGraph is + // an enhancement and must never drop an agent or trip the all-failed gate. + // SetupApp splits CODEGRAPH_TASK_KEY out of the survivor set (it isn't a + // Tool). + if (codegraphEligible(tools)) { tasks.push({ key: CODEGRAPH_TASK_KEY, label: CODEGRAPH_PKG, diff --git a/src/lib/codegraph.ts b/src/lib/codegraph.ts index a16d582..e957b1a 100644 --- a/src/lib/codegraph.ts +++ b/src/lib/codegraph.ts @@ -1,6 +1,13 @@ import { spawn as nodeSpawn } from "node:child_process"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { constants } from "node:os"; -import type { Tool } from "@/lib/configure.js"; +import { dirname } from "node:path"; +import { applyEdits, modify, type ParseError, parse } from "jsonc-parser"; +import { + codevCodeConfigPath, + OPENCODE_SCHEMA_URL, + type Tool, +} from "@/lib/configure.js"; import { logError, logInfo, logWarn } from "@/lib/log.js"; import { execAsync, isPackageInstalledGlobally } from "@/lib/npm.js"; import { formatToolList } from "@/lib/text.js"; @@ -11,8 +18,11 @@ export const CODEGRAPH_BIN = "codegraph"; // CodeGraph's installer targets that overlap with CoDev's tools. CodeGraph // also supports cursor/gemini/kiro/etc., but CoDev only installs these three -// CLI agents, so they're the only targets we ever forward. -export type CodegraphTarget = "claude" | "codex" | "opencode"; +// CLI agents, so they're the only built-in targets we ever forward. `codev` is +// not a built-in: it's the custom target CoDev registers itself (see +// CODEV_TARGET_SPEC) on codegraph versions that support `targets add`, and it +// only ever enters an install CSV through setupCodegraph's capability probe. +export type CodegraphTarget = "claude" | "codex" | "opencode" | "codev"; // TaskList row key for the CodeGraph install that runs in the "Installing // packages" step. Deliberately not a valid `Tool` so SetupApp can split it out @@ -36,9 +46,12 @@ export function toolToCodegraphTarget(tool: Tool): CodegraphTarget | null { return "codex"; case "opencode": return "opencode"; - // The codegraph CLI doesn't know the codev-code fork (its `opencode` - // target writes MCP wiring into ~/.config/opencode/opencode.json, not the - // fork's ~/.config/codev/codev.json). No target until codegraph learns it. + // The codegraph CLI has no *built-in* target for the codev-code fork (its + // `opencode` target writes ~/.config/opencode/opencode.json, not the + // fork's ~/.config/codev/codev.json). Deliberately null here: whether the + // fork can be wired through codegraph depends on the installed binary + // (custom-target support, upstream PR #1459), which a static map can't + // know. setupCodegraph special-cases codev-code at runtime instead. case "codev-code": return null; case "vscode-continue": @@ -47,6 +60,17 @@ export function toolToCodegraphTarget(tool: Tool): CodegraphTarget | null { } } +// Does this selection need the CodeGraph npm package at all? True when any +// tool maps to a built-in target, or when CoDev Code is selected — its wiring +// (custom target or config shim, see setupCodegraph) references the global +// `codegraph` binary either way. This — not `codegraphTargets(...).length` — +// is the predicate for the Install row, config mode's CodeGraph step, and the +// finalize frame; gating on targets alone would leave a codev-code-only +// selection with an MCP entry pointing at a binary that was never installed. +export function codegraphEligible(tools: Tool[]): boolean { + return codegraphTargets(tools).length > 0 || tools.includes("codev-code"); +} + // Dedupe-preserving-order list of CodeGraph targets for a tool selection. // Selecting both `claude-code` and `vscode-claude-code` collapses to a single // `claude` target. @@ -70,6 +94,7 @@ const CODEGRAPH_TARGET_LABEL: Record = { claude: "Claude Code", codex: "Codex", opencode: "OpenCode", + codev: "CoDev Code", }; // Format a list of CodeGraph targets as a natural-English sentence fragment @@ -134,6 +159,190 @@ export async function runCodegraphUninstall(): Promise { return r.stderr.trim() || r.error.message; } +// --- CoDev Code wiring ------------------------------------------------------- +// +// codegraph has no built-in target for the codev-code fork. Upstream PR #1459 +// adds user-defined custom targets (`codegraph targets add `), with the +// fork as its motivating example. Until that ships everywhere, CoDev wires the +// fork itself. Two paths, chosen per run by probing the installed binary: +// +// Path A (custom target): register CODEV_TARGET_SPEC via `targets add`, then +// include `codev` in the one `codegraph install` CSV. +// Path B (config shim): write the `mcp.codegraph` entry into +// ~/.config/codev/codev.json(c) directly. +// +// Both paths produce byte-identical config — the same entry, in the same file +// (codevCodeConfigPath) — so a machine can flip between them run-to-run (e.g. +// the day an npm-installed codegraph gains custom-target support) with the +// other path seeing the entry as already correct. Once the released codegraph +// floor supports custom targets, Path B and the probe can simply be deleted. + +export const CODEV_TARGET_ID = "codev"; + +// The spec `codegraph targets add` validates and persists (~/.codegraph/ +// targets.json). Field-for-field the `codev` example in PR #1459's design doc: +// the `opencode` family derives every path from appName (~/.config/codev/ +// codev.jsonc-or-.json), matching where the fork actually reads. +export const CODEV_TARGET_SPEC = JSON.stringify({ + id: CODEV_TARGET_ID, + displayName: "CoDev Code", + family: "opencode", + appName: "codev", + schemaUrl: OPENCODE_SCHEMA_URL, +}); + +// The MCP server entry CoDev Code needs — identical to what codegraph's +// opencode family writes, which is what makes Path A and Path B +// interchangeable on disk. `command` is argv-style (the OpenCode-family +// shape): binary and args in one array, no separate `args` key. +const CODEV_MCP_ENTRY = { + type: "local", + command: [CODEGRAPH_BIN, "serve", "--mcp"], + enabled: true, +}; + +// Does the installed codegraph support user-defined custom targets? Probed on +// every setup rather than version-compared: codev-hub npm-installs the latest +// codegraph right before wiring, so the capability appears the moment upstream +// releases it — no codev-hub change, no pinning. `targets list` is read-only; +// an older binary rejects the unknown command and we fall back to the shim. +export async function supportsCustomTargets(): Promise { + const r = await execAsync(CODEGRAPH_BIN, ["targets", "list"]); + return !r.error; +} + +// Register (idempotent upsert) the CoDev Code custom target. Returns an error +// string on failure, or null on success. +export async function registerCodevTarget(): Promise { + const r = await execAsync(CODEGRAPH_BIN, [ + "targets", + "add", + CODEV_TARGET_SPEC, + ]); + if (!r.error) return null; + return r.stderr.trim() || r.error.message; +} + +// jsonc-parser options shared by the shim writer and unwirer. Surgical +// modify/applyEdits editing — never a whole-file rewrite — so the user's +// comments, formatting, and sibling MCP servers survive, mirroring both +// codegraph's own installer and lib/vscode-settings.ts. +const JSONC_FORMATTING = { insertSpaces: true, tabSize: 2 } as const; +const JSONC_PARSE_OPTIONS = { + allowTrailingComma: true, + allowEmptyContent: true, +} as const; + +interface JsoncFile { + text: string; + // undefined for an empty/comments-only file; null/primitives are errors. + root: Record | undefined; +} + +// Parse a JSONC config we intend to edit. Returns an error string when the +// content can't be edited safely (syntax errors, non-object root) — the shim +// never risks corrupting a file it doesn't fully understand. +function readJsoncObject(path: string, text: string): JsoncFile | string { + const errors: ParseError[] = []; + const parsed: unknown = parse(text, errors, JSONC_PARSE_OPTIONS); + if (errors.length > 0) return `${path} has JSON syntax errors`; + if ( + parsed !== undefined && + (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) + ) { + return `${path} root is not a JSON object`; + } + return { text, root: parsed as Record | undefined }; +} + +function mcpEntryOf(root: Record | undefined): unknown { + const mcp = root?.mcp; + if (!mcp || typeof mcp !== "object" || Array.isArray(mcp)) return undefined; + return (mcp as Record).codegraph; +} + +// Path B: wire the CodeGraph MCP server into CoDev Code's global config by +// editing the file directly. Idempotent: an already-correct entry writes +// nothing, so a config Path A (or the user) wrote is left byte-identical. +// Returns an error string on failure, or null on success. +export function wireCodevCodeMcp(): string | null { + try { + const path = codevCodeConfigPath(); + let text = existsSync(path) ? readFileSync(path, "utf-8") : ""; + // Greenfield (or empty file): seed the $schema stub the fork itself + // writes on first run, then let modify() insert the entry below. + if (text.trim() === "") { + text = `${JSON.stringify({ $schema: OPENCODE_SCHEMA_URL }, null, 2)}\n`; + } + const file = readJsoncObject(path, text); + if (typeof file === "string") return file; + + if ( + JSON.stringify(mcpEntryOf(file.root)) === JSON.stringify(CODEV_MCP_ENTRY) + ) { + return null; + } + const edited = applyEdits( + text, + modify(text, ["mcp", "codegraph"], CODEV_MCP_ENTRY, { + formattingOptions: JSONC_FORMATTING, + }), + ); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, edited); + logInfo("wired CodeGraph MCP entry into CoDev Code config", { + action: "configure.write", + extra: { path }, + }); + return null; + } catch (err) { + return err instanceof Error ? err.message : String(err); + } +} + +// Inverse of wireCodevCodeMcp, used by `codevhub remove`. No-op when the file +// or entry is absent — including when a custom-target-aware `codegraph +// uninstall` already swept it. Drops an emptied `mcp` wrapper, mirroring +// codegraph's own uninstall. Returns an error string on failure, or null. +export function unwireCodevCodeMcp(): string | null { + try { + const path = codevCodeConfigPath(); + if (!existsSync(path)) return null; + const file = readJsoncObject(path, readFileSync(path, "utf-8")); + if (typeof file === "string") return file; + if (mcpEntryOf(file.root) === undefined) return null; + + let edited = applyEdits( + file.text, + modify(file.text, ["mcp", "codegraph"], undefined, { + formattingOptions: JSONC_FORMATTING, + }), + ); + const after = readJsoncObject(path, edited); + if ( + typeof after !== "string" && + after.root?.mcp && + typeof after.root.mcp === "object" && + Object.keys(after.root.mcp).length === 0 + ) { + edited = applyEdits( + edited, + modify(edited, ["mcp"], undefined, { + formattingOptions: JSONC_FORMATTING, + }), + ); + } + writeFileSync(path, edited); + logInfo("removed CodeGraph MCP entry from CoDev Code config", { + action: "configure.write", + extra: { path }, + }); + return null; + } catch (err) { + return err instanceof Error ? err.message : String(err); + } +} + export type CodegraphSetupStatus = "skipped" | "ok" | "warning"; export interface CodegraphSetupResult { @@ -146,26 +355,56 @@ export interface CodegraphSetupResult { // Best-effort CodeGraph MCP wiring for a tool selection. Assumes CodeGraph is // already installed — the global `npm i -g` happens earlier (the // "Installing packages" step in install mode, or right after login in config -// mode), so this only runs `codegraph install` to wire the MCP server into each -// agent. Never throws: any failure (including the binary being absent because -// the earlier install didn't land) folds into a `warning` result so the -// finalize step can surface it without aborting CoDev's own flow. `skipped` -// means the selection had no CodeGraph-eligible tools (e.g. Continue only). +// mode), so this only wires the MCP server into each agent: built-in targets +// via one `codegraph install`, CoDev Code via the custom target when the +// binary supports it (Path A) or the config shim otherwise (Path B). Never +// throws: any failure (including the binary being absent because the earlier +// install didn't land) folds into a `warning` result so the finalize step can +// surface it without aborting CoDev's own flow. `skipped` means the selection +// had no CodeGraph-eligible tools (e.g. Continue only). The returned `targets` +// list is what the success message names, so `codev` joins it on either path. export async function setupCodegraph( tools: Tool[], ): Promise { const targets = codegraphTargets(tools); - if (targets.length === 0) return { status: "skipped", targets }; - - const configErr = await runCodegraphInstall(targets); - if (configErr) { - return { - status: "warning", - targets, - message: `CodeGraph install failed: ${configErr}`, - }; + const wantsCodevCode = tools.includes("codev-code"); + if (targets.length === 0 && !wantsCodevCode) { + return { status: "skipped", targets }; + } + + const problems: string[] = []; + let shimNeeded = wantsCodevCode; + + if (wantsCodevCode && (await supportsCustomTargets())) { + const regErr = await registerCodevTarget(); + if (regErr) { + // Not fatal: the shim writes the same entry to the same file, so fall + // back rather than surface a warning for a path the user never chose. + logWarn(`codegraph targets add failed; falling back to shim: ${regErr}`, { + action: "task.result", + outcome: "failure", + }); + } else { + targets.push(CODEV_TARGET_ID); + shimNeeded = false; + } + } + + const installErr = await runCodegraphInstall(targets); + if (installErr) problems.push(`CodeGraph install failed: ${installErr}`); + + if (shimNeeded) { + const shimErr = wireCodevCodeMcp(); + if (shimErr) { + problems.push(`CoDev Code MCP wiring failed: ${shimErr}`); + } else { + targets.push(CODEV_TARGET_ID); + } } + if (problems.length > 0) { + return { status: "warning", targets, message: problems.join("; ") }; + } return { status: "ok", targets }; } diff --git a/src/lib/configure.ts b/src/lib/configure.ts index 8cb3b86..2d8edec 100644 --- a/src/lib/configure.ts +++ b/src/lib/configure.ts @@ -125,11 +125,15 @@ const CODEX_K = { bearerToken: atob("ZXhwZXJpbWVudGFsX2JlYXJlcl90b2tlbg=="), }; -const OPENCODE_SCHEMA_URL = atob( +// Exported for lib/codegraph.ts: the fork keeps upstream's schema URL (its +// DIVERGENCES list marks it NOT renamed), and the CoDev Code MCP shim seeds +// the same $schema stub the agent itself writes on first run. +export const OPENCODE_SCHEMA_URL = atob( "aHR0cHM6Ly9vcGVuY29kZS5haS9jb25maWcuanNvbg==", ); const OPENCODE_K = { schema: atob("JHNjaGVtYQ=="), + mcp: atob("bWNw"), model: atob("bW9kZWw="), provider: atob("cHJvdmlkZXI="), providerKey: atob("YWlnYXRld2F5"), @@ -340,6 +344,14 @@ function sourcePathOf(kind: BackupKind): string { } } +// The one config file CoDev Code reads that CoDev also writes. Exported for +// lib/codegraph.ts's MCP shim and lib/remove.ts's unwire, so all three writers +// resolve the same `.jsonc`-vs-`.json` candidate (see openCodeConfigPath) and +// never shadow each other. +export function codevCodeConfigPath(): string { + return sourcePathOf("codev-code-config"); +} + function statusFor(kind: BackupKind): BackupStatus { const sourcePath = sourcePathOf(kind); const backupPath = `${sourcePath}.backup`; @@ -828,6 +840,23 @@ export function configureCodevCode(creds: Credentials): ConfigureResult[] { return configureOpenCodeKind("codev-code-config", creds); } +// Read the top-level `mcp` map from an existing OpenCode-family config, or +// undefined when the file is absent, unparseable, or has no object-valued +// `mcp`. Best-effort by design: this writer has always recovered from corrupt +// configs by replacing them, and preservation must never change that. +function readPreservedMcp(path: string): Record | undefined { + if (!existsSync(path)) return undefined; + try { + const raw = parseJsonc(readFileSync(path, "utf-8")); + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined; + const mcp = (raw as Record)[OPENCODE_K.mcp]; + if (!mcp || typeof mcp !== "object" || Array.isArray(mcp)) return undefined; + return mcp as Record; + } catch { + return undefined; + } +} + function configureOpenCodeKind( kind: "opencode-config" | "codev-code-config", creds: Credentials, @@ -836,6 +865,13 @@ function configureOpenCodeKind( const sourcePath = sourcePathOf(kind); mkdirSync(dirname(sourcePath), { recursive: true }); + // Carry the `mcp` map across the rewrite. This writer doesn't just run at + // install time: every gateway-key auto-refresh (refresh.ts) and model + // switch rewrites the whole file, and dropping `mcp` there would silently + // unwire MCP servers — CodeGraph's entry, or servers the user added — that + // were wired after configure last ran. + const mcp = readPreservedMcp(sourcePath); + const baseUrl = creds.baseUrl ? normalizeOpenCodeBaseUrl(creds.baseUrl) : AI_GATEWAY_OPENAI_URL(); @@ -865,6 +901,7 @@ function configureOpenCodeKind( writeJson(sourcePath, { [OPENCODE_K.schema]: OPENCODE_SCHEMA_URL, + ...(mcp !== undefined ? { [OPENCODE_K.mcp]: mcp } : {}), // Top-level `model` pins the initial active model OpenCode uses on // launch. Format is `/` per OpenCode's schema. [OPENCODE_K.model]: `${OPENCODE_K.providerKey}/${defaultModel}`, diff --git a/src/lib/remove.ts b/src/lib/remove.ts index 395de05..fffe637 100644 --- a/src/lib/remove.ts +++ b/src/lib/remove.ts @@ -2,7 +2,7 @@ import { existsSync, rmSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; import { logout } from "@/lib/auth.js"; -import { runCodegraphUninstall } from "@/lib/codegraph.js"; +import { runCodegraphUninstall, unwireCodevCodeMcp } from "@/lib/codegraph.js"; import { restoreTool, type Tool } from "@/lib/configure.js"; import { logError, logInfo, logWarn } from "@/lib/log.js"; import { uninstallShims } from "@/lib/shims.js"; @@ -105,17 +105,30 @@ function recordStep(step: StepResult): StepResult { // Best-effort: revert CodeGraph's MCP wiring across all agents. If the // codegraph package was already removed, the command errors (e.g. ENOENT) — we -// surface a ▲ warning and continue rather than fail the remove. +// surface a ▲ warning and continue rather than fail the remove. CoDev Code's +// entry gets a direct sweep on top of the CLI uninstall: an older codegraph +// doesn't know the custom target, and the entry may have been written by the +// config shim (see lib/codegraph.ts) — either way `codegraph uninstall` would +// leave it behind. The sweep is a no-op when the entry is already gone, +// including when a custom-target-aware uninstall just removed it. async function runCodegraphRemoval(): Promise { try { const err = await runCodegraphUninstall(); - if (!err) { + const codevErr = unwireCodevCodeMcp(); + if (!err && !codevErr) { return { label: "CodeGraph", detail: "removed from agents", status: "ok", }; } + if (!err) { + return { + label: "CodeGraph", + detail: `removed from agents; CoDev Code entry left in place: ${codevErr}`, + status: "warning", + }; + } return { label: "CodeGraph", detail: `CodeGraph not available — skipped: ${err}`, diff --git a/tests/lib/codegraph.test.ts b/tests/lib/codegraph.test.ts index aac08d9..418ca87 100644 --- a/tests/lib/codegraph.test.ts +++ b/tests/lib/codegraph.test.ts @@ -1,23 +1,46 @@ import type { ChildProcess } from "node:child_process"; import { EventEmitter } from "node:events"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import type { MockInstance } from "vitest"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { + CODEV_TARGET_SPEC, type CodegraphTarget, + codegraphEligible, codegraphRunner, codegraphTargets, detectCodegraphInstalled, ensureCodegraphInstalled, formatCodegraphTargets, forwardToCodegraph, + registerCodevTarget, runCodegraphInstall, runCodegraphUninstall, setupCodegraph, + supportsCustomTargets, toolToCodegraphTarget, + unwireCodevCodeMcp, + wireCodevCodeMcp, } from "@/lib/codegraph.js"; import type { Tool } from "@/lib/configure.js"; import * as npm from "@/lib/npm.js"; +// The entry both wiring paths produce — mirrors codegraph's opencode family. +const MCP_ENTRY = { + type: "local", + command: ["codegraph", "serve", "--mcp"], + enabled: true, +}; + describe("toolToCodegraphTarget", () => { test("maps the three CLI agents to their CodeGraph target", () => { expect(toolToCodegraphTarget("claude-code")).toBe("claude"); @@ -36,6 +59,25 @@ describe("toolToCodegraphTarget", () => { }); }); +describe("codegraphEligible", () => { + test("true when a tool maps to a built-in target", () => { + expect(codegraphEligible(["codex"])).toBe(true); + expect(codegraphEligible(["vscode-claude-code"])).toBe(true); + }); + + test("true for a codev-code-only selection (custom target / shim path)", () => { + expect(codegraphEligible(["codev-code"])).toBe(true); + expect(codegraphEligible(["codev-code", "vscode-continue"])).toBe(true); + }); + + test("false for Continue-only selections", () => { + expect(codegraphEligible(["vscode-continue", "jetbrains-continue"])).toBe( + false, + ); + expect(codegraphEligible([])).toBe(false); + }); +}); + describe("codegraphTargets", () => { test("dedupes claude-code + vscode-claude-code into a single `claude`", () => { expect(codegraphTargets(["claude-code", "vscode-claude-code"])).toEqual([ @@ -66,6 +108,7 @@ describe("formatCodegraphTargets", () => { expect(formatCodegraphTargets(["claude"])).toBe("Claude Code"); expect(formatCodegraphTargets(["codex"])).toBe("Codex"); expect(formatCodegraphTargets(["opencode"])).toBe("OpenCode"); + expect(formatCodegraphTargets(["codev"])).toBe("CoDev Code"); }); test("joins two targets with 'and' (no comma)", () => { @@ -215,6 +258,168 @@ describe("detectCodegraphInstalled", () => { }); }); +describe("supportsCustomTargets", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("probes with the read-only `targets list` and reports support", async () => { + const execSpy = vi + .spyOn(npm, "execAsync") + .mockResolvedValue({ stdout: "codev", stderr: "", error: null }); + expect(await supportsCustomTargets()).toBe(true); + expect(execSpy).toHaveBeenCalledWith("codegraph", ["targets", "list"]); + }); + + test("reports no support when the binary rejects the command", async () => { + vi.spyOn(npm, "execAsync").mockResolvedValue({ + stdout: "", + stderr: "error: unknown command 'targets'", + error: new Error("exit 1"), + }); + expect(await supportsCustomTargets()).toBe(false); + }); +}); + +describe("registerCodevTarget", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("upserts the CoDev Code spec via `targets add`", async () => { + const execSpy = vi + .spyOn(npm, "execAsync") + .mockResolvedValue({ stdout: "", stderr: "", error: null }); + expect(await registerCodevTarget()).toBeNull(); + expect(execSpy).toHaveBeenCalledWith("codegraph", [ + "targets", + "add", + CODEV_TARGET_SPEC, + ]); + // The spec must satisfy `targets add` validation: opencode family keyed + // by appName, with the id the install CSV will reference. + expect(JSON.parse(CODEV_TARGET_SPEC)).toMatchObject({ + id: "codev", + family: "opencode", + appName: "codev", + }); + }); + + test("surfaces stderr as the error string on failure", async () => { + vi.spyOn(npm, "execAsync").mockResolvedValue({ + stdout: "", + stderr: "invalid spec", + error: new Error("exit 1"), + }); + expect(await registerCodevTarget()).toBe("invalid spec"); + }); +}); + +describe("wireCodevCodeMcp / unwireCodevCodeMcp", () => { + let tempDir: string; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "codev-codegraph-test-")); + vi.stubEnv("HOME", tempDir); + vi.stubEnv("USERPROFILE", tempDir); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + rmSync(tempDir, { recursive: true, force: true }); + }); + + const jsonPath = () => join(tempDir, ".config", "codev", "codev.json"); + const jsoncPath = () => join(tempDir, ".config", "codev", "codev.jsonc"); + + test("creates codev.json with $schema and the mcp entry from scratch", () => { + expect(wireCodevCodeMcp()).toBeNull(); + const config = JSON.parse(readFileSync(jsonPath(), "utf-8")); + expect(config.$schema).toBe("https://opencode.ai/config.json"); + expect(config.mcp.codegraph).toEqual(MCP_ENTRY); + }); + + test("adds the entry to an existing jsonc, preserving comments and sibling servers", () => { + mkdirSync(join(tempDir, ".config", "codev"), { recursive: true }); + writeFileSync( + jsoncPath(), + `{ + // the user's own server + "mcp": { + "mine": { "type": "local", "command": ["mine"], "enabled": true } + } +} +`, + ); + expect(wireCodevCodeMcp()).toBeNull(); + // An existing .jsonc is the fork's preferred read target — the shim must + // edit it, not shadow it with a fresh .json. + expect(existsSync(jsonPath())).toBe(false); + const text = readFileSync(jsoncPath(), "utf-8"); + // The comment and the user's own server both survive the surgical edit. + expect(text).toContain("// the user's own server"); + expect(text).toContain('"mine"'); + expect(text).toContain('"codegraph"'); + }); + + test("is idempotent — an already-correct entry leaves the file byte-identical", () => { + expect(wireCodevCodeMcp()).toBeNull(); + const before = readFileSync(jsonPath(), "utf-8"); + expect(wireCodevCodeMcp()).toBeNull(); + expect(readFileSync(jsonPath(), "utf-8")).toBe(before); + }); + + test("refuses to edit a file with syntax errors and reports it", () => { + mkdirSync(join(tempDir, ".config", "codev"), { recursive: true }); + writeFileSync(jsonPath(), "{ definitely not json"); + const err = wireCodevCodeMcp(); + expect(err).toContain("syntax errors"); + expect(readFileSync(jsonPath(), "utf-8")).toBe("{ definitely not json"); + }); + + test("unwire removes the entry and drops an emptied mcp wrapper", () => { + expect(wireCodevCodeMcp()).toBeNull(); + expect(unwireCodevCodeMcp()).toBeNull(); + const config = JSON.parse(readFileSync(jsonPath(), "utf-8")); + expect(config.mcp).toBeUndefined(); + // The rest of the file survives. + expect(config.$schema).toBe("https://opencode.ai/config.json"); + }); + + test("unwire preserves sibling mcp servers", () => { + mkdirSync(join(tempDir, ".config", "codev"), { recursive: true }); + writeFileSync( + jsonPath(), + JSON.stringify( + { + mcp: { + codegraph: MCP_ENTRY, + mine: { type: "local", command: ["mine"], enabled: true }, + }, + }, + null, + 2, + ), + ); + expect(unwireCodevCodeMcp()).toBeNull(); + const config = JSON.parse(readFileSync(jsonPath(), "utf-8")); + expect(config.mcp.codegraph).toBeUndefined(); + expect(config.mcp.mine.command).toEqual(["mine"]); + }); + + test("unwire no-ops when the file or the entry is absent", () => { + // No file at all. + expect(unwireCodevCodeMcp()).toBeNull(); + expect(existsSync(jsonPath())).toBe(false); + // File without the entry: left byte-identical. + mkdirSync(join(tempDir, ".config", "codev"), { recursive: true }); + const body = JSON.stringify({ theme: "dark" }, null, 2); + writeFileSync(jsonPath(), body); + expect(unwireCodevCodeMcp()).toBeNull(); + expect(readFileSync(jsonPath(), "utf-8")).toBe(body); + }); +}); + describe("setupCodegraph", () => { let execSpy: MockInstance; @@ -263,6 +468,122 @@ describe("setupCodegraph", () => { expect(result.status).toBe("warning"); expect(result.message).toContain("install exploded"); }); + + test("never probes for custom targets when codev-code isn't selected", async () => { + await setupCodegraph(["claude-code", "codex"]); + const probeCalls = execSpy.mock.calls.filter( + (c) => (c[1] as string[])[0] === "targets", + ); + expect(probeCalls).toEqual([]); + }); +}); + +// The codev-code wiring inside setupCodegraph touches the real filesystem on +// the shim path, so these run against a stubbed HOME like the wire/unwire +// suite above. +describe("setupCodegraph with codev-code", () => { + let tempDir: string; + let execSpy: MockInstance; + + // Route execAsync by subcommand: `targets list` (the probe) and `targets + // add` (registration) get configurable results; everything else (the + // install) succeeds and is captured for CSV assertions. + function routeExec(opts: { probeOk: boolean; addOk?: boolean }) { + execSpy.mockImplementation(async (_cmd: string, args: string[]) => { + const ok = { stdout: "", stderr: "", error: null }; + const fail = (msg: string) => ({ + stdout: "", + stderr: msg, + error: new Error("exit 1"), + }); + if (args[0] === "targets" && args[1] === "list") { + return opts.probeOk ? ok : fail("unknown command 'targets'"); + } + if (args[0] === "targets" && args[1] === "add") { + return (opts.addOk ?? true) ? ok : fail("spec rejected"); + } + return ok; + }); + } + + function installCsv(): string | undefined { + const call = execSpy.mock.calls.find( + (c) => (c[1] as string[])[0] === "install", + ); + return call ? (call[1] as string[])[2] : undefined; + } + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "codev-codegraph-setup-test-")); + vi.stubEnv("HOME", tempDir); + vi.stubEnv("USERPROFILE", tempDir); + execSpy = vi.spyOn(npm, "execAsync"); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + rmSync(tempDir, { recursive: true, force: true }); + }); + + const jsonPath = () => join(tempDir, ".config", "codev", "codev.json"); + + test("Path A: capable binary ⇒ registers the custom target, `codev` joins the CSV, no direct write", async () => { + routeExec({ probeOk: true }); + const result = await setupCodegraph(["claude-code", "codev-code"]); + expect(result.status).toBe("ok"); + expect(result.targets).toEqual(["claude", "codev"]); + expect(execSpy).toHaveBeenCalledWith("codegraph", [ + "targets", + "add", + CODEV_TARGET_SPEC, + ]); + expect(installCsv()).toBe("claude,codev"); + // codegraph owns the write on this path — the shim must not run. + expect(existsSync(jsonPath())).toBe(false); + }); + + test("Path B: older binary ⇒ CSV stays built-in-only and the shim writes the entry", async () => { + routeExec({ probeOk: false }); + const result = await setupCodegraph(["claude-code", "codev-code"]); + expect(result.status).toBe("ok"); + expect(result.targets).toEqual(["claude", "codev"]); + expect(installCsv()).toBe("claude"); + const config = JSON.parse(readFileSync(jsonPath(), "utf-8")); + expect(config.mcp.codegraph).toEqual(MCP_ENTRY); + }); + + test("Path B with a codev-code-only selection: no install call at all, entry still written", async () => { + routeExec({ probeOk: false }); + const result = await setupCodegraph(["codev-code"]); + expect(result.status).toBe("ok"); + expect(result.targets).toEqual(["codev"]); + expect(installCsv()).toBeUndefined(); + const config = JSON.parse(readFileSync(jsonPath(), "utf-8")); + expect(config.mcp.codegraph).toEqual(MCP_ENTRY); + }); + + test("falls back to the shim when registration fails on a capable binary", async () => { + routeExec({ probeOk: true, addOk: false }); + const result = await setupCodegraph(["codev-code"]); + expect(result.status).toBe("ok"); + expect(result.targets).toEqual(["codev"]); + expect(installCsv()).toBeUndefined(); + const config = JSON.parse(readFileSync(jsonPath(), "utf-8")); + expect(config.mcp.codegraph).toEqual(MCP_ENTRY); + }); + + test("a failing shim write folds into a warning without dropping built-in wiring", async () => { + routeExec({ probeOk: false }); + // An unparseable existing config makes the shim refuse to edit. + mkdirSync(join(tempDir, ".config", "codev"), { recursive: true }); + writeFileSync(jsonPath(), "{ definitely not json"); + const result = await setupCodegraph(["claude-code", "codev-code"]); + expect(result.status).toBe("warning"); + expect(result.message).toContain("CoDev Code MCP wiring failed"); + expect(result.targets).toEqual(["claude"]); + expect(installCsv()).toBe("claude"); + }); }); describe("forwardToCodegraph", () => { diff --git a/tests/lib/configure.test.ts b/tests/lib/configure.test.ts index 595e96e..7e7be35 100644 --- a/tests/lib/configure.test.ts +++ b/tests/lib/configure.test.ts @@ -490,6 +490,54 @@ describe("configureOpenCode", () => { expect(config.provider.aigateway.options.apiKey).toBe("sk-new"); }); + test("carries the `mcp` map across a rewrite (CodeGraph wiring survives reconfigure)", async () => { + const dir = join(tempDir, ".config", "opencode"); + const filePath = join(dir, "opencode.json"); + mkdirSync(dir, { recursive: true }); + // A config CoDev wrote earlier, since wired with MCP servers (CodeGraph's + // entry plus one of the user's own) — the state every gateway-key + // auto-refresh and model switch rewrites. + writeFileSync( + filePath, + JSON.stringify({ + provider: { aigateway: { options: { apiKey: "sk-old" } } }, + mcp: { + codegraph: { + type: "local", + command: ["codegraph", "serve", "--mcp"], + enabled: true, + }, + mine: { type: "local", command: ["mine"], enabled: true }, + }, + }), + ); + + const { configureOpenCode } = await import("@/lib/configure.js"); + configureOpenCode({ apiKey: "sk-new", model: "m" }); + + const config = JSON.parse(readFileSync(filePath, "utf-8")); + expect(config.mcp.codegraph.command).toEqual([ + "codegraph", + "serve", + "--mcp", + ]); + expect(config.mcp.mine.command).toEqual(["mine"]); + expect(config.provider.aigateway.options.apiKey).toBe("sk-new"); + }); + + test("does not carry a non-object `mcp` value across a rewrite", async () => { + const dir = join(tempDir, ".config", "opencode"); + const filePath = join(dir, "opencode.json"); + mkdirSync(dir, { recursive: true }); + writeFileSync(filePath, JSON.stringify({ mcp: "not a server map" })); + + const { configureOpenCode } = await import("@/lib/configure.js"); + configureOpenCode({ apiKey: "sk-new", model: "m" }); + + const config = JSON.parse(readFileSync(filePath, "utf-8")); + expect(config.mcp).toBeUndefined(); + }); + test("preserves a pre-existing opencode.json backup across repeated runs", async () => { const dir = join(tempDir, ".config", "opencode"); const filePath = join(dir, "opencode.json"); @@ -581,6 +629,36 @@ describe("configureCodevCode", () => { expect(config.provider.aigateway.options.apiKey).toBe("sk-new"); }); + test("carries the `mcp` map across a rewrite (CodeGraph wiring survives reconfigure)", async () => { + const dir = join(tempDir, ".config", "codev"); + const filePath = join(dir, "codev.json"); + mkdirSync(dir, { recursive: true }); + writeFileSync( + filePath, + JSON.stringify({ + provider: { aigateway: { options: { apiKey: "sk-old" } } }, + mcp: { + codegraph: { + type: "local", + command: ["codegraph", "serve", "--mcp"], + enabled: true, + }, + }, + }), + ); + + const { configureCodevCode } = await import("@/lib/configure.js"); + configureCodevCode({ apiKey: "sk-new", model: "m" }); + + const config = JSON.parse(readFileSync(filePath, "utf-8")); + expect(config.mcp.codegraph.command).toEqual([ + "codegraph", + "serve", + "--mcp", + ]); + expect(config.provider.aigateway.options.apiKey).toBe("sk-new"); + }); + test("preserves a pre-existing codev.json backup across repeated runs", async () => { const dir = join(tempDir, ".config", "codev"); const filePath = join(dir, "codev.json"); diff --git a/tests/lib/remove.test.ts b/tests/lib/remove.test.ts index eadea11..952ccc1 100644 --- a/tests/lib/remove.test.ts +++ b/tests/lib/remove.test.ts @@ -348,4 +348,40 @@ describe("runRemove", () => { // Non-fatal: the overall remove still succeeds. expect(result.anyFailed).toBe(false); }); + + test("codegraph: sweeps the CoDev Code mcp entry from a user-owned config it keeps", async () => { + stubFetchOk(); + // A codev.json the user wrote themselves (no CoDev marker, no backup), + // wired with the CodeGraph entry plus a server of their own. Remove must + // strip exactly the CodeGraph entry — matching `codegraph uninstall` + // semantics on every other agent's config — while the authorship gate + // keeps the file itself. + const filePath = seedFile( + ".config/codev/codev.json", + JSON.stringify( + { + theme: "dark", + mcp: { + codegraph: { + type: "local", + command: ["codegraph", "serve", "--mcp"], + enabled: true, + }, + mine: { type: "local", command: ["mine"], enabled: true }, + }, + }, + null, + 2, + ), + ); + const result = await runRemove(); + const cg = result.steps.find((s) => s.label === "CodeGraph"); + expect(cg?.status).toBe("ok"); + // The file survives (kept-live) minus the CodeGraph entry. + const config = JSON.parse(readFileSync(filePath, "utf-8")); + expect(config.theme).toBe("dark"); + expect(config.mcp.codegraph).toBeUndefined(); + expect(config.mcp.mine.command).toEqual(["mine"]); + expect(result.keptPaths).toContain(filePath); + }); });