diff --git a/.changeset/windows-codegraph-launch.md b/.changeset/windows-codegraph-launch.md new file mode 100644 index 0000000..2c7400d --- /dev/null +++ b/.changeset/windows-codegraph-launch.md @@ -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. diff --git a/__tests__/codegraph.test.ts b/__tests__/codegraph.test.ts index 9b010c7..fd3bd60 100644 --- a/__tests__/codegraph.test.ts +++ b/__tests__/codegraph.test.ts @@ -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()), @@ -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 () => { diff --git a/extensions/codegraph.ts b/extensions/codegraph.ts index c988082..27b6c60 100644 --- a/extensions/codegraph.ts +++ b/extensions/codegraph.ts @@ -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( projectPath: string | undefined, signal: AbortSignal | undefined, fn: (request: JsonRpcRequest) => Promise, ): Promise { 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);