Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/windows-codegraph-launch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@vndv/pi-codegraph": patch
---

Resolve the CodeGraph CLI through Windows-native command discovery before launching the MCP server on Windows, while preserving the existing direct `codegraph` spawn path on other platforms.
51 changes: 50 additions & 1 deletion __tests__/codegraph.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { EventEmitter } from "node:events";
import { PassThrough } from "node:stream";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";

vi.mock("node:child_process", () => ({
spawn: vi.fn(() => createMockProcess()),
Expand Down Expand Up @@ -60,12 +61,60 @@ describe("pi-codegraph extension", () => {
await expect(callCodeGraphTool("codegraph_status", {})).resolves.toBe("called codegraph_status");
});

it("uses the direct codegraph executable outside Windows", async () => {
vi.spyOn(process, "platform", "get").mockReturnValue("linux");
const { spawn } = await import("node:child_process");
const { withCodeGraphMcp } = await import("../extensions/codegraph.js");

await withCodeGraphMcp(process.cwd(), undefined, async () => "success");

expect(spawn).toHaveBeenCalledWith("codegraph", ["serve", "--mcp", "--path", process.cwd()], {
cwd: process.cwd(),
env: process.env,
stdio: ["pipe", "pipe", "pipe"],
});
});

it("uses PowerShell command discovery for the CodeGraph executable on Windows", async () => {
vi.spyOn(process, "platform", "get").mockReturnValue("win32");
const { spawn } = await import("node:child_process");
const { withCodeGraphMcp } = await import("../extensions/codegraph.js");

await withCodeGraphMcp(process.cwd(), undefined, async () => "success");

const [command, args, options] = vi.mocked(spawn).mock.calls.at(-1)!;
const spawnArgs = args as string[];
const script = spawnArgs[spawnArgs.indexOf("-Command") + 1];

expect(command).toBe("powershell.exe");
expect(spawnArgs).toEqual(expect.arrayContaining([
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-Command",
]));
expect(script).toContain("Get-Command codegraph");
expect(script).toContain("-CommandType Application");
expect(script).toContain("Select-Object -First 1");
expect(script).not.toContain("codegraph.cmd");
expect(script).not.toMatch(/Users[\\/]cq/i);
expect(script).not.toMatch(/scoop/i);
expect(spawnArgs.at(-1)).toBe(process.cwd());
expect(options).toEqual({
cwd: process.cwd(),
env: process.env,
stdio: ["pipe", "pipe", "pipe"],
windowsHide: true,
});
});

it("validates projectPath before starting CodeGraph", async () => {
const { resolveProjectCwd } = await import("../extensions/codegraph.js");

await expect(resolveProjectCwd("relative/project")).rejects.toThrow("absolute path");
await expect(resolveProjectCwd("/path/that/does/not/exist")).rejects.toThrow("does not exist");
await expect(resolveProjectCwd(new URL(import.meta.url).pathname)).rejects.toThrow("directory");
await expect(resolveProjectCwd(fileURLToPath(import.meta.url))).rejects.toThrow("directory");
});

it("preserves Unix paths on macOS/Linux", async () => {
Expand Down
45 changes: 40 additions & 5 deletions extensions/codegraph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,17 +125,52 @@ export const SessionTimeoutMs = 20_000;

export const codegraphToolNames = ToolDefinitions.map((tool) => tool.name);

const WindowsCodeGraphLaunchScript = [
"& {",
"param([string]$ProjectPath)",
"$ErrorActionPreference = 'Stop';",
"$cmd = Get-Command codegraph -CommandType Application -ErrorAction Stop | Select-Object -First 1;",
"if (-not $cmd) { throw 'codegraph command not found'; }",
"& $cmd.Source serve --mcp --path $ProjectPath;",
"exit $LASTEXITCODE;",
"}",
].join(" ");

function spawnCodeGraphServer(cwd: string): ChildProcessWithoutNullStreams {
if (process.platform !== "win32") {
return spawn("codegraph", ["serve", "--mcp", "--path", cwd], {
cwd,
env: process.env,
stdio: ["pipe", "pipe", "pipe"],
});
}

// On Windows, Node's direct spawn can miss npm/Scoop command shims that the
// shell resolves correctly. Use PowerShell command discovery so global CLI
// installs are found without hardcoding .cmd, Scoop, npm, or user paths.
return spawn("powershell.exe", [
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-Command",
WindowsCodeGraphLaunchScript,
cwd,
], {
cwd,
env: process.env,
stdio: ["pipe", "pipe", "pipe"],
windowsHide: true,
});
}

export async function withCodeGraphMcp<T>(
projectPath: string | undefined,
signal: AbortSignal | undefined,
fn: (request: JsonRpcRequest) => Promise<T>,
): Promise<T> {
const cwd = await resolveProjectCwd(projectPath);
const child = spawn("codegraph", ["serve", "--mcp", "--path", cwd], {
cwd,
env: process.env,
stdio: ["pipe", "pipe", "pipe"],
});
const child = spawnCodeGraphServer(cwd);

const session = runJsonRpcSession(child, cwd, signal, fn);

Expand Down