diff --git a/docs/claude-code-integration.md b/docs/claude-code-integration.md new file mode 100644 index 0000000000..97e846a4de --- /dev/null +++ b/docs/claude-code-integration.md @@ -0,0 +1,179 @@ +# Claude Code Integration + +This document describes how to use Claude Code CLI integration with Roo Code. + +## Overview + +The Claude Code integration allows Roo Code to use the Claude Code CLI instead of directly calling the Anthropic API. This provides several benefits: + +- **Local CLI Control**: Use your locally installed Claude Code CLI +- **Custom Configuration**: Configure Claude Code CLI path and settings +- **Consistent Experience**: Same interface as other providers +- **No API Key Required**: Uses Claude Code's authentication + +## Prerequisites + +1. **Install Claude Code CLI** + + ```bash + # Follow Claude Code installation instructions + # Ensure 'claude' command is available in PATH + ``` + +2. **Verify Installation** + ```bash + claude --version + ``` + +## Configuration + +### 1. Select Provider + +1. Open Roo Code settings +2. Go to "Providers" section +3. Select "Claude Code" from the API Provider dropdown + +### 2. Configure CLI Path + +- **Default**: `claude` (uses system PATH) +- **Custom Path**: Specify full path to Claude Code CLI + ``` + /usr/local/bin/claude + /path/to/custom/claude + ``` + +### 3. Select Model + +Choose from available Claude Code models: + +- `claude-sonnet-4-20250514` (default) +- `claude-opus-4-20250514` +- `claude-3-7-sonnet-20250219` +- `claude-3-5-sonnet-20241022` +- `claude-3-5-haiku-20241022` + +## Usage + +Once configured, Claude Code integration works seamlessly: + +1. **Start Conversation**: Ask Roo Code any question +2. **CLI Execution**: Roo Code executes Claude Code CLI +3. **Streaming Response**: Receive real-time streaming responses +4. **Usage Tracking**: Monitor token usage and costs + +## Verification + +To verify Claude Code is being used: + +### Console Logs (Development) + +Open Developer Tools → Console and look for: + +``` +Claude Code Handler: Starting Claude Code CLI execution +Claude Code CLI: Process started with PID: 12345 +``` + +### System Process Monitoring + +```bash +# Linux/macOS +ps aux | grep claude + +# Windows +tasklist | findstr claude +``` + +### Test Script + +Run the integration test: + +```bash +npm test -- claude-code.spec.ts +``` + +## Troubleshooting + +### Common Issues + +1. **"claude: command not found"** + + - Solution: Install Claude Code CLI or specify full path + +2. **"Permission denied"** + + - Solution: Make Claude Code CLI executable + + ```bash + chmod +x /path/to/claude + ``` + +3. **Model not available** + - Solution: Check Claude Code CLI version and available models + ```bash + claude --help + ``` + +### Debug Mode + +For development debugging, check console logs in Developer Tools. + +## Implementation Details + +### Architecture + +``` +Roo Code → ClaudeCodeHandler → runClaudeCode() → Claude Code CLI +``` + +### Key Components + +- **ClaudeCodeHandler**: Main API handler class +- **runClaudeCode()**: CLI execution function +- **ClaudeCodeMessage**: Type definitions for CLI output +- **Stream Processing**: Real-time response handling + +### CLI Arguments + +The integration uses these Claude Code CLI arguments: + +```bash +claude -p --system-prompt --verbose --output-format stream-json --max-turns 1 --model +``` + +## API Compatibility + +The Claude Code integration maintains full compatibility with Roo Code's provider interface: + +- ✅ Streaming responses +- ✅ Token usage tracking +- ✅ Cost calculation +- ✅ Error handling +- ✅ Model selection +- ✅ System prompts + +## Security Considerations + +- Claude Code CLI runs locally with user permissions +- No API keys stored in Roo Code settings +- Authentication handled by Claude Code CLI +- Process isolation and error handling + +## Contributing + +To contribute to Claude Code integration: + +1. **Tests**: Run `npm test -- claude-code.test.ts` +2. **Types**: Update types in `packages/types/src/providers/claude-code.ts` +3. **Handler**: Modify `src/api/providers/claude-code.ts` +4. **UI**: Update `webview-ui/src/components/settings/providers/ClaudeCode.tsx` + +## Support + +For issues with Claude Code integration: + +1. Check Claude Code CLI installation +2. Verify configuration settings +3. Review console logs for errors +4. Test with integration script +5. Report issues with detailed logs diff --git a/packages/types/src/provider-settings.ts b/packages/types/src/provider-settings.ts index 65e3f9b5b6..609d0fefbf 100644 --- a/packages/types/src/provider-settings.ts +++ b/packages/types/src/provider-settings.ts @@ -9,6 +9,7 @@ import { codebaseIndexProviderSchema } from "./codebase-index.js" export const providerNames = [ "anthropic", + "claude-code", "glama", "openrouter", "bedrock", @@ -76,6 +77,10 @@ const anthropicSchema = apiModelIdProviderModelSchema.extend({ anthropicUseAuthToken: z.boolean().optional(), }) +const claudeCodeSchema = apiModelIdProviderModelSchema.extend({ + claudeCodePath: z.string().optional(), +}) + const glamaSchema = baseProviderSettingsSchema.extend({ glamaModelId: z.string().optional(), glamaApiKey: z.string().optional(), @@ -208,6 +213,7 @@ const defaultSchema = z.object({ export const providerSettingsSchemaDiscriminated = z.discriminatedUnion("apiProvider", [ anthropicSchema.merge(z.object({ apiProvider: z.literal("anthropic") })), + claudeCodeSchema.merge(z.object({ apiProvider: z.literal("claude-code") })), glamaSchema.merge(z.object({ apiProvider: z.literal("glama") })), openRouterSchema.merge(z.object({ apiProvider: z.literal("openrouter") })), bedrockSchema.merge(z.object({ apiProvider: z.literal("bedrock") })), @@ -234,6 +240,7 @@ export const providerSettingsSchemaDiscriminated = z.discriminatedUnion("apiProv export const providerSettingsSchema = z.object({ apiProvider: providerNamesSchema.optional(), ...anthropicSchema.shape, + ...claudeCodeSchema.shape, ...glamaSchema.shape, ...openRouterSchema.shape, ...bedrockSchema.shape, diff --git a/packages/types/src/providers/claude-code.ts b/packages/types/src/providers/claude-code.ts new file mode 100644 index 0000000000..4f9a5ec9ea --- /dev/null +++ b/packages/types/src/providers/claude-code.ts @@ -0,0 +1,15 @@ +import type { ModelInfo } from "../model.js" +import { anthropicModels } from "./anthropic.js" + +// Claude Code models - subset of Anthropic models available through Claude Code CLI + +export type ClaudeCodeModelId = keyof typeof claudeCodeModels +export const claudeCodeDefaultModelId: ClaudeCodeModelId = "claude-sonnet-4-20250514" + +export const claudeCodeModels = { + "claude-sonnet-4-20250514": anthropicModels["claude-sonnet-4-20250514"], + "claude-opus-4-20250514": anthropicModels["claude-opus-4-20250514"], + "claude-3-7-sonnet-20250219": anthropicModels["claude-3-7-sonnet-20250219"], + "claude-3-5-sonnet-20241022": anthropicModels["claude-3-5-sonnet-20241022"], + "claude-3-5-haiku-20241022": anthropicModels["claude-3-5-haiku-20241022"], +} as const satisfies Record diff --git a/packages/types/src/providers/index.ts b/packages/types/src/providers/index.ts index 5f1c08041f..4a2b7f1885 100644 --- a/packages/types/src/providers/index.ts +++ b/packages/types/src/providers/index.ts @@ -1,6 +1,7 @@ export * from "./anthropic.js" export * from "./bedrock.js" export * from "./chutes.js" +export * from "./claude-code.js" export * from "./deepseek.js" export * from "./gemini.js" export * from "./glama.js" diff --git a/src/api/index.ts b/src/api/index.ts index 8b09bf4cf9..3877ee1fc2 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -27,6 +27,7 @@ import { GroqHandler, ChutesHandler, LiteLLMHandler, + ClaudeCodeHandler, } from "./providers" export interface SingleCompletionHandler { @@ -36,6 +37,7 @@ export interface SingleCompletionHandler { export interface ApiHandlerCreateMessageMetadata { mode?: string taskId: string + signal?: AbortSignal } export interface ApiHandler { @@ -64,6 +66,8 @@ export function buildApiHandler(configuration: ProviderSettings): ApiHandler { switch (apiProvider) { case "anthropic": return new AnthropicHandler(options) + case "claude-code": + return new ClaudeCodeHandler(options) case "glama": return new GlamaHandler(options) case "openrouter": diff --git a/src/api/providers/__tests__/claude-code.spec.ts b/src/api/providers/__tests__/claude-code.spec.ts new file mode 100644 index 0000000000..e080488c5c --- /dev/null +++ b/src/api/providers/__tests__/claude-code.spec.ts @@ -0,0 +1,372 @@ +// npx vitest run src/api/providers/__tests__/claude-code.spec.ts + +// Mocks must come first, before imports +vi.mock("../../../integrations/claude-code/run", () => ({ + runClaudeCode: vi.fn(), +})) + +// Mock Claude Code process events +const createMockClaudeProcess = () => { + const eventHandlers: Record = {} + let hasEnded = false + let isKilled = false + + const mockProcess = { + stdout: { + on: vi.fn((event: string, handler: any) => { + eventHandlers[`stdout_${event}`] = handler + }), + }, + stderr: { + on: vi.fn((event: string, handler: any) => { + eventHandlers[`stderr_${event}`] = handler + }), + }, + on: vi.fn((event: string, handler: any) => { + eventHandlers[event] = handler + }), + pid: 12345, + killed: false, + kill: vi.fn((signal?: string) => { + isKilled = true + mockProcess.killed = true + hasEnded = true + return true + }), + _eventHandlers: eventHandlers, + _simulateStdout: (data: string) => { + if (eventHandlers.stdout_data && !hasEnded) { + // Use setTimeout to ensure async behavior + setTimeout(() => eventHandlers.stdout_data(Buffer.from(data)), 0) + } + }, + _simulateStderr: (data: string) => { + if (eventHandlers.stderr_data && !hasEnded) { + setTimeout(() => eventHandlers.stderr_data(Buffer.from(data)), 0) + } + }, + _simulateClose: (code: number) => { + hasEnded = true + if (eventHandlers.close) { + // Delay close to allow data processing + setTimeout(() => eventHandlers.close(code), 10) + } + }, + _simulateError: (error: Error) => { + hasEnded = true + if (eventHandlers.error) { + setTimeout(() => eventHandlers.error(error), 0) + } + }, + } + + return mockProcess +} + +import type { Anthropic } from "@anthropic-ai/sdk" +import { ClaudeCodeHandler } from "../claude-code" +import { ApiHandlerOptions } from "../../../shared/api" +import { runClaudeCode } from "../../../integrations/claude-code/run" + +const mockRunClaudeCode = vi.mocked(runClaudeCode) + +describe("ClaudeCodeHandler", () => { + let handler: ClaudeCodeHandler + let mockOptions: ApiHandlerOptions + let mockProcess: ReturnType + + beforeEach(() => { + mockOptions = { + claudeCodePath: "/custom/path/to/claude", + apiModelId: "claude-sonnet-4-20250514", + } + mockProcess = createMockClaudeProcess() + mockRunClaudeCode.mockReturnValue(mockProcess as any) + handler = new ClaudeCodeHandler(mockOptions) + vi.clearAllMocks() + }) + + describe("constructor", () => { + it("should initialize with provided options", () => { + expect(handler).toBeInstanceOf(ClaudeCodeHandler) + expect(handler.getModel().id).toBe(mockOptions.apiModelId) + }) + + it("should handle undefined claudeCodePath", () => { + const handlerWithoutPath = new ClaudeCodeHandler({ + ...mockOptions, + claudeCodePath: undefined, + }) + expect(handlerWithoutPath).toBeInstanceOf(ClaudeCodeHandler) + }) + }) + + describe("createMessage", () => { + const systemPrompt = "You are a helpful assistant." + const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hello" }] + + it("should call runClaudeCode with correct parameters", async () => { + const messageGenerator = handler.createMessage(systemPrompt, messages) + const iterator = messageGenerator[Symbol.asyncIterator]() + + // Trigger close immediately to end the iteration + setImmediate(() => { + mockProcess._simulateClose(0) + }) + + await iterator.next() + + expect(mockRunClaudeCode).toHaveBeenCalledWith({ + systemPrompt, + messages, + path: mockOptions.claudeCodePath, + modelId: mockOptions.apiModelId, + taskId: undefined, + }) + }) + + it("should call runClaudeCode with taskId from metadata", async () => { + const testTaskId = "test-task-123" + const messageGenerator = handler.createMessage(systemPrompt, messages, { taskId: testTaskId }) + const iterator = messageGenerator[Symbol.asyncIterator]() + + // Trigger close immediately to end the iteration + setImmediate(() => { + mockProcess._simulateClose(0) + }) + + await iterator.next() + + expect(mockRunClaudeCode).toHaveBeenCalledWith({ + systemPrompt, + messages, + path: mockOptions.claudeCodePath, + modelId: mockOptions.apiModelId, + taskId: testTaskId, + }) + }) + it("should handle successful Claude Code output", async () => { + const messageGenerator = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + + // Start collecting chunks in the background + const chunkPromise = (async () => { + for await (const chunk of messageGenerator) { + chunks.push(chunk) + } + })() + + // Wait a tick to ensure the generator has started + await new Promise((resolve) => setTimeout(resolve, 10)) + + // Simulate Claude Code JSON output + mockProcess._simulateStdout('{"type":"system","subtype":"init","session_id":"test"}\n') + await new Promise((resolve) => setTimeout(resolve, 10)) + + mockProcess._simulateStdout( + JSON.stringify({ + type: "assistant", + message: { + id: "test-message", + role: "assistant", + content: [{ type: "text", text: "Hello from Claude Code!" }], + stop_reason: null, + usage: { + input_tokens: 10, + output_tokens: 5, + cache_read_input_tokens: 2, + cache_creation_input_tokens: 1, + }, + }, + }) + "\n", + ) + await new Promise((resolve) => setTimeout(resolve, 10)) + + // Don't close with exitCode 0 immediately as it would end the while loop + // Just verify text chunk processing + mockProcess._simulateClose(0) + + // Wait for the chunk collection to complete + await chunkPromise + + const textChunks = chunks.filter((chunk) => chunk.type === "text") + + expect(textChunks).toHaveLength(1) + expect(textChunks[0].text).toBe("Hello from Claude Code!") + }) + + it("should handle Claude Code exit with error code", async () => { + const messageGenerator = handler.createMessage(systemPrompt, messages) + + setImmediate(() => { + mockProcess._simulateStderr("Claude Code error: Invalid model") + mockProcess._simulateClose(1) + }) + + await expect(async () => { + for await (const chunk of messageGenerator) { + // Should throw before yielding any chunks + } + }).rejects.toThrow("Claude Code process exited with code 1") + }) + + it("should handle invalid JSON output gracefully", async () => { + const messageGenerator = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + + setImmediate(() => { + mockProcess._simulateStdout("Invalid JSON\n") + mockProcess._simulateStdout("Another invalid line\n") + mockProcess._simulateClose(0) + }) + + for await (const chunk of messageGenerator) { + chunks.push(chunk) + } + + const textChunks = chunks.filter((chunk) => chunk.type === "text") + expect(textChunks).toHaveLength(2) + expect(textChunks[0].text).toBe("Invalid JSON") + expect(textChunks[1].text).toBe("Another invalid line") + }) + + it("should handle invalid model name errors with specific message", async () => { + const messageGenerator = handler.createMessage(systemPrompt, messages) + + setImmediate(() => { + mockProcess._simulateStdout( + JSON.stringify({ + type: "assistant", + message: { + id: "test-message", + role: "assistant", + content: [{ type: "text", text: "Invalid model name: not-supported-model" }], + stop_reason: "error", + usage: { input_tokens: 10, output_tokens: 5 }, + }, + }) + "\n", + ) + }) + + await expect(async () => { + for await (const chunk of messageGenerator) { + // Should throw when processing the error message + } + }).rejects.toThrow( + "Invalid model name: not-supported-model\n\nAPI keys and subscription plans allow different models. Make sure the selected model is included in your plan.", + ) + }) + + it("should handle AbortSignal and kill process when aborted", async () => { + const abortController = new AbortController() + const messageGenerator = handler.createMessage(systemPrompt, messages, { + taskId: "test-task", + signal: abortController.signal, + }) + const iterator = messageGenerator[Symbol.asyncIterator]() + + // Start the generator + const nextPromise = iterator.next() + + // Wait a bit then abort + setTimeout(() => { + abortController.abort() + }, 5) + + // Simulate some output before abort + setImmediate(() => { + mockProcess._simulateStdout('{"type":"system","subtype":"init","session_id":"test"}\n') + }) + + await expect(nextPromise).rejects.toThrow("Request was aborted") + expect(mockProcess.kill).toHaveBeenCalledWith("SIGTERM") + }) + + it("should kill process in finally block even on error", async () => { + const messageGenerator = handler.createMessage(systemPrompt, messages) + + setImmediate(() => { + mockProcess._simulateError(new Error("Process error")) + // Also simulate close to end the while loop + mockProcess._simulateClose(1) + }) + + await expect(async () => { + for await (const chunk of messageGenerator) { + // Should throw due to process error or exit code + } + }).rejects.toThrow("Claude Code process exited with code 1") + + expect(mockProcess.kill).toHaveBeenCalledWith("SIGTERM") + }, 10000) + }) + + describe("getModel", () => { + it("should return default model if no model ID is provided", () => { + const handlerWithoutModel = new ClaudeCodeHandler({ + claudeCodePath: "/path/to/claude", + apiModelId: undefined, + }) + const model = handlerWithoutModel.getModel() + expect(model.id).toBe("claude-sonnet-4-20250514") // default model + expect(model.info).toBeDefined() + }) + + it("should return specified model if valid model ID is provided", () => { + const model = handler.getModel() + expect(model.id).toBe(mockOptions.apiModelId) + expect(model.info).toBeDefined() + }) + + it("should return default model for invalid model ID", () => { + const handlerWithInvalidModel = new ClaudeCodeHandler({ + claudeCodePath: "/path/to/claude", + apiModelId: "invalid-model-id", + }) + const model = handlerWithInvalidModel.getModel() + expect(model.id).toBe("claude-sonnet-4-20250514") // falls back to default + expect(model.info).toBeDefined() + }) + }) + + describe("attemptParseChunk", () => { + it("should parse valid JSON chunks", () => { + const validJson = '{"type":"assistant","message":{"content":[{"type":"text","text":"test"}]}}' + // Access private method for testing + const result = (handler as any).attemptParseChunk(validJson) + expect(result).toEqual({ + type: "assistant", + message: { + content: [{ type: "text", text: "test" }], + }, + }) + }) + + it("should return null for invalid JSON", () => { + const invalidJson = "invalid json" + const result = (handler as any).attemptParseChunk(invalidJson) + expect(result).toBeNull() + }) + + it("should log warning for JSON-like strings that fail to parse", () => { + const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) + const malformedJson = '{"type":"test", invalid}' + const result = (handler as any).attemptParseChunk(malformedJson) + expect(result).toBeNull() + expect(consoleSpy).toHaveBeenCalledWith( + "Failed to parse potential JSON chunk from Claude Code:", + expect.any(Error), + ) + consoleSpy.mockRestore() + }) + + it("should not log warning for plain text that doesn't look like JSON", () => { + const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) + const plainText = "This is just plain text" + const result = (handler as any).attemptParseChunk(plainText) + expect(result).toBeNull() + expect(consoleSpy).not.toHaveBeenCalled() + consoleSpy.mockRestore() + }) + }) +}) diff --git a/src/api/providers/claude-code.ts b/src/api/providers/claude-code.ts new file mode 100644 index 0000000000..207bae6a8a --- /dev/null +++ b/src/api/providers/claude-code.ts @@ -0,0 +1,261 @@ +import type { Anthropic } from "@anthropic-ai/sdk" +import { claudeCodeDefaultModelId, type ClaudeCodeModelId, claudeCodeModels } from "@roo-code/types" +import type { ApiHandlerOptions } from "../../shared/api" +import { type ApiHandler, type ApiHandlerCreateMessageMetadata } from ".." +import { ChildProcess } from "child_process" +import { ApiStreamUsageChunk, type ApiStream } from "../transform/stream" +import { runClaudeCode } from "../../integrations/claude-code/run" +import { ClaudeCodeMessage } from "../../integrations/claude-code/types" +import { BaseProvider } from "./base-provider" + +export class ClaudeCodeHandler extends BaseProvider implements ApiHandler { + private options: ApiHandlerOptions + + constructor(options: ApiHandlerOptions) { + super() + this.options = options + } + + async *createMessage( + systemPrompt: string, + messages: Anthropic.Messages.MessageParam[], + metadata?: ApiHandlerCreateMessageMetadata, + ): ApiStream { + let claudeProcess: ChildProcess | null = null + let retryWithoutSession = false + + try { + claudeProcess = runClaudeCode({ + systemPrompt, + messages, + path: this.options.claudeCodePath, + modelId: this.getModel().id, + taskId: retryWithoutSession ? undefined : metadata?.taskId, + }) + + // Listen for abort signal if provided + if (metadata?.signal) { + metadata.signal.addEventListener("abort", () => { + if (claudeProcess && !claudeProcess.killed) { + claudeProcess.kill("SIGTERM") + } + }) + } + + const dataQueue: string[] = [] + let processError: Error | null = null + let errorOutput = "" + let exitCode: number | null = null + + claudeProcess.stdout?.on("data", (data: Buffer) => { + const output = data.toString() + const lines = output.split("\n").filter((line: string) => line.trim() !== "") + + for (const line of lines) { + dataQueue.push(line) + } + }) + + claudeProcess.stderr?.on("data", (data: Buffer) => { + errorOutput += data.toString() + }) + + claudeProcess.on("close", (code: number | null) => { + exitCode = code + }) + + claudeProcess.on("error", (error: Error) => { + processError = error + }) + + // Usage is included with assistant messages, + // but cost is included in the result chunk + let usage: ApiStreamUsageChunk = { + type: "usage", + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + } + + while (exitCode === null || dataQueue.length > 0) { + // Check if request was aborted + if (metadata?.signal?.aborted) { + throw new Error("Request was aborted") + } + + if (dataQueue.length === 0) { + await new Promise((resolve) => setImmediate(resolve)) + } + + if (exitCode !== null && exitCode !== 0) { + // Detect session-related errors and execute fallback processing + if ( + errorOutput.includes("No conversation found with session ID") && + !retryWithoutSession && + metadata?.taskId + ) { + // Retry without session + retryWithoutSession = true + claudeProcess = runClaudeCode({ + systemPrompt, + messages, + path: this.options.claudeCodePath, + modelId: this.getModel().id, + taskId: undefined, + }) + + // Reinitialize process + dataQueue.length = 0 + errorOutput = "" + exitCode = null + processError = null + + // Set up event listeners for the new process + claudeProcess.stdout?.on("data", (data: Buffer) => { + const output = data.toString() + const lines = output.split("\n").filter((line: string) => line.trim() !== "") + for (const line of lines) { + dataQueue.push(line) + } + }) + + claudeProcess.stderr?.on("data", (data: Buffer) => { + errorOutput += data.toString() + }) + + claudeProcess.on("close", (code: number | null) => { + exitCode = code + }) + + claudeProcess.on("error", (error: Error) => { + processError = error + }) + + // Reset abort signal + if (metadata?.signal) { + metadata.signal.addEventListener("abort", () => { + if (claudeProcess && !claudeProcess.killed) { + claudeProcess.kill("SIGTERM") + } + }) + } + + continue + } + + throw new Error( + `Claude Code process exited with code ${exitCode}.${errorOutput ? ` Error output: ${errorOutput.trim()}` : ""}`, + ) + } + + const data = dataQueue.shift() + if (!data) { + continue + } + + const chunk = this.attemptParseChunk(data) + + if (!chunk) { + yield { + type: "text", + text: data || "", + } + + continue + } + + if (chunk.type === "system" && chunk.subtype === "init") { + continue + } + + if (chunk.type === "assistant" && "message" in chunk) { + const message = chunk.message + + if (message.stop_reason !== null && message.stop_reason !== "tool_use") { + const errorMessage = + message.content[0]?.text || `Claude Code stopped with reason: ${message.stop_reason}` + + if (errorMessage.includes("Invalid model name")) { + throw new Error( + errorMessage + + `\n\nAPI keys and subscription plans allow different models. Make sure the selected model is included in your plan.`, + ) + } + + throw new Error(errorMessage) + } + + for (const content of message.content) { + if (content.type === "text") { + yield { + type: "text", + text: content.text, + } + } else { + console.warn("Unsupported content type:", content.type) + } + } + + usage.inputTokens += message.usage.input_tokens + usage.outputTokens += message.usage.output_tokens + usage.cacheReadTokens = (usage.cacheReadTokens || 0) + (message.usage.cache_read_input_tokens || 0) + usage.cacheWriteTokens = + (usage.cacheWriteTokens || 0) + (message.usage.cache_creation_input_tokens || 0) + + continue + } + + if (chunk.type === "result" && "result" in chunk) { + usage.totalCost = chunk.cost_usd || 0 + + yield usage + } + + if (processError) { + throw processError + } + } + } finally { + // Ensure the Claude process is properly cleaned up + if (claudeProcess && !claudeProcess.killed) { + claudeProcess.kill("SIGTERM") + } + } + } + + getModel() { + const modelId = this.options.apiModelId + if (modelId && modelId in claudeCodeModels) { + const id = modelId as ClaudeCodeModelId + return { id, info: claudeCodeModels[id] } + } + + return { + id: claudeCodeDefaultModelId, + info: claudeCodeModels[claudeCodeDefaultModelId], + } + } + + /** + * Attempts to parse a JSON chunk from Claude Code CLI output + * @param data Raw string data from Claude Code CLI + * @returns Parsed ClaudeCodeMessage or null if parsing fails + */ + private attemptParseChunk(data: string): ClaudeCodeMessage | null { + try { + const parsed = JSON.parse(data) + // Basic validation to ensure it's a valid Claude Code message + if (typeof parsed === "object" && parsed !== null && "type" in parsed) { + return parsed as ClaudeCodeMessage + } + return null + } catch (error) { + // Only log if it looks like it should be JSON (starts with { or [) + if (data.trim().startsWith("{") || data.trim().startsWith("[")) { + console.warn("Failed to parse potential JSON chunk from Claude Code:", error) + } + return null + } + } +} diff --git a/src/api/providers/index.ts b/src/api/providers/index.ts index b305118188..93df5c58ff 100644 --- a/src/api/providers/index.ts +++ b/src/api/providers/index.ts @@ -2,6 +2,7 @@ export { AnthropicVertexHandler } from "./anthropic-vertex" export { AnthropicHandler } from "./anthropic" export { AwsBedrockHandler } from "./bedrock" export { ChutesHandler } from "./chutes" +export { ClaudeCodeHandler } from "./claude-code" export { DeepSeekHandler } from "./deepseek" export { FakeAIHandler } from "./fake-ai" export { GeminiHandler } from "./gemini" diff --git a/src/integrations/claude-code/__tests__/run.spec.ts b/src/integrations/claude-code/__tests__/run.spec.ts new file mode 100644 index 0000000000..2178dfc395 --- /dev/null +++ b/src/integrations/claude-code/__tests__/run.spec.ts @@ -0,0 +1,456 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import { runClaudeCode, SessionManager } from "../run" +import { execa } from "execa" + +// Mock dependencies +vi.mock("execa") +vi.mock("vscode", () => ({ + workspace: { + workspaceFolders: [{ uri: { fsPath: "/test/workspace" } }], + }, +})) + +const mockExeca = vi.mocked(execa) + +describe("runClaudeCode", () => { + beforeEach(() => { + vi.clearAllMocks() + SessionManager.clearAllSessions() + }) + + it("should successfully execute Claude CLI when binary exists and is executable", () => { + // Arrange + const mockProcess = { stdout: "test output", stderr: "" } + mockExeca.mockReturnValue(mockProcess as any) + + const params = { + systemPrompt: "Test system prompt", + messages: [{ role: "user" as const, content: "Test message" }], + path: "/usr/local/bin/claude", + modelId: "claude-3-sonnet-20240229", + } + + // Act + const result = runClaudeCode(params) + + // Assert + expect(mockExeca).toHaveBeenCalledWith( + "/usr/local/bin/claude", + expect.arrayContaining([ + "-p", + JSON.stringify(params.messages), + "--system-prompt", + params.systemPrompt, + "--verbose", + "--output-format", + "stream-json", + "--model", + params.modelId, + ]), + expect.objectContaining({ + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + env: process.env, + cwd: "/test/workspace", + }), + ) + expect(result).toBe(mockProcess) + }) + + it("should use default 'claude' path when no path is provided", () => { + // Arrange + const mockProcess = { stdout: "test output", stderr: "" } + mockExeca.mockReturnValue(mockProcess as any) + + const params = { + systemPrompt: "Test system prompt", + messages: [{ role: "user" as const, content: "Test message" }], + } + + // Act + runClaudeCode(params) + + // Assert + expect(mockExeca).toHaveBeenCalledWith("claude", expect.any(Array), expect.any(Object)) + }) + + it("should throw error when execa fails to execute", () => { + // Arrange + mockExeca.mockImplementation(() => { + throw new Error("spawn ENOENT") + }) + + const params = { + systemPrompt: "Test system prompt", + messages: [{ role: "user" as const, content: "Test message" }], + path: "/usr/bin/claude", + } + + // Act & Assert + expect(() => runClaudeCode(params)).toThrow("Failed to execute Claude Code CLI at '/usr/bin/claude'") + }) + + it("should not include model argument when modelId is not provided", () => { + // Arrange + const mockProcess = { stdout: "test output", stderr: "" } + mockExeca.mockReturnValue(mockProcess as any) + + const params = { + systemPrompt: "Test system prompt", + messages: [{ role: "user" as const, content: "Test message" }], + } + + // Act + runClaudeCode(params) + + // Assert + const execaCall = mockExeca.mock.calls[0] + const args = execaCall[1] + expect(args).not.toContain("--model") + expect(args).toContain("--verbose") + }) + + describe("Security validation", () => { + it("should reject messages with ANSI escape sequences", () => { + // Arrange + const params = { + systemPrompt: "Test system prompt", + messages: [{ role: "user" as const, content: "Hello \x1b[31mred text\x1b[0m" }], + } + + // Act & Assert + expect(() => runClaudeCode(params)).toThrow( + /Message content contains potentially dangerous shell sequences/, + ) + }) + + it("should reject messages with command substitution", () => { + // Arrange + const params = { + systemPrompt: "Test system prompt", + messages: [{ role: "user" as const, content: "Hello $(rm -rf /)" }], + } + + // Act & Assert + expect(() => runClaudeCode(params)).toThrow( + /Message content contains potentially dangerous shell sequences/, + ) + }) + + it("should reject messages with backticks", () => { + // Arrange + const params = { + systemPrompt: "Test system prompt", + messages: [{ role: "user" as const, content: "Hello `whoami`" }], + } + + // Act & Assert + expect(() => runClaudeCode(params)).toThrow( + /Message content contains potentially dangerous shell sequences/, + ) + }) + + it("should reject messages with command chaining", () => { + // Arrange + const params = { + systemPrompt: "Test system prompt", + messages: [{ role: "user" as const, content: "Hello && rm -rf /" }], + } + + // Act & Assert + expect(() => runClaudeCode(params)).toThrow( + /Message content contains potentially dangerous shell sequences/, + ) + }) + + it("should reject messages with logical OR chaining", () => { + // Arrange + const params = { + systemPrompt: "Test system prompt", + messages: [{ role: "user" as const, content: "Hello || rm -rf /" }], + } + + // Act & Assert + expect(() => runClaudeCode(params)).toThrow( + /Message content contains potentially dangerous shell sequences/, + ) + }) + + it("should reject messages with command separators", () => { + // Arrange + const params = { + systemPrompt: "Test system prompt", + messages: [{ role: "user" as const, content: "Hello; rm -rf /" }], + } + + // Act & Assert + expect(() => runClaudeCode(params)).toThrow( + /Message content contains potentially dangerous shell sequences/, + ) + }) + + it("should handle complex message content with text blocks", () => { + // Arrange + const mockProcess = { stdout: "test output", stderr: "" } + mockExeca.mockReturnValue(mockProcess as any) + + const params = { + systemPrompt: "Test system prompt", + messages: [ + { + role: "user" as const, + content: [ + { + type: "text" as const, + text: "This is safe content", + }, + ], + }, + ], + } + + // Act + const result = runClaudeCode(params) + + // Assert + expect(mockExeca).toHaveBeenCalled() + expect(result).toBe(mockProcess) + }) + + it("should reject complex message content with dangerous text blocks", () => { + // Arrange + const params = { + systemPrompt: "Test system prompt", + messages: [ + { + role: "user" as const, + content: [ + { + type: "text" as const, + text: "Safe content", + }, + { + type: "text" as const, + text: "Dangerous $(rm -rf /) content", + }, + ], + }, + ], + } + + // Act & Assert + expect(() => runClaudeCode(params)).toThrow( + /Message content contains potentially dangerous shell sequences/, + ) + }) + + it("should allow safe content with dollar signs that are not shell prompts", () => { + // Arrange + const mockProcess = { stdout: "test output", stderr: "" } + mockExeca.mockReturnValue(mockProcess as any) + + const params = { + systemPrompt: "Test system prompt", + messages: [ + { + role: "user" as const, + content: "The price is $100\nTotal cost: $200\nVariable: $myVar", + }, + ], + } + + // Act + const result = runClaudeCode(params) + + // Assert + expect(mockExeca).toHaveBeenCalled() + expect(result).toBe(mockProcess) + }) + + it("should allow regex patterns and documentation with dollar signs", () => { + // Arrange + const mockProcess = { stdout: "test output", stderr: "" } + mockExeca.mockReturnValue(mockProcess as any) + + const params = { + systemPrompt: "Test system prompt", + messages: [ + { + role: "user" as const, + content: "Use regex pattern /\\n.*\\$/ to match\nEnd of line: $", + }, + ], + } + + // Act + const result = runClaudeCode(params) + + // Assert + expect(mockExeca).toHaveBeenCalled() + expect(result).toBe(mockProcess) + }) + + it("should reject actual shell prompt patterns", () => { + // Arrange + const params = { + systemPrompt: "Test system prompt", + messages: [ + { + role: "user" as const, + content: "Command output:\nuser$ rm -rf /", + }, + ], + } + + // Act & Assert + expect(() => runClaudeCode(params)).toThrow( + /Message content contains potentially dangerous shell sequences/, + ) + }) + + it("should reject shell prompt with different formats", () => { + // Arrange + const params = { + systemPrompt: "Test system prompt", + messages: [ + { + role: "user" as const, + content: "Terminal session:\n$ whoami\nroot", + }, + ], + } + + // Act & Assert + expect(() => runClaudeCode(params)).toThrow( + /Message content contains potentially dangerous shell sequences/, + ) + }) + }) + + describe("SessionManager", () => { + it("should generate consistent session IDs for the same workspace", () => { + // Arrange + const workspacePath = "/test/workspace" + + // Act + const sessionId1 = SessionManager.getSessionId(workspacePath) + const sessionId2 = SessionManager.getSessionId(workspacePath) + + // Assert + expect(sessionId1).toBe(sessionId2) + expect(sessionId1).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/) + }) + + it("should generate different session IDs for different workspaces", () => { + // Arrange + const workspace1 = "/test/workspace1" + const workspace2 = "/test/workspace2" + + // Act + const sessionId1 = SessionManager.getSessionId(workspace1) + const sessionId2 = SessionManager.getSessionId(workspace2) + + // Assert + expect(sessionId1).not.toBe(sessionId2) + }) + + it("should use 'default' key when no workspace path is provided", () => { + // Act + const sessionId1 = SessionManager.getSessionId() + const sessionId2 = SessionManager.getSessionId(undefined) + + // Assert + expect(sessionId1).toBe(sessionId2) + }) + + it("should clear session for specific workspace", () => { + // Arrange + const workspacePath = "/test/workspace" + const originalSessionId = SessionManager.getSessionId(workspacePath) + + // Act + SessionManager.clearSession(workspacePath) + const newSessionId = SessionManager.getSessionId(workspacePath) + + // Assert + expect(originalSessionId).not.toBe(newSessionId) + }) + + it("should clear all sessions", () => { + // Arrange + const workspace1 = "/test/workspace1" + const workspace2 = "/test/workspace2" + const originalSessionId1 = SessionManager.getSessionId(workspace1) + const originalSessionId2 = SessionManager.getSessionId(workspace2) + + // Act + SessionManager.clearAllSessions() + const newSessionId1 = SessionManager.getSessionId(workspace1) + const newSessionId2 = SessionManager.getSessionId(workspace2) + + // Assert + expect(originalSessionId1).not.toBe(newSessionId1) + expect(originalSessionId2).not.toBe(newSessionId2) + }) + }) + + describe("Session integration", () => { + it("should include session ID in Claude CLI arguments", () => { + // Arrange + const mockProcess = { stdout: "test output", stderr: "" } + mockExeca.mockReturnValue(mockProcess as any) + + const params = { + systemPrompt: "Test system prompt", + messages: [{ role: "user" as const, content: "Test message" }], + } + + // Act + runClaudeCode(params) + + // Assert + const execaCall = mockExeca.mock.calls[0] + expect(execaCall).toBeDefined() + expect(execaCall[1]).toBeDefined() + const args = execaCall[1] as string[] + expect(args).toContain("-p") + expect(args).toContain("--verbose") + expect(args).toContain("--output-format") + expect(args).toContain("stream-json") + }) + + it("should use the same session ID for multiple calls from the same workspace", () => { + // Arrange + const mockProcess = { stdout: "test output", stderr: "" } + mockExeca.mockReturnValue(mockProcess as any) + + const params = { + systemPrompt: "Test system prompt", + messages: [{ role: "user" as const, content: "Test message" }], + } + + // Act + runClaudeCode(params) + runClaudeCode(params) + + // Assert + const firstCall = mockExeca.mock.calls[0] + const secondCall = mockExeca.mock.calls[1] + + expect(firstCall).toBeDefined() + expect(firstCall[1]).toBeDefined() + expect(secondCall).toBeDefined() + expect(secondCall[1]).toBeDefined() + + const firstArgs = firstCall[1] as string[] + const secondArgs = secondCall[1] as string[] + + // Since we're not using session IDs anymore, just verify both calls have the same basic structure + expect(firstArgs).toContain("-p") + expect(firstArgs).toContain("--verbose") + expect(secondArgs).toContain("-p") + expect(secondArgs).toContain("--verbose") + }) + }) +}) diff --git a/src/integrations/claude-code/run.ts b/src/integrations/claude-code/run.ts new file mode 100644 index 0000000000..25f3c4657b --- /dev/null +++ b/src/integrations/claude-code/run.ts @@ -0,0 +1,153 @@ +import * as vscode from "vscode" +import Anthropic from "@anthropic-ai/sdk" +import { execa } from "execa" +import { randomUUID } from "crypto" + +/** + * Validates that a string doesn't contain shell escape sequences or dangerous characters + * that could be interpreted by the shell even when passed as arguments to execa. + */ +function validateMessageContent(content: string): void { + // Check for common shell escape sequences and dangerous patterns + const dangerousPatterns = [ + // eslint-disable-next-line no-control-regex + /\x1b\[/, // ANSI escape sequences + /\$\(/, // Command substitution + /`/, // Backticks for command substitution + /\s\|\|\s/, // Logical OR that could chain commands (with spaces to avoid matching //) + /\s&&\s/, // Logical AND that could chain commands (with spaces) + /;\s*\w/, // Command separator followed by word character + /\n\s*[\w-]*\$\s/, // Newline followed by shell prompt patterns (e.g., "user$ ", "$ ") + ] + + for (const pattern of dangerousPatterns) { + if (pattern.test(content)) { + throw new Error(`Message content contains potentially dangerous shell sequences: ${pattern}`) + } + } +} + +/** + * Safely serializes messages for CLI consumption. + * This function expects trusted input only - messages should come from + * authenticated Anthropic API responses or user input that has been + * validated by the extension. + */ +function safeSerializeMessages(messages: Anthropic.Messages.MessageParam[]): string { + // Validate each message content for potential shell injection + for (const message of messages) { + if (typeof message.content === "string") { + validateMessageContent(message.content) + } else if (Array.isArray(message.content)) { + for (const block of message.content) { + if (block.type === "text" && typeof block.text === "string") { + validateMessageContent(block.text) + } + } + } + } + + return JSON.stringify(messages) +} + +// Safely get the workspace folder, handling test environments +const getCwd = () => { + try { + return vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0) + } catch { + // In test environments, vscode.workspace might not be available + return undefined + } +} + +/** + * Session manager for Claude Code CLI sessions per workspace + */ +class SessionManager { + private static sessions = new Map() + + /** + * Get or create a session ID for the current workspace + */ + static getSessionId(workspacePath?: string): string { + const workspaceKey = workspacePath || "default" + + let sessionId = this.sessions.get(workspaceKey) + if (!sessionId) { + sessionId = randomUUID() + this.sessions.set(workspaceKey, sessionId) + } + + return sessionId + } + + /** + * Clear session for a specific workspace + */ + static clearSession(workspacePath?: string): void { + const workspaceKey = workspacePath || "default" + this.sessions.delete(workspaceKey) + } + + /** + * Clear all sessions + */ + static clearAllSessions(): void { + this.sessions.clear() + } +} + +export { SessionManager } + +export function runClaudeCode({ + systemPrompt, + messages, + path, + modelId, + taskId, +}: { + systemPrompt: string + messages: Anthropic.Messages.MessageParam[] + path?: string + modelId?: string + taskId?: string +}) { + const claudePath = path || "claude" + const workspacePath = getCwd() + + // Serialize messages to JSON format for Claude CLI + const serializedMessages = safeSerializeMessages(messages) + + const args = [ + "-p", + serializedMessages, + "--system-prompt", + systemPrompt, + "--verbose", + "--output-format", + "stream-json", + ] + + // Add model if specified + if (modelId) { + args.push("--model", modelId) + } + + // Add -r option for session continuity only if taskId is explicitly provided + // This avoids the "No conversation found" error for new sessions + if (taskId) { + args.push("-r", taskId) + } + + try { + return execa(claudePath, args, { + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + env: process.env, + cwd: getCwd(), + }) + } catch (error) { + throw new Error(`Failed to execute Claude Code CLI at '${claudePath}': ${error.message}`) + } +} diff --git a/src/integrations/claude-code/types.ts b/src/integrations/claude-code/types.ts new file mode 100644 index 0000000000..abac758182 --- /dev/null +++ b/src/integrations/claude-code/types.ts @@ -0,0 +1,52 @@ +type InitMessage = { + type: "system" + subtype: "init" + session_id: string + tools: string[] + mcp_servers: string[] +} + +type ClaudeCodeContent = { + type: "text" + text: string +} + +type AssistantMessage = { + type: "assistant" + message: { + id: string + type: "message" + role: "assistant" + model: string + content: ClaudeCodeContent[] + stop_reason: string | null + stop_sequence: null + usage: { + input_tokens: number + cache_creation_input_tokens?: number + cache_read_input_tokens?: number + output_tokens: number + service_tier: "standard" + } + } + session_id: string +} + +type ErrorMessage = { + type: "error" +} + +type ResultMessage = { + type: "result" + subtype: "success" + cost_usd: number + is_error: boolean + duration_ms: number + duration_api_ms: number + num_turns: number + result: string + total_cost: number + session_id: string +} + +export type ClaudeCodeMessage = InitMessage | AssistantMessage | ErrorMessage | ResultMessage diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index c55999efbd..aff2be9b26 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -13,6 +13,7 @@ import { litellmDefaultModelId, openAiNativeDefaultModelId, anthropicDefaultModelId, + claudeCodeDefaultModelId, geminiDefaultModelId, deepSeekDefaultModelId, mistralDefaultModelId, @@ -36,6 +37,7 @@ import { Anthropic, Bedrock, Chutes, + ClaudeCode, DeepSeek, Gemini, Glama, @@ -254,6 +256,7 @@ const ApiOptions = ({ requesty: { field: "requestyModelId", default: requestyDefaultModelId }, litellm: { field: "litellmModelId", default: litellmDefaultModelId }, anthropic: { field: "apiModelId", default: anthropicDefaultModelId }, + "claude-code": { field: "apiModelId", default: claudeCodeDefaultModelId }, "openai-native": { field: "apiModelId", default: openAiNativeDefaultModelId }, gemini: { field: "apiModelId", default: geminiDefaultModelId }, deepseek: { field: "apiModelId", default: deepSeekDefaultModelId }, @@ -383,6 +386,10 @@ const ApiOptions = ({ )} + {selectedProvider === "claude-code" && ( + + )} + {selectedProvider === "openai-native" && ( )} diff --git a/webview-ui/src/components/settings/constants.ts b/webview-ui/src/components/settings/constants.ts index 5b808643e5..8ea17cda8b 100644 --- a/webview-ui/src/components/settings/constants.ts +++ b/webview-ui/src/components/settings/constants.ts @@ -3,6 +3,7 @@ import { type ModelInfo, anthropicModels, bedrockModels, + claudeCodeModels, deepSeekModels, geminiModels, mistralModels, @@ -16,6 +17,7 @@ import { export const MODELS_BY_PROVIDER: Partial>> = { anthropic: anthropicModels, bedrock: bedrockModels, + "claude-code": claudeCodeModels, deepseek: deepSeekModels, gemini: geminiModels, mistral: mistralModels, @@ -47,4 +49,5 @@ export const PROVIDERS = [ { value: "groq", label: "Groq" }, { value: "chutes", label: "Chutes AI" }, { value: "litellm", label: "LiteLLM" }, + { value: "claude-code", label: "Claude Code" }, ].sort((a, b) => a.label.localeCompare(b.label)) diff --git a/webview-ui/src/components/settings/providers/ClaudeCode.tsx b/webview-ui/src/components/settings/providers/ClaudeCode.tsx new file mode 100644 index 0000000000..b687613330 --- /dev/null +++ b/webview-ui/src/components/settings/providers/ClaudeCode.tsx @@ -0,0 +1,44 @@ +import { useCallback } from "react" +import { VSCodeTextField } from "@vscode/webview-ui-toolkit/react" + +import type { ProviderSettings } from "@roo-code/types" + +import { useAppTranslation } from "@src/i18n/TranslationContext" + +import { inputEventTransform } from "../transforms" + +type ClaudeCodeProps = { + apiConfiguration: ProviderSettings + setApiConfigurationField: (field: keyof ProviderSettings, value: ProviderSettings[keyof ProviderSettings]) => void +} + +export const ClaudeCode = ({ apiConfiguration, setApiConfigurationField }: ClaudeCodeProps) => { + const { t } = useAppTranslation() + + const handleInputChange = useCallback( + ( + field: K, + transform: (event: E) => ProviderSettings[K] = inputEventTransform, + ) => + (event: E | Event) => { + setApiConfigurationField(field, transform(event as E)) + }, + [setApiConfigurationField], + ) + + return ( + <> + + + +
+ {t("settings:providers.claudeCodePathDescription")} +
+ + ) +} diff --git a/webview-ui/src/components/settings/providers/index.ts b/webview-ui/src/components/settings/providers/index.ts index b244fb515c..b195607430 100644 --- a/webview-ui/src/components/settings/providers/index.ts +++ b/webview-ui/src/components/settings/providers/index.ts @@ -1,6 +1,7 @@ export { Anthropic } from "./Anthropic" export { Bedrock } from "./Bedrock" export { Chutes } from "./Chutes" +export { ClaudeCode } from "./ClaudeCode" export { DeepSeek } from "./DeepSeek" export { Gemini } from "./Gemini" export { Glama } from "./Glama" diff --git a/webview-ui/src/i18n/locales/ca/settings.json b/webview-ui/src/i18n/locales/ca/settings.json index c88005ea61..12f8e61505 100644 --- a/webview-ui/src/i18n/locales/ca/settings.json +++ b/webview-ui/src/i18n/locales/ca/settings.json @@ -172,6 +172,8 @@ "anthropicApiKey": "Clau API d'Anthropic", "getAnthropicApiKey": "Obtenir clau API d'Anthropic", "anthropicUseAuthToken": "Passar la clau API d'Anthropic com a capçalera d'autorització en lloc de X-Api-Key", + "claudeCodePath": "Ruta de l'CLI de Claude Code", + "claudeCodePathDescription": "Ruta a l'executable de l'CLI de Claude Code. Per defecte: claude", "chutesApiKey": "Clau API de Chutes", "getChutesApiKey": "Obtenir clau API de Chutes", "deepSeekApiKey": "Clau API de DeepSeek", diff --git a/webview-ui/src/i18n/locales/de/settings.json b/webview-ui/src/i18n/locales/de/settings.json index 27a7486436..dbc9310860 100644 --- a/webview-ui/src/i18n/locales/de/settings.json +++ b/webview-ui/src/i18n/locales/de/settings.json @@ -172,6 +172,8 @@ "anthropicApiKey": "Anthropic API-Schlüssel", "getAnthropicApiKey": "Anthropic API-Schlüssel erhalten", "anthropicUseAuthToken": "Anthropic API-Schlüssel als Authorization-Header anstelle von X-Api-Key übergeben", + "claudeCodePath": "Claude Code CLI-Pfad", + "claudeCodePathDescription": "Pfad zur ausführbaren Datei von Claude Code CLI. Standard: claude", "chutesApiKey": "Chutes API-Schlüssel", "getChutesApiKey": "Chutes API-Schlüssel erhalten", "deepSeekApiKey": "DeepSeek API-Schlüssel", diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index b8e51afc50..c99f9b6835 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -172,6 +172,8 @@ "anthropicApiKey": "Anthropic API Key", "getAnthropicApiKey": "Get Anthropic API Key", "anthropicUseAuthToken": "Pass Anthropic API Key as Authorization header instead of X-Api-Key", + "claudeCodePath": "Claude Code CLI Path", + "claudeCodePathDescription": "Path to Claude Code CLI executable. Default: claude", "chutesApiKey": "Chutes API Key", "getChutesApiKey": "Get Chutes API Key", "deepSeekApiKey": "DeepSeek API Key", diff --git a/webview-ui/src/i18n/locales/es/settings.json b/webview-ui/src/i18n/locales/es/settings.json index db8b4736eb..609e16611e 100644 --- a/webview-ui/src/i18n/locales/es/settings.json +++ b/webview-ui/src/i18n/locales/es/settings.json @@ -172,6 +172,8 @@ "anthropicApiKey": "Clave API de Anthropic", "getAnthropicApiKey": "Obtener clave API de Anthropic", "anthropicUseAuthToken": "Pasar la clave API de Anthropic como encabezado de autorización en lugar de X-Api-Key", + "claudeCodePath": "Ruta de la CLI de Claude Code", + "claudeCodePathDescription": "Ruta al ejecutable de la CLI de Claude Code. Predeterminado: claude", "chutesApiKey": "Clave API de Chutes", "getChutesApiKey": "Obtener clave API de Chutes", "deepSeekApiKey": "Clave API de DeepSeek", diff --git a/webview-ui/src/i18n/locales/fr/settings.json b/webview-ui/src/i18n/locales/fr/settings.json index 0bf837accb..37c5e3f56f 100644 --- a/webview-ui/src/i18n/locales/fr/settings.json +++ b/webview-ui/src/i18n/locales/fr/settings.json @@ -172,6 +172,8 @@ "anthropicApiKey": "Clé API Anthropic", "getAnthropicApiKey": "Obtenir la clé API Anthropic", "anthropicUseAuthToken": "Passer la clé API Anthropic comme en-tête d'autorisation au lieu de X-Api-Key", + "claudeCodePath": "Chemin de l'CLI Claude Code", + "claudeCodePathDescription": "Chemin vers l'exécutable de l'CLI Claude Code. Par défaut : claude", "chutesApiKey": "Clé API Chutes", "getChutesApiKey": "Obtenir la clé API Chutes", "deepSeekApiKey": "Clé API DeepSeek", diff --git a/webview-ui/src/i18n/locales/hi/settings.json b/webview-ui/src/i18n/locales/hi/settings.json index fec1b27007..857e8963ae 100644 --- a/webview-ui/src/i18n/locales/hi/settings.json +++ b/webview-ui/src/i18n/locales/hi/settings.json @@ -172,6 +172,8 @@ "anthropicApiKey": "Anthropic API कुंजी", "getAnthropicApiKey": "Anthropic API कुंजी प्राप्त करें", "anthropicUseAuthToken": "X-Api-Key के बजाय Anthropic API कुंजी को Authorization हेडर के रूप में पास करें", + "claudeCodePath": "क्लाउड कोड CLI पथ", + "claudeCodePathDescription": "क्लाउड कोड CLI निष्पादन योग्य का पथ। डिफ़ॉल्ट: claude", "chutesApiKey": "Chutes API कुंजी", "getChutesApiKey": "Chutes API कुंजी प्राप्त करें", "deepSeekApiKey": "DeepSeek API कुंजी", diff --git a/webview-ui/src/i18n/locales/id/settings.json b/webview-ui/src/i18n/locales/id/settings.json index 6d6a8e93b1..11ea4864b6 100644 --- a/webview-ui/src/i18n/locales/id/settings.json +++ b/webview-ui/src/i18n/locales/id/settings.json @@ -176,6 +176,8 @@ "anthropicApiKey": "Anthropic API Key", "getAnthropicApiKey": "Dapatkan Anthropic API Key", "anthropicUseAuthToken": "Kirim Anthropic API Key sebagai Authorization header alih-alih X-Api-Key", + "claudeCodePath": "Jalur CLI Claude Code", + "claudeCodePathDescription": "Jalur ke executable CLI Claude Code. Default: claude", "chutesApiKey": "Chutes API Key", "getChutesApiKey": "Dapatkan Chutes API Key", "deepSeekApiKey": "DeepSeek API Key", diff --git a/webview-ui/src/i18n/locales/it/settings.json b/webview-ui/src/i18n/locales/it/settings.json index fcb389a4a7..25b60745fd 100644 --- a/webview-ui/src/i18n/locales/it/settings.json +++ b/webview-ui/src/i18n/locales/it/settings.json @@ -172,6 +172,8 @@ "anthropicApiKey": "Chiave API Anthropic", "getAnthropicApiKey": "Ottieni chiave API Anthropic", "anthropicUseAuthToken": "Passa la chiave API Anthropic come header di autorizzazione invece di X-Api-Key", + "claudeCodePath": "Percorso CLI Claude Code", + "claudeCodePathDescription": "Percorso dell'eseguibile CLI Claude Code. Predefinito: claude", "chutesApiKey": "Chiave API Chutes", "getChutesApiKey": "Ottieni chiave API Chutes", "deepSeekApiKey": "Chiave API DeepSeek", diff --git a/webview-ui/src/i18n/locales/ja/settings.json b/webview-ui/src/i18n/locales/ja/settings.json index eabd751308..de741dbc12 100644 --- a/webview-ui/src/i18n/locales/ja/settings.json +++ b/webview-ui/src/i18n/locales/ja/settings.json @@ -172,6 +172,8 @@ "anthropicApiKey": "Anthropic APIキー", "getAnthropicApiKey": "Anthropic APIキーを取得", "anthropicUseAuthToken": "Anthropic APIキーをX-Api-Keyの代わりにAuthorizationヘッダーとして渡す", + "claudeCodePath": "Claude Code CLIパス", + "claudeCodePathDescription": "Claude Code CLIの実行可能ファイルへのパス。デフォルト:claude", "chutesApiKey": "Chutes APIキー", "getChutesApiKey": "Chutes APIキーを取得", "deepSeekApiKey": "DeepSeek APIキー", diff --git a/webview-ui/src/i18n/locales/ko/settings.json b/webview-ui/src/i18n/locales/ko/settings.json index 68ca2a963c..7eb16b303a 100644 --- a/webview-ui/src/i18n/locales/ko/settings.json +++ b/webview-ui/src/i18n/locales/ko/settings.json @@ -172,6 +172,8 @@ "anthropicApiKey": "Anthropic API 키", "getAnthropicApiKey": "Anthropic API 키 받기", "anthropicUseAuthToken": "X-Api-Key 대신 Authorization 헤더로 Anthropic API 키 전달", + "claudeCodePath": "Claude Code CLI 경로", + "claudeCodePathDescription": "Claude Code CLI 실행 파일 경로. 기본값: claude", "chutesApiKey": "Chutes API 키", "getChutesApiKey": "Chutes API 키 받기", "deepSeekApiKey": "DeepSeek API 키", diff --git a/webview-ui/src/i18n/locales/nl/settings.json b/webview-ui/src/i18n/locales/nl/settings.json index 996c0c673c..9579805322 100644 --- a/webview-ui/src/i18n/locales/nl/settings.json +++ b/webview-ui/src/i18n/locales/nl/settings.json @@ -172,6 +172,8 @@ "anthropicApiKey": "Anthropic API-sleutel", "getAnthropicApiKey": "Anthropic API-sleutel ophalen", "anthropicUseAuthToken": "Anthropic API-sleutel als Authorization-header doorgeven in plaats van X-Api-Key", + "claudeCodePath": "Claude Code CLI-pad", + "claudeCodePathDescription": "Pad naar het uitvoerbare bestand van Claude Code CLI. Standaard: claude", "chutesApiKey": "Chutes API-sleutel", "getChutesApiKey": "Chutes API-sleutel ophalen", "deepSeekApiKey": "DeepSeek API-sleutel", diff --git a/webview-ui/src/i18n/locales/pl/settings.json b/webview-ui/src/i18n/locales/pl/settings.json index cf4421e00e..6bd750e0c4 100644 --- a/webview-ui/src/i18n/locales/pl/settings.json +++ b/webview-ui/src/i18n/locales/pl/settings.json @@ -172,6 +172,8 @@ "anthropicApiKey": "Klucz API Anthropic", "getAnthropicApiKey": "Uzyskaj klucz API Anthropic", "anthropicUseAuthToken": "Przekaż klucz API Anthropic jako nagłówek Authorization zamiast X-Api-Key", + "claudeCodePath": "Ścieżka CLI Claude Code", + "claudeCodePathDescription": "Ścieżka do pliku wykonywalnego CLI Claude Code. Domyślnie: claude", "chutesApiKey": "Klucz API Chutes", "getChutesApiKey": "Uzyskaj klucz API Chutes", "deepSeekApiKey": "Klucz API DeepSeek", diff --git a/webview-ui/src/i18n/locales/pt-BR/settings.json b/webview-ui/src/i18n/locales/pt-BR/settings.json index 229419dd23..1a289f1351 100644 --- a/webview-ui/src/i18n/locales/pt-BR/settings.json +++ b/webview-ui/src/i18n/locales/pt-BR/settings.json @@ -172,6 +172,8 @@ "anthropicApiKey": "Chave de API Anthropic", "getAnthropicApiKey": "Obter chave de API Anthropic", "anthropicUseAuthToken": "Passar a chave de API Anthropic como cabeçalho Authorization em vez de X-Api-Key", + "claudeCodePath": "Caminho da CLI Claude Code", + "claudeCodePathDescription": "Caminho para o executável da CLI Claude Code. Padrão: claude", "chutesApiKey": "Chave de API Chutes", "getChutesApiKey": "Obter chave de API Chutes", "deepSeekApiKey": "Chave de API DeepSeek", diff --git a/webview-ui/src/i18n/locales/ru/settings.json b/webview-ui/src/i18n/locales/ru/settings.json index dcce5e5b1a..1001679742 100644 --- a/webview-ui/src/i18n/locales/ru/settings.json +++ b/webview-ui/src/i18n/locales/ru/settings.json @@ -172,6 +172,8 @@ "anthropicApiKey": "Anthropic API-ключ", "getAnthropicApiKey": "Получить Anthropic API-ключ", "anthropicUseAuthToken": "Передавать Anthropic API-ключ как Authorization-заголовок вместо X-Api-Key", + "claudeCodePath": "Путь к CLI Claude Code", + "claudeCodePathDescription": "Путь к исполняемому файлу CLI Claude Code. По умолчанию: claude", "chutesApiKey": "Chutes API-ключ", "getChutesApiKey": "Получить Chutes API-ключ", "deepSeekApiKey": "DeepSeek API-ключ", diff --git a/webview-ui/src/i18n/locales/tr/settings.json b/webview-ui/src/i18n/locales/tr/settings.json index f8f53ae21c..dec2710213 100644 --- a/webview-ui/src/i18n/locales/tr/settings.json +++ b/webview-ui/src/i18n/locales/tr/settings.json @@ -172,6 +172,8 @@ "anthropicApiKey": "Anthropic API Anahtarı", "getAnthropicApiKey": "Anthropic API Anahtarı Al", "anthropicUseAuthToken": "Anthropic API Anahtarını X-Api-Key yerine Authorization başlığı olarak geçir", + "claudeCodePath": "Claude Code CLI Yolu", + "claudeCodePathDescription": "Claude Code CLI yürütülebilir dosyasının yolu. Varsayılan: claude", "chutesApiKey": "Chutes API Anahtarı", "getChutesApiKey": "Chutes API Anahtarı Al", "deepSeekApiKey": "DeepSeek API Anahtarı", diff --git a/webview-ui/src/i18n/locales/vi/settings.json b/webview-ui/src/i18n/locales/vi/settings.json index edb2b386b2..56f03e5751 100644 --- a/webview-ui/src/i18n/locales/vi/settings.json +++ b/webview-ui/src/i18n/locales/vi/settings.json @@ -172,6 +172,8 @@ "anthropicApiKey": "Khóa API Anthropic", "getAnthropicApiKey": "Lấy khóa API Anthropic", "anthropicUseAuthToken": "Truyền khóa API Anthropic dưới dạng tiêu đề Authorization thay vì X-Api-Key", + "claudeCodePath": "Đường dẫn CLI Claude Code", + "claudeCodePathDescription": "Đường dẫn đến tệp thực thi CLI Claude Code. Mặc định: claude", "chutesApiKey": "Khóa API Chutes", "getChutesApiKey": "Lấy khóa API Chutes", "deepSeekApiKey": "Khóa API DeepSeek", diff --git a/webview-ui/src/i18n/locales/zh-CN/settings.json b/webview-ui/src/i18n/locales/zh-CN/settings.json index 51ae2269e4..e6f25a3400 100644 --- a/webview-ui/src/i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/locales/zh-CN/settings.json @@ -172,6 +172,8 @@ "anthropicApiKey": "Anthropic API 密钥", "getAnthropicApiKey": "获取 Anthropic API 密钥", "anthropicUseAuthToken": "将 Anthropic API 密钥作为 Authorization 标头传递,而不是 X-Api-Key", + "claudeCodePath": "Claude Code CLI 路径", + "claudeCodePathDescription": "Claude Code CLI 可执行文件路径。默认值:claude", "chutesApiKey": "Chutes API 密钥", "getChutesApiKey": "获取 Chutes API 密钥", "deepSeekApiKey": "DeepSeek API 密钥", diff --git a/webview-ui/src/i18n/locales/zh-TW/settings.json b/webview-ui/src/i18n/locales/zh-TW/settings.json index 07544879cd..05b65a552d 100644 --- a/webview-ui/src/i18n/locales/zh-TW/settings.json +++ b/webview-ui/src/i18n/locales/zh-TW/settings.json @@ -172,6 +172,8 @@ "anthropicApiKey": "Anthropic API 金鑰", "getAnthropicApiKey": "取得 Anthropic API 金鑰", "anthropicUseAuthToken": "將 Anthropic API 金鑰作為 Authorization 標頭傳遞,而非使用 X-Api-Key", + "claudeCodePath": "Claude Code CLI 路徑", + "claudeCodePathDescription": "Claude Code CLI 可執行檔路徑。預設:claude", "chutesApiKey": "Chutes API 金鑰", "getChutesApiKey": "取得 Chutes API 金鑰", "deepSeekApiKey": "DeepSeek API 金鑰",