diff --git a/src/integrations/claude-code/__tests__/run.spec.ts b/src/integrations/claude-code/__tests__/run.spec.ts index d2fda08fc0..3de3f8c6d1 100644 --- a/src/integrations/claude-code/__tests__/run.spec.ts +++ b/src/integrations/claude-code/__tests__/run.spec.ts @@ -68,6 +68,9 @@ vi.mock("execa", () => ({ // Mock readline with proper interface simulation let mockReadlineInterface: any = null +// Store original platform value +let originalPlatform: string + vi.mock("readline", () => ({ default: { createInterface: vi.fn(() => { @@ -95,10 +98,19 @@ describe("runClaudeCode", () => { callback() return {} as any }) + + // Store original platform and mock it to ensure non-Windows code path is used + originalPlatform = process.platform + Object.defineProperty(process, "platform", { value: "linux" }) }) afterEach(() => { vi.restoreAllMocks() + + // Restore original platform + if (originalPlatform) { + Object.defineProperty(process, "platform", { value: originalPlatform }) + } }) test("should export runClaudeCode function", async () => { diff --git a/src/integrations/claude-code/__tests__/windows-integration.spec.ts b/src/integrations/claude-code/__tests__/windows-integration.spec.ts new file mode 100644 index 0000000000..11df4e03f2 --- /dev/null +++ b/src/integrations/claude-code/__tests__/windows-integration.spec.ts @@ -0,0 +1,301 @@ +import { describe, test, expect, vi, beforeEach, afterEach } from "vitest" +import * as fs from "fs" +import * as path from "path" +import * as os from "os" +import { execa } from "execa" +import readline from "readline" + +// Skip tests on non-Windows platforms +const isWindows = process.platform === "win32" +const describePlatform = isWindows ? describe : describe.skip + +// Mock dependencies +vi.mock("fs") +vi.mock("path") +vi.mock("os") +vi.mock("execa") +vi.mock("readline") + +// Mock vscode workspace +vi.mock("vscode", () => ({ + workspace: { + workspaceFolders: [ + { + uri: { + fsPath: "/test/workspace", + }, + }, + ], + }, +})) + +describePlatform("Windows WSL Integration", () => { + // Store original values + const originalPid = process.pid + const originalDateNow = Date.now + + beforeEach(() => { + vi.clearAllMocks() + vi.resetModules() + + // Setup common mocks + vi.mocked(os.tmpdir).mockReturnValue("C:\\temp") + vi.mocked(path.join).mockImplementation((...args) => args.join("\\")) + vi.mocked(fs.existsSync).mockReturnValue(false) + vi.mocked(fs.mkdirSync).mockImplementation(() => undefined) + + // Mock process.pid using Object.defineProperty + Object.defineProperty(process, "pid", { value: 12345 }) + + // Mock Date.now using vi.spyOn + vi.spyOn(Date, "now").mockImplementation(() => 1000000) + + // Mock readline.createInterface + vi.mocked(readline.createInterface).mockReturnValue({ + [Symbol.asyncIterator]: () => ({ + next: async () => ({ done: true, value: undefined }), + }), + close: vi.fn(), + } as any) + + // Mock execa to return a process-like object + vi.mocked(execa).mockReturnValue({ + stdout: { pipe: vi.fn(), on: vi.fn() }, + stderr: { on: vi.fn() }, + on: vi.fn(), + finally: vi.fn().mockImplementation((fn) => { + fn() // Call the cleanup function immediately for testing + return { on: vi.fn() } + }), + kill: vi.fn(), + killed: false, + exitCode: 0, // Add exitCode property to avoid "process exited with code undefined" error + then: vi.fn().mockImplementation((callback) => { + callback({ exitCode: 0 }) // Mock the Promise resolution + return Promise.resolve({ exitCode: 0 }) + }), + } as any) + }) + + afterEach(() => { + // Restore original values + Object.defineProperty(process, "pid", { value: originalPid }) + vi.restoreAllMocks() // This will restore Date.now and other spies + }) + + test("should use WSL on Windows platform and call execa correctly", async () => { + // Mock process.platform to simulate Windows + const originalPlatform = process.platform + Object.defineProperty(process, "platform", { value: "win32" }) + + try { + // Import the module under test + const { runClaudeCode } = await import("../run") + + // Setup test data + const options = { + systemPrompt: "Test system prompt", + messages: [{ role: "user" as const, content: "Test message" }], + modelId: "claude-3-opus-20240229", + } + + // Start the generator + const generator = runClaudeCode(options) + + try { + // Consume the generator to trigger the code + await generator.next() + + // Verify temporary directory was created + expect(fs.existsSync).toHaveBeenCalledWith("C:\\temp\\.claude-code-temp") + expect(fs.mkdirSync).toHaveBeenCalledWith("C:\\temp\\.claude-code-temp", { recursive: true }) + + // Verify temporary files were created + expect(fs.writeFileSync).toHaveBeenCalledTimes(2) + expect(fs.writeFileSync).toHaveBeenCalledWith( + "C:\\temp\\.claude-code-temp\\messages-1000000-12345.json", + JSON.stringify(options.messages), + "utf8", + ) + expect(fs.writeFileSync).toHaveBeenCalledWith( + "C:\\temp\\.claude-code-temp\\system-prompt-1000000-12345.txt", + options.systemPrompt, + "utf8", + ) + + // Verify execa was called with WSL and correct parameters + expect(execa).toHaveBeenCalledTimes(1) + expect(execa).toHaveBeenCalledWith( + "wsl", + expect.arrayContaining([ + "claude", + "-p", + expect.stringContaining("/mnt/c/temp/.claude-code-temp/messages-1000000-12345.json"), + "--system-prompt", + expect.stringContaining("/mnt/c/temp/.claude-code-temp/system-prompt-1000000-12345.txt"), + "--verbose", + "--output-format", + "stream-json", + "--disallowedTools", + expect.any(String), + "--max-turns", + "1", + "--model", + "claude-3-opus-20240229", + ]), + expect.objectContaining({ + env: expect.objectContaining({ + CLAUDE_CODE_MAX_OUTPUT_TOKENS: "64000", + }), + }), + ) + + // Verify cleanup was registered + expect(vi.mocked(execa).mock.results[0].value.finally).toHaveBeenCalled() + + // Verify files were cleaned up (since we call the cleanup function in our mock) + expect(fs.unlinkSync).toHaveBeenCalledTimes(2) + expect(fs.unlinkSync).toHaveBeenCalledWith("C:\\temp\\.claude-code-temp\\messages-1000000-12345.json") + expect(fs.unlinkSync).toHaveBeenCalledWith( + "C:\\temp\\.claude-code-temp\\system-prompt-1000000-12345.txt", + ) + + // Verify directory cleanup was attempted + expect(fs.readdirSync).toHaveBeenCalledWith("C:\\temp\\.claude-code-temp") + } finally { + // Clean up the generator + await generator.return(undefined) + } + } finally { + // Restore original platform + Object.defineProperty(process, "platform", { value: originalPlatform }) + } + }) + + test("should convert Windows paths to WSL paths correctly", async () => { + // Mock process.platform to simulate Windows + const originalPlatform = process.platform + Object.defineProperty(process, "platform", { value: "win32" }) + + try { + // Import the module under test + const { runClaudeCode } = await import("../run") + + // Setup test data + const options = { + systemPrompt: "Test system prompt", + messages: [{ role: "user" as const, content: "Test message" }], + } + + // Start the generator + const generator = runClaudeCode(options) + + try { + // Consume the generator to trigger the code + await generator.next() + + // Verify execa was called with correctly converted WSL paths + expect(execa).toHaveBeenCalledWith( + "wsl", + expect.arrayContaining([ + expect.stringContaining("/mnt/c/temp/.claude-code-temp/messages-1000000-12345.json"), + expect.stringContaining("/mnt/c/temp/.claude-code-temp/system-prompt-1000000-12345.txt"), + ]), + expect.anything(), + ) + } finally { + // Clean up the generator + await generator.return(undefined) + } + } finally { + // Restore original platform + Object.defineProperty(process, "platform", { value: originalPlatform }) + } + }) + + test("should handle error cases gracefully", async () => { + // Mock process.platform to simulate Windows + const originalPlatform = process.platform + Object.defineProperty(process, "platform", { value: "win32" }) + + try { + // Mock execa to throw an error + vi.mocked(execa).mockImplementationOnce(() => { + throw new Error("WSL not installed") + }) + + // Import the module under test + const { runClaudeCode } = await import("../run") + + // Setup test data + const options = { + systemPrompt: "Test system prompt", + messages: [{ role: "user" as const, content: "Test message" }], + } + + // Start the generator - should throw an error + const generator = runClaudeCode(options) + + // Verify that the error is properly handled + await expect(generator.next()).rejects.toThrow("Failed to execute Claude CLI via WSL") + + // Verify cleanup was still called + expect(fs.unlinkSync).toHaveBeenCalledTimes(2) + } finally { + // Restore original platform + Object.defineProperty(process, "platform", { value: originalPlatform }) + } + }) + + test("should handle process errors correctly", async () => { + // Mock process.platform to simulate Windows + const originalPlatform = process.platform + Object.defineProperty(process, "platform", { value: "win32" }) + + try { + // Mock readline.createInterface + vi.mocked(readline.createInterface).mockReturnValue({ + [Symbol.asyncIterator]: () => ({ + next: async () => ({ done: true, value: undefined }), + }), + close: vi.fn(), + } as any) + + // Setup a mock that will emit an error + const mockProcess = { + stdout: { pipe: vi.fn(), on: vi.fn() }, + stderr: { on: vi.fn() }, + on: vi.fn().mockImplementation((event, callback) => { + if (event === "error") { + // Simulate an error event immediately + callback(new Error("Process error")) + } + return mockProcess + }), + finally: vi.fn().mockReturnValue({ on: vi.fn() }), + kill: vi.fn(), + killed: false, + } + + vi.mocked(execa).mockReturnValueOnce(mockProcess as any) + + // Import the module under test + const { runClaudeCode } = await import("../run") + + // Setup test data + const options = { + systemPrompt: "Test system prompt", + messages: [{ role: "user" as const, content: "Test message" }], + } + + // Start the generator + const generator = runClaudeCode(options) + + // Verify that the error is properly propagated + await expect(generator.next()).rejects.toThrow("Process error") + } finally { + // Restore original platform + Object.defineProperty(process, "platform", { value: originalPlatform }) + } + }) +}) diff --git a/src/integrations/claude-code/run.ts b/src/integrations/claude-code/run.ts index 8e37d0f303..99f82e2fbe 100644 --- a/src/integrations/claude-code/run.ts +++ b/src/integrations/claude-code/run.ts @@ -3,6 +3,9 @@ import type Anthropic from "@anthropic-ai/sdk" import { execa } from "execa" import { ClaudeCodeMessage } from "./types" import readline from "readline" +import * as fs from "fs" +import * as path from "path" +import * as os from "os" const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0) @@ -21,6 +24,14 @@ type ProcessState = { } export async function* runClaudeCode(options: ClaudeCodeOptions): AsyncGenerator { + // Validate inputs + if (!options.systemPrompt || typeof options.systemPrompt !== "string") { + throw new Error("systemPrompt is required and must be a string") + } + if (!options.messages || !Array.isArray(options.messages) || options.messages.length === 0) { + throw new Error("messages is required and must be a non-empty array") + } + const process = runProcess(options) const rl = readline.createInterface({ @@ -109,7 +120,217 @@ const CLAUDE_CODE_TIMEOUT = 600000 // 10 minutes function runProcess({ systemPrompt, messages, path, modelId }: ClaudeCodeOptions) { const claudePath = path || "claude" + const isWindows = process.platform === "win32" + const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0) + + // Dispatch to the appropriate platform-specific method + if (isWindows) { + return runClaudeCodeOnWindows({ + systemPrompt, + messages, + claudePath, + modelId, + cwd, + }) + } else { + return runClaudeCodeOnNonWindows({ + systemPrompt, + messages, + claudePath, + modelId, + cwd, + }) + } +} + +/** + * Runs Claude CLI on Windows using WSL + * @param params Parameters for running Claude CLI + * @returns Execa process + */ +function runClaudeCodeOnWindows({ + systemPrompt, + messages, + claudePath, + modelId, + cwd, +}: { + systemPrompt: string + messages: Anthropic.Messages.MessageParam[] + claudePath: string + modelId?: string + cwd?: string +}) { + /** + * Creates temporary files for Claude CLI input and returns their paths + * @param cwd The current working directory + * @param messages The messages to write to a file + * @param systemPrompt The system prompt to write to a file + * @returns Object containing paths to the temporary files and a cleanup function + */ + const createTemporaryFiles = ( + cwd: string | undefined, + messages: Anthropic.Messages.MessageParam[], + systemPrompt: string, + ) => { + // Always use system temp directory to avoid workspace pollution + const tempDirPath = path.join(os.tmpdir(), ".claude-code-temp") + + // Create the directory if it doesn't exist + if (!fs.existsSync(tempDirPath)) { + fs.mkdirSync(tempDirPath, { recursive: true }) + } + + // Create unique filenames with process PID to avoid collisions + const timestamp = Date.now() + const pid = process.pid + const messagesFilePath = path.join(tempDirPath, `messages-${timestamp}-${pid}.json`) + const systemPromptFilePath = path.join(tempDirPath, `system-prompt-${timestamp}-${pid}.txt`) + + // Write the files + fs.writeFileSync(messagesFilePath, JSON.stringify(messages), "utf8") + fs.writeFileSync(systemPromptFilePath, systemPrompt, "utf8") + + // Return paths and cleanup function + return { + messagesFilePath, + systemPromptFilePath, + cleanup: () => { + try { + fs.unlinkSync(messagesFilePath) + fs.unlinkSync(systemPromptFilePath) + + // Try to remove the directory if it's empty (use newer rmSync) + try { + const files = fs.readdirSync(tempDirPath) + if (files.length === 0) { + fs.rmSync(tempDirPath, { recursive: true }) + } + } catch (dirError) { + // Directory might not be empty or already removed, which is fine + } + } catch (error) { + console.error("Error cleaning up temporary Claude CLI files:", error) + // Don't re-throw to avoid breaking the main process + } + }, + } + } + + /** + * Converts a Windows path to a WSL-compatible path + * @param windowsPath The Windows path to convert + * @returns The WSL-compatible path + */ + const convertWindowsPathToWsl = (windowsPath: string): string => { + // Validate that this is a Windows absolute path with drive letter + if (!windowsPath || windowsPath.length < 3 || windowsPath.charAt(1) !== ":") { + throw new Error(`Invalid Windows path format: ${windowsPath}`) + } + + // Remove drive letter and convert backslashes to forward slashes + const driveLetter = windowsPath.charAt(0).toLowerCase() + const pathWithoutDrive = windowsPath.substring(2).replace(/\\/g, "/") + return `/mnt/${driveLetter}${pathWithoutDrive}` + } + + // Create temporary files for messages and system prompt + const tempFiles = createTemporaryFiles(cwd, messages, systemPrompt) + + // Convert Windows paths to WSL paths + const wslMessagesPath = convertWindowsPathToWsl(tempFiles.messagesFilePath) + const wslSystemPromptPath = convertWindowsPathToWsl(tempFiles.systemPromptFilePath) + + // Modify args to use file paths instead of raw JSON + const fileArgs = [ + "-p", + `@${wslMessagesPath}`, + "--system-prompt", + `@${wslSystemPromptPath}`, + "--verbose", + "--output-format", + "stream-json", + "--disallowedTools", + claudeCodeTools, + "--max-turns", + "1", + ] + + if (modelId) { + fileArgs.push("--model", modelId) + } + + try { + // Create the process + const childProcess = execa("wsl", [claudePath, ...fileArgs], { + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + env: { + ...process.env, + // The default is 32000. However, I've gotten larger responses, so we increase it unless the user specified it. + CLAUDE_CODE_MAX_OUTPUT_TOKENS: process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS || "64000", + }, + cwd, + maxBuffer: 1024 * 1024 * 1000, + timeout: CLAUDE_CODE_TIMEOUT, + }) + + // Clean up temporary files when the process exits + childProcess.finally(() => { + tempFiles.cleanup() + }) + // Register cleanup handlers for process termination signals + const cleanupOnTermination = () => { + tempFiles.cleanup() + // Only attempt to kill the process if it's still running + if (childProcess && !childProcess.killed) { + childProcess.kill() + } + } + + // Handle forceful termination by user + process.on("SIGINT", cleanupOnTermination) + process.on("SIGTERM", cleanupOnTermination) + process.on("exit", cleanupOnTermination) + + // Remove the signal listeners when the child process completes + childProcess.finally(() => { + process.off("SIGINT", cleanupOnTermination) + process.off("SIGTERM", cleanupOnTermination) + process.off("exit", cleanupOnTermination) + }) + + return childProcess + } catch (error) { + // Clean up temp files if WSL execution fails + tempFiles.cleanup() + throw new Error( + `Failed to execute Claude CLI via WSL: ${error instanceof Error ? error.message : String(error)}. Make sure WSL is installed and configured properly.`, + ) + } +} + +/** + * Runs Claude CLI on non-Windows platforms + * @param params Parameters for running Claude CLI + * @returns Execa process + */ +function runClaudeCodeOnNonWindows({ + systemPrompt, + messages, + claudePath, + modelId, + cwd, +}: { + systemPrompt: string + messages: Anthropic.Messages.MessageParam[] + claudePath: string + modelId?: string + cwd?: string +}) { + // Create args for Claude CLI const args = [ "-p", "--system-prompt",