import { spawn, execFile } from "node:child_process"; import { dirname, relative } from "node:path"; /** * Interface to the `codebase-memory-mcp` binary's `cli` subcommand. * * Args are passed as JSON over stdin, which the binary accepts for every tool * without the deprecation warning that raw-JSON positional args emit. Logs go * to stderr; clean JSON comes back on stdout. */ export const BIN = "codebase-memory-mcp"; function runCliOnce(tool: string, args: unknown, signal?: AbortSignal): Promise { return new Promise((resolve, reject) => { const child = spawn(BIN, ["cli", tool], { stdio: ["pipe", "pipe", "pipe"], signal, }); let stdout = ""; let stderr = ""; child.stdout.on("data", (d) => (stdout += d.toString())); child.stderr.on("data", (d) => (stderr += d.toString())); // logs go here child.on("error", reject); child.on("close", (code) => { if (code === 0) { resolve(stdout.trim()); } else { reject(new Error(`${BIN} cli ${tool} exited ${code}: ${stderr.trim() || stdout.trim()}`)); } }); child.stdin.write(JSON.stringify(args ?? {})); child.stdin.end(); }); } /** * Run a tool, retrying once with a cwd-resolved project name when the binary * rejects the given `project`. Indexes are keyed by the canonical checkout's * path, so a name derived inside a Git worktree misses - resolveProject maps * the worktree back to the canonical project instead of forcing a re-index. */ export async function runCli(tool: string, args: unknown, signal?: AbortSignal): Promise { try { return await runCliOnce(tool, args, signal); } catch (e) { const a = args as Record | null; const retriable = a !== null && typeof a === "object" && typeof a.project === "string" && (e as Error).message.includes("project not found or not indexed"); if (!retriable) throw e; const resolved = await resolveProject(process.cwd(), signal).catch(() => null); if (!resolved || resolved === a.project) throw e; return runCliOnce(tool, { ...a, project: resolved }, signal); } } /** * Worktree-aware Git lookup: returns the toplevel of the current checkout and * the canonical root (dirname of the common .git dir). In the main checkout * both are equal; in a linked worktree the canonical root is the main * checkout's path. Returns null outside a Git repo or for bare/odd layouts. */ function gitRoots(cwd: string): Promise<{ toplevel: string; canonical: string } | null> { return new Promise((res) => { execFile( "git", ["rev-parse", "--path-format=absolute", "--show-toplevel", "--git-common-dir"], { cwd }, (err, stdout) => { if (err) return res(null); const [toplevel, commonDir] = stdout.trim().split("\n"); if (!toplevel || !commonDir || !commonDir.endsWith("/.git")) return res(null); res({ toplevel, canonical: dirname(commonDir) }); }, ); }); } /** Longest matching root_path prefix wins (handles subdir projects). */ function matchByPrefix( projects: Array<{ name: string; root_path: string }>, path: string, ): { name: string; root_path: string } | null { let best: { name: string; root_path: string } | null = null; for (const p of projects) { if (p.root_path && (path === p.root_path || path.startsWith(p.root_path + "/"))) { if (!best || p.root_path.length > best.root_path.length) best = p; } } return best; } /** * Resolve the codebase-memory project whose root_path best matches `cwd`. * When `cwd` is inside a linked Git worktree, its path is translated to the * canonical checkout's equivalent path first, so worktrees resolve to the * canonical project instead of requiring their own index. */ export async function resolveProject(cwd: string, signal?: AbortSignal): Promise { const out = await runCliOnce("list_projects", {}, signal); let projects: Array<{ name: string; root_path: string }>; try { projects = JSON.parse(out).projects ?? []; } catch { return null; } const direct = matchByPrefix(projects, cwd); if (direct) return direct.name; // No direct hit - if cwd is in a linked worktree, retry against the // canonical checkout's equivalent path. const roots = await gitRoots(cwd); if (!roots || roots.canonical === roots.toplevel) return null; const rel = relative(roots.toplevel, cwd); const translated = rel && rel !== "." ? `${roots.canonical}/${rel}` : roots.canonical; return matchByPrefix(projects, translated)?.name ?? null; }