From b32987b3a1cc17c674b27e029a84ce5469b75477 Mon Sep 17 00:00:00 2001 From: jsmjsm <49445179+jsmjsm@users.noreply.github.com> Date: Mon, 16 Feb 2026 16:14:59 -0800 Subject: [PATCH 1/6] Add Cursor CLI and Gemini CLI backend support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the API proxy to route requests to three CLI backends (Claude, Cursor agent, Gemini) based on model name prefix, so openclaw bot can use any of them via the same OpenAI-compatible endpoint. New files: - src/subprocess/cursor.ts – CursorSubprocess (agent -p, stdin prompt) - src/subprocess/gemini.ts – GeminiSubprocess (gemini -p) - src/subprocess/factory.ts – resolveBackend() + createSubprocess() - src/types/common.ts – shared ContentDeltaEvent / ResultEvent Modified: - ClaudeSubprocess now emits standardised events - routes.ts is backend-agnostic (uses factory + common events) - /v1/models lists all three backends - standalone.ts verifies & reports all available CLIs on startup - Plugin entry point registers all backends for Clawdbot Co-authored-by: Cursor --- src/adapter/openai-to-cli.ts | 62 +-------- src/index.ts | 194 +++++++++++++++++++--------- src/server/index.ts | 2 +- src/server/routes.ts | 237 ++++++++++++++++++++++++----------- src/server/standalone.ts | 103 +++++++++++---- src/subprocess/cursor.ts | 237 +++++++++++++++++++++++++++++++++++ src/subprocess/factory.ts | 174 +++++++++++++++++++++++++ src/subprocess/gemini.ts | 229 +++++++++++++++++++++++++++++++++ src/subprocess/manager.ts | 71 ++++++----- src/types/common.ts | 55 ++++++++ 10 files changed, 1124 insertions(+), 240 deletions(-) create mode 100644 src/subprocess/cursor.ts create mode 100644 src/subprocess/factory.ts create mode 100644 src/subprocess/gemini.ts create mode 100644 src/types/common.ts diff --git a/src/adapter/openai-to-cli.ts b/src/adapter/openai-to-cli.ts index c8ecaa1..71bebf9 100644 --- a/src/adapter/openai-to-cli.ts +++ b/src/adapter/openai-to-cli.ts @@ -1,55 +1,16 @@ /** - * Converts OpenAI chat request format to Claude CLI input + * Converts OpenAI chat request messages to a single prompt string + * suitable for any CLI backend (Claude, Cursor, Gemini). + * + * All three CLIs accept a single prompt string in their non-interactive modes. */ import type { OpenAIChatRequest } from "../types/openai.js"; -export type ClaudeModel = "opus" | "sonnet" | "haiku"; - -export interface CliInput { - prompt: string; - model: ClaudeModel; - sessionId?: string; -} - -const MODEL_MAP: Record = { - // Direct model names - "claude-opus-4": "opus", - "claude-sonnet-4": "sonnet", - "claude-haiku-4": "haiku", - // With provider prefix - "claude-code-cli/claude-opus-4": "opus", - "claude-code-cli/claude-sonnet-4": "sonnet", - "claude-code-cli/claude-haiku-4": "haiku", - // Aliases - "opus": "opus", - "sonnet": "sonnet", - "haiku": "haiku", -}; - /** - * Extract Claude model alias from request model string - */ -export function extractModel(model: string): ClaudeModel { - // Try direct lookup - if (MODEL_MAP[model]) { - return MODEL_MAP[model]; - } - - // Try stripping provider prefix - const stripped = model.replace(/^claude-code-cli\//, ""); - if (MODEL_MAP[stripped]) { - return MODEL_MAP[stripped]; - } - - // Default to opus (Claude Max subscription) - return "opus"; -} - -/** - * Convert OpenAI messages array to a single prompt string for Claude CLI + * Convert OpenAI messages array to a single prompt string for CLI backends. * - * Claude Code CLI in --print mode expects a single prompt, not a conversation. + * CLI tools in --print mode expect a single prompt, not a conversation. * We format the messages into a readable format that preserves context. */ export function messagesToPrompt(messages: OpenAIChatRequest["messages"]): string { @@ -76,14 +37,3 @@ export function messagesToPrompt(messages: OpenAIChatRequest["messages"]): strin return parts.join("\n").trim(); } - -/** - * Convert OpenAI chat request to CLI input format - */ -export function openaiToCli(request: OpenAIChatRequest): CliInput { - return { - prompt: messagesToPrompt(request.messages), - model: extractModel(request.model), - sessionId: request.user, // Use OpenAI's user field for session mapping - }; -} diff --git a/src/index.ts b/src/index.ts index 420ee7d..ae0562b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,45 +1,102 @@ /** - * Claude Code CLI Provider Plugin for Clawdbot + * Multi-CLI API Proxy Plugin for Clawdbot * - * Enables using Claude Max subscription through Claude Code CLI, - * bypassing OAuth token scope restrictions. + * Enables using Claude Max, Cursor Pro, and Gemini subscriptions + * through their respective CLI tools, exposed as an OpenAI-compatible API. + * + * Supported backends: + * - Claude Code CLI (`claude`) — Claude Max subscription + * - Cursor CLI (`agent`) — Cursor Pro subscription + * - Gemini CLI (`gemini`) — Google Gemini subscription */ import { startServer, stopServer, getServer } from "./server/index.js"; import { verifyClaude, verifyAuth } from "./subprocess/manager.js"; +import { verifyCursor } from "./subprocess/cursor.js"; +import { verifyGemini } from "./subprocess/gemini.js"; // Provider constants -const PROVIDER_ID = "claude-code-cli"; -const PROVIDER_LABEL = "Claude Code CLI"; +const PROVIDER_ID = "multi-cli-proxy"; +const PROVIDER_LABEL = "Multi-CLI Proxy"; const DEFAULT_PORT = 3456; -const DEFAULT_MODEL = "claude-code-cli/claude-sonnet-4"; +const DEFAULT_MODEL = "claude-sonnet-4"; -// Available models -const AVAILABLE_MODELS = [ +// Available models across all backends +const CLAUDE_MODELS = [ { id: "claude-opus-4", name: "Claude Opus 4.5", - alias: "opus", reasoning: true, }, { id: "claude-sonnet-4", name: "Claude Sonnet 4", - alias: "sonnet", reasoning: false, }, { id: "claude-haiku-4", name: "Claude Haiku 4", - alias: "haiku", reasoning: false, }, ]; +const CURSOR_MODELS = [ + { + id: "cursor/opus-4.6-thinking", + name: "Cursor: Claude 4.6 Opus (Thinking)", + reasoning: true, + }, + { + id: "cursor/opus-4.6", + name: "Cursor: Claude 4.6 Opus", + reasoning: false, + }, + { + id: "cursor/sonnet-4.5-thinking", + name: "Cursor: Claude 4.5 Sonnet (Thinking)", + reasoning: true, + }, + { + id: "cursor/sonnet-4.5", + name: "Cursor: Claude 4.5 Sonnet", + reasoning: false, + }, + { + id: "cursor/gpt-5.3-codex", + name: "Cursor: GPT-5.3 Codex", + reasoning: false, + }, + { + id: "cursor/gpt-5.2", + name: "Cursor: GPT-5.2", + reasoning: false, + }, + { + id: "cursor/auto", + name: "Cursor: Auto", + reasoning: false, + }, +]; + +const GEMINI_MODELS = [ + { + id: "gemini-cli/gemini-2.5-pro", + name: "Gemini 2.5 Pro (CLI)", + reasoning: false, + }, + { + id: "gemini-cli/gemini-2.5-flash", + name: "Gemini 2.5 Flash (CLI)", + reasoning: false, + }, +]; + +const ALL_MODELS = [...CLAUDE_MODELS, ...CURSOR_MODELS, ...GEMINI_MODELS]; + /** * Build model definitions for Clawdbot config */ -function buildModelDefinition(model: (typeof AVAILABLE_MODELS)[number]) { +function buildModelDefinition(model: (typeof ALL_MODELS)[number]) { return { id: model.id, name: model.name, @@ -66,11 +123,11 @@ function emptyPluginConfigSchema() { /** * Plugin definition */ -const claudeCodeCliPlugin = { - id: "claude-code-cli-provider", - name: "Claude Code CLI Provider", +const multiCliProxyPlugin = { + id: "multi-cli-proxy-provider", + name: "Multi-CLI API Proxy", description: - "Use Claude Max subscription via Claude Code CLI (bypasses OAuth restrictions)", + "Use Claude Max, Cursor Pro, and Gemini subscriptions via their CLI tools (OpenAI-compatible API)", configSchema: emptyPluginConfigSchema(), register(api: any) { @@ -80,46 +137,56 @@ const claudeCodeCliPlugin = { api.registerProvider({ id: PROVIDER_ID, label: PROVIDER_LABEL, - docsPath: "/providers/claude-code-cli", - aliases: ["claude-cli", "claude-max"], - envVars: [], // No env vars needed - uses Claude CLI auth + docsPath: "/providers/multi-cli-proxy", + aliases: ["claude-cli", "cursor-cli", "gemini-cli", "claude-max"], + envVars: [], // No env vars needed - CLIs handle their own auth auth: [ { id: "local", - label: "Local Claude CLI", - hint: "Uses your existing Claude Code CLI authentication (from Claude Max)", + label: "Local CLI Proxy", + hint: "Uses your existing CLI authentication (Claude Max, Cursor Pro, Gemini)", kind: "custom", run: async (ctx: any) => { - const spin = ctx.prompter.progress("Checking Claude CLI..."); + const spin = ctx.prompter.progress("Checking CLI backends..."); try { - // 1. Verify Claude CLI is installed - const cliCheck = await verifyClaude(); - if (!cliCheck.ok) { - spin.stop("Claude CLI not found"); - await ctx.prompter.note( - "Install Claude Code: npm install -g @anthropic-ai/claude-code", - "Installation" - ); - throw new Error(cliCheck.error); + const availableBackends: string[] = []; + + // Check Claude CLI + const claudeCheck = await verifyClaude(); + if (claudeCheck.ok) { + const authCheck = await verifyAuth(); + if (authCheck.ok) { + availableBackends.push("claude"); + } } - spin.message("Claude CLI found, checking auth..."); - // 2. Verify authentication - const authCheck = await verifyAuth(); - if (!authCheck.ok) { - spin.stop("Not authenticated"); + // Check Cursor CLI + const cursorCheck = await verifyCursor(); + if (cursorCheck.ok) { + availableBackends.push("cursor"); + } + + // Check Gemini CLI + const geminiCheck = await verifyGemini(); + if (geminiCheck.ok) { + availableBackends.push("gemini"); + } + + if (availableBackends.length === 0) { + spin.stop("No CLI backends found"); await ctx.prompter.note( - "Run 'claude auth login' to authenticate with your Claude Max account", - "Authentication" + "Install at least one CLI: claude, agent, or gemini", + "Installation" ); - throw new Error(authCheck.error); + throw new Error("No CLI backends available"); } - spin.message("Authenticated, starting server..."); - // 3. Ask for port + spin.message(`Found backends: ${availableBackends.join(", ")}. Starting server...`); + + // Ask for port const portInput = await ctx.prompter.text({ message: "Local server port", initialValue: String(DEFAULT_PORT), @@ -133,12 +200,19 @@ const claudeCodeCliPlugin = { }); serverPort = parseInt(portInput, 10); - // 4. Start the local server + // Start the local server await startServer({ port: serverPort }); - spin.stop("Claude CLI provider ready"); + spin.stop("Multi-CLI proxy ready"); const baseUrl = `http://127.0.0.1:${serverPort}/v1`; + // Filter models to only include available backends + const availableModels = ALL_MODELS.filter((m) => { + if (m.id.startsWith("cursor/")) return availableBackends.includes("cursor"); + if (m.id.startsWith("gemini-cli/")) return availableBackends.includes("gemini"); + return availableBackends.includes("claude"); + }); + return { profiles: [ { @@ -146,7 +220,7 @@ const claudeCodeCliPlugin = { credential: { type: "token", provider: PROVIDER_ID, - token: "local", // Dummy token - CLI handles auth + token: "local", }, }, ], @@ -158,14 +232,14 @@ const claudeCodeCliPlugin = { apiKey: "local", api: "openai-completions", authHeader: false, - models: AVAILABLE_MODELS.map(buildModelDefinition), + models: availableModels.map(buildModelDefinition), }, }, }, agents: { defaults: { models: Object.fromEntries( - AVAILABLE_MODELS.map((m) => [ + availableModels.map((m) => [ `${PROVIDER_ID}/${m.id}`, {}, ]) @@ -175,10 +249,10 @@ const claudeCodeCliPlugin = { }, defaultModel: DEFAULT_MODEL, notes: [ - "This uses your Claude Max subscription via Claude Code CLI.", - "Your OAuth token is used by the CLI, not exposed directly.", + `Available backends: ${availableBackends.join(", ")}`, + "Uses your existing CLI subscriptions — no additional API costs.", `Local server running at http://127.0.0.1:${serverPort}`, - "Keep the server running to use this provider.", + "Model prefixes: claude-* (Claude CLI), cursor/* (Cursor CLI), gemini-cli/* (Gemini CLI)", ], }; } catch (err) { @@ -194,16 +268,16 @@ const claudeCodeCliPlugin = { api.on("plugin:unload", async () => { const server = getServer(); if (server) { - console.log("[ClaudeCodeCLI] Stopping server on plugin unload"); + console.log("[MultiCliProxy] Stopping server on plugin unload"); await stopServer(); } }); - // Register CLI command for manual server control + // Register CLI commands for manual server control api.registerCli?.((cli: any) => { cli - .command("claude-cli:start [port]") - .description("Start the Claude CLI proxy server") + .command("proxy:start [port]") + .description("Start the multi-CLI proxy server") .action(async (port: string) => { const p = parseInt(port || String(DEFAULT_PORT), 10); await startServer({ port: p }); @@ -211,16 +285,16 @@ const claudeCodeCliPlugin = { }); cli - .command("claude-cli:stop") - .description("Stop the Claude CLI proxy server") + .command("proxy:stop") + .description("Stop the multi-CLI proxy server") .action(async () => { await stopServer(); console.log("Server stopped"); }); cli - .command("claude-cli:status") - .description("Check Claude CLI proxy server status") + .command("proxy:status") + .description("Check multi-CLI proxy server status") .action(() => { const server = getServer(); if (server) { @@ -231,13 +305,15 @@ const claudeCodeCliPlugin = { }); }); - console.log("[ClaudeCodeCLI] Plugin registered"); + console.log("[MultiCliProxy] Plugin registered"); }, }; -export default claudeCodeCliPlugin; +export default multiCliProxyPlugin; // Also export server utilities for standalone use export { startServer, stopServer, getServer } from "./server/index.js"; export { ClaudeSubprocess, verifyClaude, verifyAuth } from "./subprocess/manager.js"; +export { CursorSubprocess, verifyCursor } from "./subprocess/cursor.js"; +export { GeminiSubprocess, verifyGemini } from "./subprocess/gemini.js"; export { sessionManager } from "./session/manager.js"; diff --git a/src/server/index.ts b/src/server/index.ts index de8b73d..9227821 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -101,7 +101,7 @@ export async function startServer(config: ServerConfig): Promise { }); serverInstance.listen(port, host, () => { - console.log(`[Server] Claude Code CLI provider running at http://${host}:${port}`); + console.log(`[Server] Multi-CLI API Proxy running at http://${host}:${port}`); console.log(`[Server] OpenAI-compatible endpoint: http://${host}:${port}/v1/chat/completions`); resolve(serverInstance!); }); diff --git a/src/server/routes.ts b/src/server/routes.ts index ffe2e5b..1fc1f57 100644 --- a/src/server/routes.ts +++ b/src/server/routes.ts @@ -1,24 +1,23 @@ /** * API Route Handlers * - * Implements OpenAI-compatible endpoints for Clawdbot integration + * Implements OpenAI-compatible endpoints that route to Claude CLI, + * Cursor CLI (agent), or Gemini CLI based on the requested model. */ import type { Request, Response } from "express"; +import type { EventEmitter } from "events"; import { v4 as uuidv4 } from "uuid"; -import { ClaudeSubprocess } from "../subprocess/manager.js"; -import { openaiToCli } from "../adapter/openai-to-cli.js"; -import { - cliResultToOpenai, - createDoneChunk, -} from "../adapter/cli-to-openai.js"; +import { createAndStartSubprocess, resolveBackend } from "../subprocess/factory.js"; +import { messagesToPrompt } from "../adapter/openai-to-cli.js"; import type { OpenAIChatRequest } from "../types/openai.js"; -import type { ClaudeCliAssistant, ClaudeCliResult, ClaudeCliStreamEvent } from "../types/claude-cli.js"; +import type { ContentDeltaEvent, ResultEvent } from "../types/common.js"; /** * Handle POST /v1/chat/completions * - * Main endpoint for chat requests, supports both streaming and non-streaming + * Main endpoint for chat requests, supports both streaming and non-streaming. + * Routes to the appropriate CLI backend based on model name. */ export async function handleChatCompletions( req: Request, @@ -41,14 +40,22 @@ export async function handleChatCompletions( return; } - // Convert to CLI input format - const cliInput = openaiToCli(body); - const subprocess = new ClaudeSubprocess(); + const model = body.model || "claude-sonnet-4"; + const resolved = resolveBackend(model); + const prompt = messagesToPrompt(body.messages); + + console.error( + `[handleChatCompletions] model="${model}" → backend=${resolved.backend}, cliModel="${resolved.cliModel}"` + ); + + const { subprocess, start } = createAndStartSubprocess(model, prompt, { + sessionId: body.user, + }); if (stream) { - await handleStreamingResponse(req, res, subprocess, cliInput, requestId); + await handleStreamingResponse(req, res, subprocess, start, requestId, model); } else { - await handleNonStreamingResponse(res, subprocess, cliInput, requestId); + await handleNonStreamingResponse(res, subprocess, start, requestId, model); } } catch (error) { const message = error instanceof Error ? error.message : "Unknown error"; @@ -69,16 +76,15 @@ export async function handleChatCompletions( /** * Handle streaming response (SSE) * - * IMPORTANT: The Express req.on("close") event fires when the request body - * is fully received, NOT when the client disconnects. For SSE connections, - * we use res.on("close") to detect actual client disconnection. + * Uses standardized events (content_delta, result) that all backends emit. */ async function handleStreamingResponse( req: Request, res: Response, - subprocess: ClaudeSubprocess, - cliInput: ReturnType, - requestId: string + subprocess: EventEmitter, + startSubprocess: () => Promise, + requestId: string, + requestedModel: string ): Promise { // Set SSE headers res.setHeader("Content-Type", "text/event-stream"); @@ -86,8 +92,7 @@ async function handleStreamingResponse( res.setHeader("Connection", "keep-alive"); res.setHeader("X-Request-Id", requestId); - // CRITICAL: Flush headers immediately to establish SSE connection - // Without this, headers are buffered and client times out waiting + // Flush headers immediately to establish SSE connection res.flushHeaders(); // Send initial comment to confirm connection is alive @@ -95,22 +100,27 @@ async function handleStreamingResponse( return new Promise((resolve, reject) => { let isFirst = true; - let lastModel = "claude-sonnet-4"; + let lastModel = requestedModel; let isComplete = false; - // Handle actual client disconnect (response stream closed) + // Helper to kill subprocess on disconnect + const killSubprocess = () => { + if ("kill" in subprocess && typeof (subprocess as any).kill === "function") { + (subprocess as any).kill(); + } + }; + + // Handle actual client disconnect res.on("close", () => { if (!isComplete) { - // Client disconnected before response completed - kill subprocess - subprocess.kill(); + killSubprocess(); } resolve(); }); - // Handle streaming content deltas - subprocess.on("content_delta", (event: ClaudeCliStreamEvent) => { - const text = event.event.delta?.text || ""; - if (text && !res.writableEnded) { + // Handle streaming content deltas (standardized across all backends) + subprocess.on("content_delta", (delta: ContentDeltaEvent) => { + if (delta.text && !res.writableEnded) { const chunk = { id: `chatcmpl-${requestId}`, object: "chat.completion.chunk", @@ -120,7 +130,7 @@ async function handleStreamingResponse( index: 0, delta: { role: isFirst ? "assistant" : undefined, - content: text, + content: delta.text, }, finish_reason: null, }], @@ -130,16 +140,25 @@ async function handleStreamingResponse( } }); - // Handle final assistant message (for model name) - subprocess.on("assistant", (message: ClaudeCliAssistant) => { - lastModel = message.message.model; - }); - - subprocess.on("result", (_result: ClaudeCliResult) => { + // Handle final result (standardized across all backends) + subprocess.on("result", (result: ResultEvent) => { isComplete = true; + if (result.model) { + lastModel = result.model; + } if (!res.writableEnded) { // Send final done chunk with finish_reason - const doneChunk = createDoneChunk(requestId, lastModel); + const doneChunk = { + id: `chatcmpl-${requestId}`, + object: "chat.completion.chunk", + created: Math.floor(Date.now() / 1000), + model: lastModel, + choices: [{ + index: 0, + delta: {}, + finish_reason: "stop", + }], + }; res.write(`data: ${JSON.stringify(doneChunk)}\n\n`); res.write("data: [DONE]\n\n"); res.end(); @@ -161,10 +180,8 @@ async function handleStreamingResponse( }); subprocess.on("close", (code: number | null) => { - // Subprocess exited - ensure response is closed if (!res.writableEnded) { if (code !== 0 && !isComplete) { - // Abnormal exit without result - send error res.write(`data: ${JSON.stringify({ error: { message: `Process exited with code ${code}`, type: "server_error", code: null }, })}\n\n`); @@ -176,10 +193,7 @@ async function handleStreamingResponse( }); // Start the subprocess - subprocess.start(cliInput.prompt, { - model: cliInput.model, - sessionId: cliInput.sessionId, - }).catch((err) => { + startSubprocess().catch((err) => { console.error("[Streaming] Subprocess start error:", err); reject(err); }); @@ -191,14 +205,15 @@ async function handleStreamingResponse( */ async function handleNonStreamingResponse( res: Response, - subprocess: ClaudeSubprocess, - cliInput: ReturnType, - requestId: string + subprocess: EventEmitter, + startSubprocess: () => Promise, + requestId: string, + requestedModel: string ): Promise { return new Promise((resolve) => { - let finalResult: ClaudeCliResult | null = null; + let finalResult: ResultEvent | null = null; - subprocess.on("result", (result: ClaudeCliResult) => { + subprocess.on("result", (result: ResultEvent) => { finalResult = result; }); @@ -216,11 +231,32 @@ async function handleNonStreamingResponse( subprocess.on("close", (code: number | null) => { if (finalResult) { - res.json(cliResultToOpenai(finalResult, requestId)); + const response = { + id: `chatcmpl-${requestId}`, + object: "chat.completion", + created: Math.floor(Date.now() / 1000), + model: finalResult.model || requestedModel, + choices: [{ + index: 0, + message: { + role: "assistant", + content: finalResult.text, + }, + finish_reason: "stop", + }], + usage: { + prompt_tokens: finalResult.usage?.input_tokens || 0, + completion_tokens: finalResult.usage?.output_tokens || 0, + total_tokens: + (finalResult.usage?.input_tokens || 0) + + (finalResult.usage?.output_tokens || 0), + }, + }; + res.json(response); } else if (!res.headersSent) { res.status(500).json({ error: { - message: `Claude CLI exited with code ${code} without response`, + message: `CLI exited with code ${code} without response`, type: "server_error", code: null, }, @@ -230,50 +266,110 @@ async function handleNonStreamingResponse( }); // Start the subprocess - subprocess - .start(cliInput.prompt, { - model: cliInput.model, - sessionId: cliInput.sessionId, - }) - .catch((error) => { - res.status(500).json({ - error: { - message: error.message, - type: "server_error", - code: null, - }, - }); - resolve(); + startSubprocess().catch((error) => { + res.status(500).json({ + error: { + message: error instanceof Error ? error.message : String(error), + type: "server_error", + code: null, + }, }); + resolve(); + }); }); } /** * Handle GET /v1/models * - * Returns available models + * Returns available models from all backends */ export function handleModels(_req: Request, res: Response): void { + const now = Math.floor(Date.now() / 1000); + res.json({ object: "list", data: [ + // ─── Claude CLI models ───────────────────────────────────────── { id: "claude-opus-4", object: "model", owned_by: "anthropic", - created: Math.floor(Date.now() / 1000), + created: now, }, { id: "claude-sonnet-4", object: "model", owned_by: "anthropic", - created: Math.floor(Date.now() / 1000), + created: now, }, { id: "claude-haiku-4", object: "model", owned_by: "anthropic", - created: Math.floor(Date.now() / 1000), + created: now, + }, + // ─── Cursor CLI models (popular subset) ──────────────────────── + { + id: "cursor/opus-4.6-thinking", + object: "model", + owned_by: "cursor", + created: now, + }, + { + id: "cursor/opus-4.6", + object: "model", + owned_by: "cursor", + created: now, + }, + { + id: "cursor/sonnet-4.5-thinking", + object: "model", + owned_by: "cursor", + created: now, + }, + { + id: "cursor/sonnet-4.5", + object: "model", + owned_by: "cursor", + created: now, + }, + { + id: "cursor/gpt-5.3-codex", + object: "model", + owned_by: "cursor", + created: now, + }, + { + id: "cursor/gpt-5.2", + object: "model", + owned_by: "cursor", + created: now, + }, + { + id: "cursor/gemini-3-pro", + object: "model", + owned_by: "cursor", + created: now, + }, + { + id: "cursor/auto", + object: "model", + owned_by: "cursor", + created: now, + }, + // ─── Gemini CLI models ───────────────────────────────────────── + { + id: "gemini-cli/gemini-2.5-pro", + object: "model", + owned_by: "google", + created: now, + }, + { + id: "gemini-cli/gemini-2.5-flash", + object: "model", + owned_by: "google", + created: now, }, ], }); @@ -287,7 +383,8 @@ export function handleModels(_req: Request, res: Response): void { export function handleHealth(_req: Request, res: Response): void { res.json({ status: "ok", - provider: "claude-code-cli", + provider: "multi-cli-proxy", + backends: ["claude", "cursor", "gemini"], timestamp: new Date().toISOString(), }); } diff --git a/src/server/standalone.ts b/src/server/standalone.ts index 0d4881f..a9d34c1 100644 --- a/src/server/standalone.ts +++ b/src/server/standalone.ts @@ -2,6 +2,8 @@ /** * Standalone server for testing without Clawdbot * + * Supports multiple CLI backends: Claude Code CLI, Cursor CLI (agent), Gemini CLI + * * Usage: * npm run start * # or @@ -10,12 +12,14 @@ import { startServer, stopServer } from "./index.js"; import { verifyClaude, verifyAuth } from "../subprocess/manager.js"; +import { verifyCursor } from "../subprocess/cursor.js"; +import { verifyGemini } from "../subprocess/gemini.js"; const DEFAULT_PORT = 3456; async function main(): Promise { - console.log("Claude Code CLI Provider - Standalone Server"); - console.log("============================================\n"); + console.log("Multi-CLI API Proxy - Standalone Server"); + console.log("=======================================\n"); // Parse port from command line const port = parseInt(process.argv[2] || String(DEFAULT_PORT), 10); @@ -24,33 +28,88 @@ async function main(): Promise { process.exit(1); } - // Verify Claude CLI - console.log("Checking Claude CLI..."); - const cliCheck = await verifyClaude(); - if (!cliCheck.ok) { - console.error(`Error: ${cliCheck.error}`); - process.exit(1); + // Track available backends + const backends: string[] = []; + + // ─── Verify Claude CLI ───────────────────────────────────────────── + console.log("Checking Claude CLI (claude)..."); + const claudeCheck = await verifyClaude(); + if (claudeCheck.ok) { + console.log(` ✓ Claude CLI: ${claudeCheck.version || "OK"}`); + const authCheck = await verifyAuth(); + if (authCheck.ok) { + console.log(" ✓ Claude Auth: OK"); + backends.push("claude"); + } else { + console.log(` ✗ Claude Auth: ${authCheck.error}`); + } + } else { + console.log(` ✗ ${claudeCheck.error}`); + } + + // ─── Verify Cursor CLI ──────────────────────────────────────────── + console.log("Checking Cursor CLI (agent)..."); + const cursorCheck = await verifyCursor(); + if (cursorCheck.ok) { + console.log(` ✓ Cursor CLI: ${cursorCheck.version || "OK"}`); + backends.push("cursor"); + } else { + console.log(` ✗ ${cursorCheck.error}`); + } + + // ─── Verify Gemini CLI ──────────────────────────────────────────── + console.log("Checking Gemini CLI (gemini)..."); + const geminiCheck = await verifyGemini(); + if (geminiCheck.ok) { + console.log(` ✓ Gemini CLI: ${geminiCheck.version || "OK"}`); + backends.push("gemini"); + } else { + console.log(` ✗ ${geminiCheck.error}`); } - console.log(` Claude CLI: ${cliCheck.version || "OK"}`); - - // Verify authentication - console.log("Checking authentication..."); - const authCheck = await verifyAuth(); - if (!authCheck.ok) { - console.error(`Error: ${authCheck.error}`); - console.error("Please run: claude auth login"); + + console.log(""); + + if (backends.length === 0) { + console.error("Error: No CLI backends available."); + console.error("Install at least one of:"); + console.error(" Claude: npm install -g @anthropic-ai/claude-code"); + console.error(" Cursor: https://docs.cursor.com/agent"); + console.error(" Gemini: npm install -g @anthropic-ai/gemini-cli"); process.exit(1); } - console.log(" Authentication: OK\n"); + + console.log(`Available backends: ${backends.join(", ")}\n`); // Start server try { await startServer({ port }); - console.log("\nServer ready. Test with:"); - console.log(` curl -X POST http://localhost:${port}/v1/chat/completions \\`); - console.log(` -H "Content-Type: application/json" \\`); - console.log(` -d '{"model": "claude-sonnet-4", "messages": [{"role": "user", "content": "Hello!"}]}'`); - console.log("\nPress Ctrl+C to stop.\n"); + console.log("\nServer ready. Examples:\n"); + + if (backends.includes("claude")) { + console.log(" # Claude CLI:"); + console.log(` curl -X POST http://localhost:${port}/v1/chat/completions \\`); + console.log(` -H "Content-Type: application/json" \\`); + console.log(` -d '{"model": "claude-sonnet-4", "messages": [{"role": "user", "content": "Hello!"}]}'`); + console.log(""); + } + + if (backends.includes("cursor")) { + console.log(" # Cursor CLI:"); + console.log(` curl -X POST http://localhost:${port}/v1/chat/completions \\`); + console.log(` -H "Content-Type: application/json" \\`); + console.log(` -d '{"model": "cursor/auto", "messages": [{"role": "user", "content": "Hello!"}]}'`); + console.log(""); + } + + if (backends.includes("gemini")) { + console.log(" # Gemini CLI:"); + console.log(` curl -X POST http://localhost:${port}/v1/chat/completions \\`); + console.log(` -H "Content-Type: application/json" \\`); + console.log(` -d '{"model": "gemini-cli/gemini-2.5-pro", "messages": [{"role": "user", "content": "Hello!"}]}'`); + console.log(""); + } + + console.log("Press Ctrl+C to stop.\n"); } catch (err) { console.error("Failed to start server:", err); process.exit(1); diff --git a/src/subprocess/cursor.ts b/src/subprocess/cursor.ts new file mode 100644 index 0000000..fb223e7 --- /dev/null +++ b/src/subprocess/cursor.ts @@ -0,0 +1,237 @@ +/** + * Cursor CLI (agent) Subprocess Manager + * + * Handles spawning and parsing output from the Cursor CLI `agent` command. + * The Cursor CLI uses a similar stream-json format to Claude CLI but with + * some key differences: + * - Prompt is piped via stdin (not passed as argument) + * - Streaming deltas are `type: "assistant"` messages with `timestamp_ms` + * - The final complete message has no `timestamp_ms` + * - Result messages have no usage/token stats + */ + +import { spawn, ChildProcess } from "child_process"; +import { EventEmitter } from "events"; +import type { ContentDeltaEvent, ResultEvent, SubprocessStartOptions } from "../types/common.js"; + +const DEFAULT_TIMEOUT = 300000; // 5 minutes + +export class CursorSubprocess extends EventEmitter { + private process: ChildProcess | null = null; + private buffer: string = ""; + private timeoutId: NodeJS.Timeout | null = null; + private isKilled: boolean = false; + + /** + * Start the Cursor CLI subprocess with the given prompt. + * Prompt is written to stdin because agent -p reads from stdin. + */ + async start(prompt: string, options: SubprocessStartOptions): Promise { + const args = this.buildArgs(options); + const timeout = options.timeout || DEFAULT_TIMEOUT; + + return new Promise((resolve, reject) => { + try { + this.process = spawn("agent", args, { + cwd: options.cwd || process.cwd(), + env: { ...process.env }, + stdio: ["pipe", "pipe", "pipe"], + }); + + this.timeoutId = setTimeout(() => { + if (!this.isKilled) { + this.isKilled = true; + this.process?.kill("SIGTERM"); + this.emit("error", new Error(`Request timed out after ${timeout}ms`)); + } + }, timeout); + + this.process.on("error", (err) => { + this.clearTimeout(); + if (err.message.includes("ENOENT")) { + reject( + new Error( + "Cursor CLI (agent) not found. Install from: https://docs.cursor.com/agent" + ) + ); + } else { + reject(err); + } + }); + + console.error(`[CursorSubprocess] Process spawned with PID: ${this.process.pid}`); + + // Write prompt to stdin, then close it + this.process.stdin?.write(prompt); + this.process.stdin?.end(); + + // Parse JSON stream from stdout + this.process.stdout?.on("data", (chunk: Buffer) => { + const data = chunk.toString(); + console.error(`[CursorSubprocess] Received ${data.length} bytes of stdout`); + this.buffer += data; + this.processBuffer(); + }); + + // Capture stderr for debugging + this.process.stderr?.on("data", (chunk: Buffer) => { + const errorText = chunk.toString().trim(); + if (errorText) { + console.error("[CursorSubprocess stderr]:", errorText.slice(0, 200)); + } + }); + + this.process.on("close", (code) => { + console.error(`[CursorSubprocess] Process closed with code: ${code}`); + this.clearTimeout(); + if (this.buffer.trim()) { + this.processBuffer(); + } + this.emit("close", code); + }); + + resolve(); + } catch (err) { + this.clearTimeout(); + reject(err); + } + }); + } + + /** + * Build CLI arguments for Cursor agent + */ + private buildArgs(options: SubprocessStartOptions): string[] { + const args = [ + "-p", // Print mode (non-interactive, reads from stdin) + "--output-format", "stream-json", + "--stream-partial-output", // Enable streaming deltas + ]; + + if (options.model) { + args.push("--model", options.model); + } + + return args; + } + + /** + * Process the buffer and emit parsed messages. + * + * Cursor CLI stream-json messages: + * - { type: "system", subtype: "init", ... } + * - { type: "user", message: { role: "user", content: [...] } } + * - { type: "assistant", message: { role: "assistant", content: [{ type: "text", text: "delta" }] }, timestamp_ms: ... } + * - { type: "assistant", message: { role: "assistant", content: [{ type: "text", text: "full" }] } } (final, no timestamp_ms) + * - { type: "result", subtype: "success", result: "full text", ... } + */ + private processBuffer(): void { + const lines = this.buffer.split("\n"); + this.buffer = lines.pop() || ""; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + + try { + const message = JSON.parse(trimmed); + this.handleMessage(message); + } catch { + this.emit("raw", trimmed); + } + } + } + + private lastModel: string = "cursor-auto"; + + private handleMessage(msg: any): void { + if (msg.type === "system" && msg.subtype === "init") { + if (msg.model) { + this.lastModel = msg.model; + } + return; + } + + if (msg.type === "assistant") { + // Extract text from content array + const content = msg.message?.content; + if (Array.isArray(content)) { + const text = content + .filter((c: any) => c.type === "text") + .map((c: any) => c.text) + .join(""); + + if (msg.timestamp_ms) { + // Streaming delta (has timestamp_ms) + const delta: ContentDeltaEvent = { text }; + this.emit("content_delta", delta); + } + // Final complete message (no timestamp_ms) — we don't need to emit, + // the result message will follow with the full text. + } + return; + } + + if (msg.type === "result") { + const result: ResultEvent = { + text: msg.result || "", + model: this.lastModel, + // Cursor CLI result has no usage stats + usage: undefined, + }; + this.emit("result", result); + return; + } + } + + private clearTimeout(): void { + if (this.timeoutId) { + clearTimeout(this.timeoutId); + this.timeoutId = null; + } + } + + kill(signal: NodeJS.Signals = "SIGTERM"): void { + if (!this.isKilled && this.process) { + this.isKilled = true; + this.clearTimeout(); + this.process.kill(signal); + } + } + + isRunning(): boolean { + return this.process !== null && !this.isKilled && this.process.exitCode === null; + } +} + +/** + * Verify that Cursor CLI (agent) is installed and accessible + */ +export async function verifyCursor(): Promise<{ ok: boolean; error?: string; version?: string }> { + return new Promise((resolve) => { + const proc = spawn("agent", ["--version"], { stdio: "pipe" }); + let output = ""; + + proc.stdout?.on("data", (chunk: Buffer) => { + output += chunk.toString(); + }); + + proc.on("error", () => { + resolve({ + ok: false, + error: "Cursor CLI (agent) not found. Install from: https://docs.cursor.com/agent", + }); + }); + + proc.on("close", (code) => { + if (code === 0) { + resolve({ ok: true, version: output.trim() }); + } else { + resolve({ + ok: false, + error: "Cursor CLI (agent) returned non-zero exit code", + }); + } + }); + }); +} diff --git a/src/subprocess/factory.ts b/src/subprocess/factory.ts new file mode 100644 index 0000000..573b243 --- /dev/null +++ b/src/subprocess/factory.ts @@ -0,0 +1,174 @@ +/** + * Subprocess Factory + * + * Determines which CLI backend to use based on the model name, + * and creates the appropriate subprocess instance. + */ + +import { EventEmitter } from "events"; +import { ClaudeSubprocess } from "./manager.js"; +import { CursorSubprocess } from "./cursor.js"; +import { GeminiSubprocess } from "./gemini.js"; +import type { BackendType, SubprocessStartOptions } from "../types/common.js"; + +/** + * Resolved backend info: which CLI to use and what model alias to pass + */ +export interface ResolvedBackend { + backend: BackendType; + cliModel: string; // Model name/alias to pass to the CLI +} + +// ─── Cursor CLI model IDs ─────────────────────────────────────────────────── +// From `agent --list-models` +const CURSOR_MODELS = new Set([ + "auto", + "composer-1.5", + "composer-1", + "gpt-5.3-codex", + "gpt-5.3-codex-low", + "gpt-5.3-codex-high", + "gpt-5.3-codex-xhigh", + "gpt-5.3-codex-fast", + "gpt-5.3-codex-low-fast", + "gpt-5.3-codex-high-fast", + "gpt-5.3-codex-xhigh-fast", + "gpt-5.2", + "gpt-5.2-codex", + "gpt-5.2-codex-high", + "gpt-5.2-codex-low", + "gpt-5.2-codex-xhigh", + "gpt-5.2-codex-fast", + "gpt-5.2-codex-high-fast", + "gpt-5.2-codex-low-fast", + "gpt-5.2-codex-xhigh-fast", + "gpt-5.1-codex-max", + "gpt-5.1-codex-max-high", + "opus-4.6-thinking", + "sonnet-4.5-thinking", + "gpt-5.2-high", + "opus-4.6", + "opus-4.5", + "opus-4.5-thinking", + "sonnet-4.5", + "gpt-5.1-high", + "gemini-3-pro", + "gemini-3-flash", + "grok", +]); + +// ─── Gemini CLI model patterns ────────────────────────────────────────────── +// Gemini CLI uses Google's model names +const GEMINI_CLI_MODELS = new Set([ + "gemini-2.5-pro", + "gemini-2.5-flash", + "gemini-2.0-flash", + "gemini-2.0-flash-lite", +]); + +// ─── Claude CLI model aliases ─────────────────────────────────────────────── +const CLAUDE_MODEL_MAP: Record = { + "claude-opus-4": "opus", + "claude-sonnet-4": "sonnet", + "claude-haiku-4": "haiku", + "opus": "opus", + "sonnet": "sonnet", + "haiku": "haiku", +}; + +/** + * Resolve which backend to use and what CLI model name to pass. + * + * Model routing rules: + * 1. "cursor/" prefix → Cursor CLI with + * 2. "gemini-cli/" prefix → Gemini CLI with + * 3. "claude/" or "claude-code-cli/" prefix → Claude CLI + * 4. Known Cursor model IDs → Cursor CLI + * 5. Known Gemini CLI model IDs → Gemini CLI + * 6. Known Claude aliases (opus/sonnet/haiku) → Claude CLI + * 7. Default → Claude CLI with "sonnet" + */ +export function resolveBackend(model: string): ResolvedBackend { + // 1. Explicit prefix routing + if (model.startsWith("cursor/")) { + const cliModel = model.slice("cursor/".length); + return { backend: "cursor", cliModel: cliModel || "auto" }; + } + + if (model.startsWith("gemini-cli/")) { + const cliModel = model.slice("gemini-cli/".length); + return { backend: "gemini", cliModel: cliModel || "" }; + } + + if (model.startsWith("claude/") || model.startsWith("claude-code-cli/")) { + const prefix = model.startsWith("claude/") ? "claude/" : "claude-code-cli/"; + const remainder = model.slice(prefix.length); + const cliModel = CLAUDE_MODEL_MAP[remainder] || "sonnet"; + return { backend: "claude", cliModel }; + } + + // 2. Known model IDs + if (CURSOR_MODELS.has(model)) { + return { backend: "cursor", cliModel: model }; + } + + if (GEMINI_CLI_MODELS.has(model)) { + return { backend: "gemini", cliModel: model }; + } + + if (CLAUDE_MODEL_MAP[model]) { + return { backend: "claude", cliModel: CLAUDE_MODEL_MAP[model] }; + } + + // 3. Pattern matching + if (model.startsWith("gpt-") || model.startsWith("grok")) { + return { backend: "cursor", cliModel: model }; + } + + // Default to Claude CLI + return { backend: "claude", cliModel: "sonnet" }; +} + +/** + * Create the appropriate subprocess for the given backend + */ +export function createSubprocess(backend: BackendType): EventEmitter { + switch (backend) { + case "cursor": + return new CursorSubprocess(); + case "gemini": + return new GeminiSubprocess(); + case "claude": + default: + return new ClaudeSubprocess(); + } +} + +/** + * Create a subprocess and start it with the resolved backend + */ +export function createAndStartSubprocess( + model: string, + prompt: string, + options: Omit +): { subprocess: EventEmitter; backend: BackendType; start: () => Promise } { + const resolved = resolveBackend(model); + const subprocess = createSubprocess(resolved.backend); + + const startFn = async () => { + const startOptions: SubprocessStartOptions = { + ...options, + model: resolved.cliModel, + }; + + if (subprocess instanceof ClaudeSubprocess) { + await subprocess.start(prompt, startOptions); + } else if (subprocess instanceof CursorSubprocess) { + await subprocess.start(prompt, startOptions); + } else if (subprocess instanceof GeminiSubprocess) { + await subprocess.start(prompt, startOptions); + } + }; + + return { subprocess, backend: resolved.backend, start: startFn }; +} diff --git a/src/subprocess/gemini.ts b/src/subprocess/gemini.ts new file mode 100644 index 0000000..6cf3f04 --- /dev/null +++ b/src/subprocess/gemini.ts @@ -0,0 +1,229 @@ +/** + * Gemini CLI Subprocess Manager + * + * Handles spawning and parsing output from the Gemini CLI `gemini` command. + * The Gemini CLI stream-json format differs from Claude CLI: + * - Init: { type: "init", session_id, model } + * - User: { type: "message", role: "user", content: "..." } + * - Delta: { type: "message", role: "assistant", content: "delta text", delta: true } + * - Result: { type: "result", status: "success", stats: { input_tokens, output_tokens, ... } } + */ + +import { spawn, ChildProcess } from "child_process"; +import { EventEmitter } from "events"; +import type { ContentDeltaEvent, ResultEvent, SubprocessStartOptions } from "../types/common.js"; + +const DEFAULT_TIMEOUT = 300000; // 5 minutes + +export class GeminiSubprocess extends EventEmitter { + private process: ChildProcess | null = null; + private buffer: string = ""; + private timeoutId: NodeJS.Timeout | null = null; + private isKilled: boolean = false; + + /** + * Start the Gemini CLI subprocess with the given prompt + */ + async start(prompt: string, options: SubprocessStartOptions): Promise { + const args = this.buildArgs(prompt, options); + const timeout = options.timeout || DEFAULT_TIMEOUT; + + return new Promise((resolve, reject) => { + try { + this.process = spawn("gemini", args, { + cwd: options.cwd || process.cwd(), + env: { ...process.env }, + stdio: ["pipe", "pipe", "pipe"], + }); + + this.timeoutId = setTimeout(() => { + if (!this.isKilled) { + this.isKilled = true; + this.process?.kill("SIGTERM"); + this.emit("error", new Error(`Request timed out after ${timeout}ms`)); + } + }, timeout); + + this.process.on("error", (err) => { + this.clearTimeout(); + if (err.message.includes("ENOENT")) { + reject( + new Error( + "Gemini CLI not found. Install with: npm install -g @anthropic-ai/gemini-cli or see https://github.com/google-gemini/gemini-cli" + ) + ); + } else { + reject(err); + } + }); + + // Close stdin since we pass prompt as argument + this.process.stdin?.end(); + + console.error(`[GeminiSubprocess] Process spawned with PID: ${this.process.pid}`); + + // Parse JSON stream from stdout + this.process.stdout?.on("data", (chunk: Buffer) => { + const data = chunk.toString(); + console.error(`[GeminiSubprocess] Received ${data.length} bytes of stdout`); + this.buffer += data; + this.processBuffer(); + }); + + // Capture stderr for debugging (gemini writes status messages to stderr) + this.process.stderr?.on("data", (chunk: Buffer) => { + const errorText = chunk.toString().trim(); + if (errorText) { + console.error("[GeminiSubprocess stderr]:", errorText.slice(0, 200)); + } + }); + + this.process.on("close", (code) => { + console.error(`[GeminiSubprocess] Process closed with code: ${code}`); + this.clearTimeout(); + if (this.buffer.trim()) { + this.processBuffer(); + } + this.emit("close", code); + }); + + resolve(); + } catch (err) { + this.clearTimeout(); + reject(err); + } + }); + } + + /** + * Build CLI arguments for Gemini. + * Gemini uses -p "prompt" (prompt is the value of -p flag). + */ + private buildArgs(prompt: string, options: SubprocessStartOptions): string[] { + const args = [ + "-p", prompt, // Non-interactive mode with prompt + "-o", "stream-json", // JSON streaming output + ]; + + if (options.model) { + args.push("-m", options.model); + } + + // Auto-approve tool use for headless operation + args.push("-y"); + + return args; + } + + /** + * Process the buffer and emit parsed messages. + * + * Gemini CLI stream-json messages: + * - { type: "init", session_id, model, timestamp } + * - { type: "message", role: "user", content: "...", timestamp } + * - { type: "message", role: "assistant", content: "delta text", delta: true, timestamp } + * - { type: "result", status: "success", stats: { input_tokens, output_tokens, duration_ms, ... }, timestamp } + */ + private processBuffer(): void { + const lines = this.buffer.split("\n"); + this.buffer = lines.pop() || ""; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + + try { + const message = JSON.parse(trimmed); + this.handleMessage(message); + } catch { + this.emit("raw", trimmed); + } + } + } + + private lastModel: string = "gemini"; + private fullText: string = ""; + + private handleMessage(msg: any): void { + if (msg.type === "init") { + if (msg.model) { + this.lastModel = msg.model; + } + return; + } + + if (msg.type === "message" && msg.role === "assistant") { + if (msg.delta) { + // Streaming delta + const delta: ContentDeltaEvent = { text: msg.content || "" }; + this.fullText += msg.content || ""; + this.emit("content_delta", delta); + } + return; + } + + if (msg.type === "result") { + const result: ResultEvent = { + text: this.fullText || "", + model: this.lastModel, + usage: msg.stats ? { + input_tokens: msg.stats.input_tokens || msg.stats.input || 0, + output_tokens: msg.stats.output_tokens || 0, + } : undefined, + }; + this.emit("result", result); + return; + } + } + + private clearTimeout(): void { + if (this.timeoutId) { + clearTimeout(this.timeoutId); + this.timeoutId = null; + } + } + + kill(signal: NodeJS.Signals = "SIGTERM"): void { + if (!this.isKilled && this.process) { + this.isKilled = true; + this.clearTimeout(); + this.process.kill(signal); + } + } + + isRunning(): boolean { + return this.process !== null && !this.isKilled && this.process.exitCode === null; + } +} + +/** + * Verify that Gemini CLI is installed and accessible + */ +export async function verifyGemini(): Promise<{ ok: boolean; error?: string; version?: string }> { + return new Promise((resolve) => { + const proc = spawn("gemini", ["--version"], { stdio: "pipe" }); + let output = ""; + + proc.stdout?.on("data", (chunk: Buffer) => { + output += chunk.toString(); + }); + + proc.on("error", () => { + resolve({ + ok: false, + error: "Gemini CLI not found. See: https://github.com/google-gemini/gemini-cli", + }); + }); + + proc.on("close", (code) => { + if (code === 0) { + resolve({ ok: true, version: output.trim() }); + } else { + resolve({ + ok: false, + error: "Gemini CLI returned non-zero exit code", + }); + } + }); + }); +} diff --git a/src/subprocess/manager.ts b/src/subprocess/manager.ts index 6551a81..d263852 100644 --- a/src/subprocess/manager.ts +++ b/src/subprocess/manager.ts @@ -3,12 +3,13 @@ * * Handles spawning, managing, and parsing output from Claude CLI subprocesses. * Uses spawn() instead of exec() to prevent shell injection vulnerabilities. + * + * Emits standardized events (ContentDeltaEvent, ResultEvent) for uniform + * handling across all CLI backends. */ import { spawn, ChildProcess } from "child_process"; import { EventEmitter } from "events"; -import fs from "fs/promises"; -import path from "path"; import type { ClaudeCliMessage, ClaudeCliAssistant, @@ -16,23 +17,9 @@ import type { ClaudeCliStreamEvent, } from "../types/claude-cli.js"; import { isAssistantMessage, isResultMessage, isContentDelta } from "../types/claude-cli.js"; -import type { ClaudeModel } from "../adapter/openai-to-cli.js"; - -export interface SubprocessOptions { - model: ClaudeModel; - sessionId?: string; - cwd?: string; - timeout?: number; -} +import type { ContentDeltaEvent, ResultEvent, SubprocessStartOptions } from "../types/common.js"; -export interface SubprocessEvents { - message: (msg: ClaudeCliMessage) => void; - assistant: (msg: ClaudeCliAssistant) => void; - result: (result: ClaudeCliResult) => void; - error: (error: Error) => void; - close: (code: number | null) => void; - raw: (line: string) => void; -} +export type ClaudeModel = "opus" | "sonnet" | "haiku"; const DEFAULT_TIMEOUT = 300000; // 5 minutes @@ -45,7 +32,7 @@ export class ClaudeSubprocess extends EventEmitter { /** * Start the Claude CLI subprocess with the given prompt */ - async start(prompt: string, options: SubprocessOptions): Promise { + async start(prompt: string, options: SubprocessStartOptions): Promise { const args = this.buildArgs(prompt, options); const timeout = options.timeout || DEFAULT_TIMEOUT; @@ -84,12 +71,12 @@ export class ClaudeSubprocess extends EventEmitter { // Close stdin since we pass prompt as argument this.process.stdin?.end(); - console.error(`[Subprocess] Process spawned with PID: ${this.process.pid}`); + console.error(`[ClaudeSubprocess] Process spawned with PID: ${this.process.pid}`); // Parse JSON stream from stdout this.process.stdout?.on("data", (chunk: Buffer) => { const data = chunk.toString(); - console.error(`[Subprocess] Received ${data.length} bytes of stdout`); + console.error(`[ClaudeSubprocess] Received ${data.length} bytes of stdout`); this.buffer += data; this.processBuffer(); }); @@ -100,13 +87,13 @@ export class ClaudeSubprocess extends EventEmitter { if (errorText) { // Don't emit as error unless it's actually an error // Claude CLI may write debug info to stderr - console.error("[Subprocess stderr]:", errorText.slice(0, 200)); + console.error("[ClaudeSubprocess stderr]:", errorText.slice(0, 200)); } }); // Handle process close this.process.on("close", (code) => { - console.error(`[Subprocess] Process closed with code: ${code}`); + console.error(`[ClaudeSubprocess] Process closed with code: ${code}`); this.clearTimeout(); // Process any remaining buffer if (this.buffer.trim()) { @@ -127,7 +114,7 @@ export class ClaudeSubprocess extends EventEmitter { /** * Build CLI arguments array */ - private buildArgs(prompt: string, options: SubprocessOptions): string[] { + private buildArgs(prompt: string, options: SubprocessStartOptions): string[] { const args = [ "--print", // Non-interactive mode "--output-format", @@ -135,8 +122,7 @@ export class ClaudeSubprocess extends EventEmitter { "--verbose", // Required for stream-json "--include-partial-messages", // Enable streaming chunks "--model", - options.model, // Model alias (opus/sonnet/haiku) - "--no-session-persistence", // Don't save sessions + options.model || "sonnet", // Model alias (opus/sonnet/haiku) prompt, // Pass prompt as argument (more reliable than stdin) ]; @@ -147,8 +133,11 @@ export class ClaudeSubprocess extends EventEmitter { return args; } + private lastModel: string = "claude-sonnet-4"; + /** - * Process the buffer and emit parsed messages + * Process the buffer and emit parsed messages. + * Emits standardized ContentDeltaEvent and ResultEvent. */ private processBuffer(): void { const lines = this.buffer.split("\n"); @@ -160,15 +149,33 @@ export class ClaudeSubprocess extends EventEmitter { try { const message: ClaudeCliMessage = JSON.parse(trimmed); - this.emit("message", message); if (isContentDelta(message)) { - // Emit content delta for streaming - this.emit("content_delta", message as ClaudeCliStreamEvent); + // Emit standardized content delta + const event = message as ClaudeCliStreamEvent; + const text = event.event.delta?.text || ""; + if (text) { + const delta: ContentDeltaEvent = { text }; + this.emit("content_delta", delta); + } } else if (isAssistantMessage(message)) { - this.emit("assistant", message); + const assistant = message as ClaudeCliAssistant; + this.lastModel = assistant.message.model || this.lastModel; } else if (isResultMessage(message)) { - this.emit("result", message); + const cliResult = message as ClaudeCliResult; + const modelName = cliResult.modelUsage + ? Object.keys(cliResult.modelUsage)[0] + : this.lastModel; + + const result: ResultEvent = { + text: cliResult.result, + model: modelName, + usage: cliResult.usage ? { + input_tokens: cliResult.usage.input_tokens || 0, + output_tokens: cliResult.usage.output_tokens || 0, + } : undefined, + }; + this.emit("result", result); } } catch { // Non-JSON output, emit as raw diff --git a/src/types/common.ts b/src/types/common.ts new file mode 100644 index 0000000..377bae6 --- /dev/null +++ b/src/types/common.ts @@ -0,0 +1,55 @@ +/** + * Common types shared across all CLI backends + * + * Standardized event interfaces that all subprocess managers emit, + * allowing routes.ts to handle any backend uniformly. + */ + +/** + * Which CLI backend to use + */ +export type BackendType = "claude" | "cursor" | "gemini"; + +/** + * Standardized streaming text delta event + */ +export interface ContentDeltaEvent { + text: string; +} + +/** + * Standardized final result event + */ +export interface ResultEvent { + text: string; + model: string; + usage?: { + input_tokens: number; + output_tokens: number; + }; +} + +/** + * Options for starting a subprocess (backend-agnostic) + */ +export interface SubprocessStartOptions { + model: string; + sessionId?: string; + cwd?: string; + timeout?: number; +} + +/** + * Common interface for all CLI subprocess managers + */ +export interface CliSubprocess { + start(prompt: string, options: SubprocessStartOptions): Promise; + kill(signal?: NodeJS.Signals): void; + isRunning(): boolean; + + on(event: "content_delta", listener: (delta: ContentDeltaEvent) => void): this; + on(event: "result", listener: (result: ResultEvent) => void): this; + on(event: "error", listener: (error: Error) => void): this; + on(event: "close", listener: (code: number | null) => void): this; + on(event: "raw", listener: (line: string) => void): this; +} From b1e316a65738d90cf6bc5e7ffaae420734e826a2 Mon Sep 17 00:00:00 2001 From: jsmjsm <49445179+jsmjsm@users.noreply.github.com> Date: Mon, 16 Feb 2026 16:29:25 -0800 Subject: [PATCH 2/6] Fix multimodal message content and add gemini/ prefix routing - Handle array-format message content (e.g. [{type:"text", text:"..."}]) that OpenClaw and other clients send, instead of only plain strings - Add gemini/ prefix routing in factory (alongside gemini-cli/) - Update OpenAIChatMessage type to accept string | content-part array Co-authored-by: Cursor --- package-lock.json | 7 +++++-- src/adapter/openai-to-cli.ts | 35 +++++++++++++++++++++++++++++++---- src/subprocess/factory.ts | 5 +++++ src/types/openai.ts | 10 +++++++++- 4 files changed, 50 insertions(+), 7 deletions(-) diff --git a/package-lock.json b/package-lock.json index cfdfc81..f6561bd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,20 @@ { - "name": "claude-code-cli-provider", + "name": "claude-max-api-proxy", "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "claude-code-cli-provider", + "name": "claude-max-api-proxy", "version": "1.0.0", "license": "MIT", "dependencies": { "express": "^4.21.2", "uuid": "^11.0.5" }, + "bin": { + "claude-max-api": "dist/server/standalone.js" + }, "devDependencies": { "@types/express": "^5.0.0", "@types/node": "^22.10.7", diff --git a/src/adapter/openai-to-cli.ts b/src/adapter/openai-to-cli.ts index 71bebf9..9f52940 100644 --- a/src/adapter/openai-to-cli.ts +++ b/src/adapter/openai-to-cli.ts @@ -5,7 +5,32 @@ * All three CLIs accept a single prompt string in their non-interactive modes. */ -import type { OpenAIChatRequest } from "../types/openai.js"; +import type { OpenAIChatRequest, OpenAIChatContent } from "../types/openai.js"; + +/** + * Extract plain text from OpenAI message content. + * + * Content can be: + * - A plain string: "Hello" + * - An array of parts: [{ type: "text", text: "Hello" }, { type: "image_url", ... }] + * + * We extract only the text parts and ignore images (CLIs don't support them). + */ +function contentToString(content: OpenAIChatContent): string { + if (typeof content === "string") { + return content; + } + + if (Array.isArray(content)) { + return content + .filter((part) => part.type === "text" && part.text) + .map((part) => part.text!) + .join(""); + } + + // Fallback for unexpected types + return String(content); +} /** * Convert OpenAI messages array to a single prompt string for CLI backends. @@ -17,20 +42,22 @@ export function messagesToPrompt(messages: OpenAIChatRequest["messages"]): strin const parts: string[] = []; for (const msg of messages) { + const text = contentToString(msg.content); + switch (msg.role) { case "system": // System messages become context instructions - parts.push(`\n${msg.content}\n\n`); + parts.push(`\n${text}\n\n`); break; case "user": // User messages are the main prompt - parts.push(msg.content); + parts.push(text); break; case "assistant": // Previous assistant responses for context - parts.push(`\n${msg.content}\n\n`); + parts.push(`\n${text}\n\n`); break; } } diff --git a/src/subprocess/factory.ts b/src/subprocess/factory.ts index 573b243..e3d030a 100644 --- a/src/subprocess/factory.ts +++ b/src/subprocess/factory.ts @@ -100,6 +100,11 @@ export function resolveBackend(model: string): ResolvedBackend { return { backend: "gemini", cliModel: cliModel || "" }; } + if (model.startsWith("gemini/")) { + const cliModel = model.slice("gemini/".length); + return { backend: "gemini", cliModel: cliModel || "" }; + } + if (model.startsWith("claude/") || model.startsWith("claude-code-cli/")) { const prefix = model.startsWith("claude/") ? "claude/" : "claude-code-cli/"; const remainder = model.slice(prefix.length); diff --git a/src/types/openai.ts b/src/types/openai.ts index c116658..0a0abe6 100644 --- a/src/types/openai.ts +++ b/src/types/openai.ts @@ -3,9 +3,17 @@ * Used for Clawdbot integration */ +/** + * Content can be a plain string or an array of content parts (multimodal). + * OpenClaw and other clients may send either format. + */ +export type OpenAIChatContent = + | string + | Array<{ type: string; text?: string; image_url?: { url: string } }>; + export interface OpenAIChatMessage { role: "system" | "user" | "assistant"; - content: string; + content: OpenAIChatContent; } export interface OpenAIChatRequest { From 58647a3686668b9f5a40eddfa6ca2d5561da1b85 Mon Sep 17 00:00:00 2001 From: jsmjsm <49445179+jsmjsm@users.noreply.github.com> Date: Mon, 16 Feb 2026 16:44:30 -0800 Subject: [PATCH 3/6] Update README for multi-CLI proxy with Cursor and Gemini support Rewrite documentation to cover all three backends, model routing, OpenClaw configuration, and updated architecture diagram. Co-authored-by: Cursor --- README.md | 283 +++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 214 insertions(+), 69 deletions(-) diff --git a/README.md b/README.md index f976dfa..19e1151 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,16 @@ -# Claude Code CLI Provider +# Multi-CLI API Proxy -**Use your Claude Max subscription ($200/month) with any OpenAI-compatible client — no separate API costs!** +**Use your Claude Max, Cursor Pro, and Gemini subscriptions with any OpenAI-compatible client — no separate API costs!** -This provider wraps the Claude Code CLI as a subprocess and exposes an OpenAI-compatible HTTP API, allowing tools like Clawdbot, Continue.dev, or any OpenAI-compatible client to use your Claude Max subscription instead of paying per-API-call. +This proxy wraps multiple AI CLI tools (Claude Code CLI, Cursor CLI, Gemini CLI) as subprocesses and exposes a unified OpenAI-compatible HTTP API, allowing tools like OpenClaw, Continue.dev, or any OpenAI-compatible client to use your existing subscriptions instead of paying per-API-call. + +## Supported Backends + +| Backend | CLI Command | Subscription | Models | +|---------|------------|--------------|--------| +| **Claude Code CLI** | `claude` | Claude Max ($200/month) | Opus, Sonnet, Haiku | +| **Cursor CLI** | `agent` | Cursor Pro | Opus 4.6, Sonnet 4.5, GPT-5.x, Gemini 3, Grok | +| **Gemini CLI** | `gemini` | Google Gemini | Gemini 2.5 Pro/Flash, 2.0 Flash | ## Why This Exists @@ -10,53 +18,71 @@ This provider wraps the Claude Code CLI as a subprocess and exposes an OpenAI-co |----------|------|------------| | Claude API | ~$15/M input, ~$75/M output tokens | Pay per use | | Claude Max | $200/month flat | OAuth blocked for third-party API use | -| **This Provider** | $0 extra (uses Max subscription) | Routes through CLI | - -Anthropic blocks OAuth tokens from being used directly with third-party API clients. However, the Claude Code CLI *can* use OAuth tokens. This provider bridges that gap by wrapping the CLI and exposing a standard API. +| Cursor Pro | $20/month | No public API | +| Google Gemini | Free tier / paid | CLI-only access | +| **This Proxy** | $0 extra (uses existing subscriptions) | Routes through CLIs | ## How It Works ``` -Your App (Clawdbot, etc.) +Your App (OpenClaw, Continue.dev, Python client, etc.) ↓ HTTP Request (OpenAI format) ↓ - Claude Code CLI Provider (this project) - ↓ - Claude Code CLI (subprocess) - ↓ - OAuth Token (from Max subscription) - ↓ - Anthropic API - ↓ + Multi-CLI API Proxy (this project) + ↓ resolves model → backend + ┌────┼────────────┐ + ↓ ↓ ↓ + claude agent gemini + (CLI) (CLI) (CLI) + ↓ ↓ ↓ + Anthropic Cursor Google + API servers API + ↓ ↓ ↓ Response → OpenAI format → Your App ``` ## Features +- **Three CLI backends** — Claude, Cursor, and Gemini behind one API - **OpenAI-compatible API** — Works with any client that supports OpenAI's API format - **Streaming support** — Real-time token streaming via Server-Sent Events -- **Multiple models** — Claude Opus, Sonnet, and Haiku +- **Automatic routing** — Model name determines which backend handles the request +- **Multimodal content** — Handles both plain string and array-format message content - **Session management** — Maintains conversation context - **Auto-start service** — Optional LaunchAgent for macOS -- **Zero configuration** — Uses existing Claude CLI authentication -- **Secure by design** — Uses spawn() to prevent shell injection +- **Zero configuration** — Uses existing CLI authentication +- **Secure by design** — Uses `spawn()` to prevent shell injection ## Prerequisites -1. **Claude Max subscription** ($200/month) — [Subscribe here](https://claude.ai) -2. **Claude Code CLI** installed and authenticated: - ```bash - npm install -g @anthropic-ai/claude-code - claude auth login - ``` +Install at least **one** CLI backend: + +### Claude Code CLI (optional) +```bash +npm install -g @anthropic-ai/claude-code +claude auth login +``` + +### Cursor CLI (optional) +Install from [docs.cursor.com/agent](https://docs.cursor.com/agent), then: +```bash +agent login +``` + +### Gemini CLI (optional) +```bash +npm install -g @anthropic-ai/gemini-cli +# or see https://github.com/google-gemini/gemini-cli +gemini # Follow auth prompts on first run +``` ## Installation ```bash # Clone the repository -git clone https://github.com/anthropics/claude-code-cli-provider.git -cd claude-code-cli-provider +git clone https://github.com/atalovesyou/claude-max-api-proxy.git +cd claude-max-api-proxy # Install dependencies npm install @@ -70,10 +96,27 @@ npm run build ### Start the server ```bash -node dist/server/standalone.js +npm start +# or +node dist/server/standalone.js [port] ``` -The server runs at `http://localhost:3456` by default. +The server starts at `http://localhost:3456` by default and auto-detects which CLIs are available: + +``` +Multi-CLI API Proxy - Standalone Server +======================================= + +Checking Claude CLI (claude)... + ✓ Claude CLI: 2.0.22 (Claude Code) + ✓ Claude Auth: OK +Checking Cursor CLI (agent)... + ✓ Cursor CLI: 2026.02.13 +Checking Gemini CLI (gemini)... + ✓ Gemini CLI: 0.28.2 + +Available backends: claude, cursor, gemini +``` ### Test it @@ -81,22 +124,38 @@ The server runs at `http://localhost:3456` by default. # Health check curl http://localhost:3456/health -# List models +# List all available models curl http://localhost:3456/v1/models -# Chat completion (non-streaming) +# Claude CLI — chat completion +curl -X POST http://localhost:3456/v1/chat/completions \ + -H "Content-Type: application/json" \ + -d '{ + "model": "claude-sonnet-4", + "messages": [{"role": "user", "content": "Hello!"}] + }' + +# Cursor CLI — chat completion +curl -X POST http://localhost:3456/v1/chat/completions \ + -H "Content-Type: application/json" \ + -d '{ + "model": "cursor/opus-4.6", + "messages": [{"role": "user", "content": "Hello!"}] + }' + +# Gemini CLI — chat completion curl -X POST http://localhost:3456/v1/chat/completions \ -H "Content-Type: application/json" \ -d '{ - "model": "claude-opus-4", + "model": "gemini-cli/gemini-2.5-pro", "messages": [{"role": "user", "content": "Hello!"}] }' -# Chat completion (streaming) +# Streaming (any backend) curl -N -X POST http://localhost:3456/v1/chat/completions \ -H "Content-Type: application/json" \ -d '{ - "model": "claude-opus-4", + "model": "cursor/auto", "messages": [{"role": "user", "content": "Hello!"}], "stream": true }' @@ -106,29 +165,76 @@ curl -N -X POST http://localhost:3456/v1/chat/completions \ | Endpoint | Method | Description | |----------|--------|-------------| -| `/health` | GET | Health check | -| `/v1/models` | GET | List available models | +| `/health` | GET | Health check (lists available backends) | +| `/v1/models` | GET | List available models from all backends | | `/v1/chat/completions` | POST | Chat completions (streaming & non-streaming) | ## Available Models -| Model ID | Maps To | -|----------|---------| +### Claude CLI (`claude`) + +| Model ID | Description | +|----------|-------------| | `claude-opus-4` | Claude Opus 4.5 | | `claude-sonnet-4` | Claude Sonnet 4 | | `claude-haiku-4` | Claude Haiku 4 | +### Cursor CLI (`agent`) + +Use the `cursor/` prefix: + +| Model ID | Description | +|----------|-------------| +| `cursor/auto` | Auto-select best model | +| `cursor/opus-4.6-thinking` | Claude 4.6 Opus (Thinking) | +| `cursor/opus-4.6` | Claude 4.6 Opus | +| `cursor/sonnet-4.5-thinking` | Claude 4.5 Sonnet (Thinking) | +| `cursor/sonnet-4.5` | Claude 4.5 Sonnet | +| `cursor/gpt-5.3-codex` | GPT-5.3 Codex | +| `cursor/gpt-5.2` | GPT-5.2 | +| `cursor/gemini-3-pro` | Gemini 3 Pro | +| `cursor/grok` | Grok | + +Run `agent --list-models` to see all available Cursor models. + +### Gemini CLI (`gemini`) + +Use the `gemini-cli/` or `gemini/` prefix: + +| Model ID | Description | +|----------|-------------| +| `gemini-cli/gemini-2.5-pro` | Gemini 2.5 Pro | +| `gemini-cli/gemini-2.5-flash` | Gemini 2.5 Flash | +| `gemini/gemini-2.5-pro` | Gemini 2.5 Pro (alias) | + +## Model Routing + +The proxy determines which CLI backend to use based on the model name: + +| Model Pattern | Backend | Example | +|--------------|---------|---------| +| `cursor/*` | Cursor CLI | `cursor/opus-4.6` | +| `gemini-cli/*` or `gemini/*` | Gemini CLI | `gemini-cli/gemini-2.5-pro` | +| `claude-*`, `opus`, `sonnet`, `haiku` | Claude CLI | `claude-sonnet-4` | +| `gpt-*`, `grok` | Cursor CLI | `gpt-5.3-codex` | +| Everything else | Claude CLI (default) | — | + ## Configuration with Popular Tools -### Clawdbot +### OpenClaw -Clawdbot has **built-in support** for Claude CLI OAuth! Check your config: +Set the provider in your OpenClaw config: ```bash -clawdbot models status +openclaw config set models.providers.openai.baseUrl "http://127.0.0.1:3456/v1" +openclaw config set models.providers.openai.apiKey "not-needed" +openclaw config set agents.defaults.model.primary "openai/cursor/opus-4.6" ``` -If you see `anthropic:claude-cli=OAuth`, you're already using your Max subscription. +Then test: +```bash +openclaw agent --local -m "Hello!" --session-id test --json +``` ### Continue.dev @@ -137,9 +243,9 @@ Add to your Continue config: ```json { "models": [{ - "title": "Claude (Max)", + "title": "Cursor Opus 4.6", "provider": "openai", - "model": "claude-opus-4", + "model": "cursor/opus-4.6", "apiBase": "http://localhost:3456/v1", "apiKey": "not-needed" }] @@ -156,53 +262,72 @@ client = OpenAI( api_key="not-needed" # Any value works ) +# Use Cursor backend +response = client.chat.completions.create( + model="cursor/opus-4.6", + messages=[{"role": "user", "content": "Hello!"}] +) + +# Use Gemini backend +response = client.chat.completions.create( + model="gemini-cli/gemini-2.5-pro", + messages=[{"role": "user", "content": "Hello!"}] +) + +# Use Claude backend response = client.chat.completions.create( - model="claude-opus-4", + model="claude-sonnet-4", messages=[{"role": "user", "content": "Hello!"}] ) ``` ## Auto-Start on macOS -Create a LaunchAgent to start the provider automatically on login. See `docs/macos-setup.md` for detailed instructions. +Create a LaunchAgent to start the proxy automatically on login. See `docs/macos-setup.md` for detailed instructions. ## Architecture ``` src/ ├── types/ -│ ├── claude-cli.ts # Claude CLI JSON output types -│ └── openai.ts # OpenAI API types +│ ├── common.ts # Shared event types (ContentDeltaEvent, ResultEvent) +│ ├── claude-cli.ts # Claude CLI JSON output types +│ └── openai.ts # OpenAI API types (multimodal content support) ├── adapter/ -│ ├── openai-to-cli.ts # Convert OpenAI requests → CLI format -│ └── cli-to-openai.ts # Convert CLI responses → OpenAI format +│ └── openai-to-cli.ts # Convert OpenAI messages → CLI prompt string ├── subprocess/ -│ └── manager.ts # Claude CLI subprocess management +│ ├── manager.ts # Claude CLI subprocess (spawn "claude") +│ ├── cursor.ts # Cursor CLI subprocess (spawn "agent") +│ ├── gemini.ts # Gemini CLI subprocess (spawn "gemini") +│ └── factory.ts # Backend resolver & subprocess factory ├── session/ -│ └── manager.ts # Session ID mapping +│ └── manager.ts # Session ID mapping ├── server/ -│ ├── index.ts # Express server setup -│ ├── routes.ts # API route handlers -│ └── standalone.ts # Entry point -└── index.ts # Package exports +│ ├── index.ts # Express server setup +│ ├── routes.ts # API route handlers (backend-agnostic) +│ └── standalone.ts # CLI entry point +└── index.ts # Package exports & plugin definition ``` ## Security - Uses Node.js `spawn()` instead of shell execution to prevent injection attacks -- No API keys stored or transmitted by this provider -- All authentication handled by Claude CLI's secure keychain storage -- Prompts passed as CLI arguments, not through shell interpretation +- No API keys stored or transmitted by this proxy +- All authentication handled by each CLI's own secure credential storage +- Prompts passed as CLI arguments or stdin, not through shell interpretation +- Server binds to `127.0.0.1` only (not exposed externally) ## Cost Savings Example -| Usage | API Cost | With This Provider | -|-------|----------|-------------------| -| 1M input tokens/month | ~$15 | $0 (included in Max) | -| 500K output tokens/month | ~$37.50 | $0 (included in Max) | -| **Monthly Total** | **~$52.50** | **$0 extra** | +| Usage | API Cost | With This Proxy | +|-------|----------|----------------| +| 1M input tokens/month (Claude) | ~$15 | $0 (included in Max) | +| 500K output tokens/month (Claude) | ~$37.50 | $0 (included in Max) | +| Cursor Pro models | Not available via API | $0 (included in Pro) | +| Gemini CLI | Free tier | $0 | +| **Monthly Total** | **~$52.50+** | **$0 extra** | -If you're already paying for Claude Max, this provider lets you use that subscription for API-style access at no additional cost. +If you're already paying for Claude Max, Cursor Pro, or using Gemini's free tier, this proxy lets you use those subscriptions for API-style access at no additional cost. ## Troubleshooting @@ -214,6 +339,21 @@ npm install -g @anthropic-ai/claude-code claude auth login ``` +### "Cursor CLI (agent) not found" + +Install from [docs.cursor.com/agent](https://docs.cursor.com/agent): +```bash +agent login +agent status # Verify authentication +``` + +### "Gemini CLI not found" + +```bash +npm install -g @anthropic-ai/gemini-cli +gemini --version # Verify installation +``` + ### Streaming returns immediately with no content Ensure you're using `-N` flag with curl (disables buffering): @@ -221,13 +361,18 @@ Ensure you're using `-N` flag with curl (disables buffering): curl -N -X POST http://localhost:3456/v1/chat/completions ... ``` -### Server won't start +### Server won't start (port in use) -Check that the Claude CLI is in your PATH: ```bash -which claude +lsof -i :3456 # Find what's using the port +kill # Kill the process +npm start # Restart ``` +### OpenClaw shows `[object Object]` in responses + +Update to the latest version of this proxy — multimodal message content (array format) is now handled correctly. + ## Contributing Contributions welcome! Please submit PRs with tests. @@ -238,5 +383,5 @@ MIT ## Acknowledgments -- Built for use with [Clawdbot](https://clawd.bot) -- Powered by [Claude Code CLI](https://github.com/anthropics/claude-code) +- Built for use with [OpenClaw](https://openclaw.ai) and [Clawdbot](https://clawd.bot) +- Powered by [Claude Code CLI](https://github.com/anthropics/claude-code), [Cursor CLI](https://docs.cursor.com/agent), and [Gemini CLI](https://github.com/google-gemini/gemini-cli) From 89c396f5317fa035f2cf42786f2f7b13eeb17ae9 Mon Sep 17 00:00:00 2001 From: jsmjsm <49445179+jsmjsm@users.noreply.github.com> Date: Mon, 16 Feb 2026 16:45:35 -0800 Subject: [PATCH 4/6] Add dash-prefix model routing for OpenClaw compatibility OpenClaw custom providers can't use "/" in model IDs, so add dash-format aliases (e.g. cursor-opus-4.6, gemini-cli-gemini-2.5-pro) alongside the existing slash-format. Both the /v1/models endpoint and the backend resolver now handle either convention. Co-authored-by: Cursor --- src/server/routes.ts | 112 +++++++++++--------------------------- src/subprocess/factory.ts | 108 ++++++++++++++++++++++++++++++++---- 2 files changed, 129 insertions(+), 91 deletions(-) diff --git a/src/server/routes.ts b/src/server/routes.ts index 1fc1f57..ac2e0bf 100644 --- a/src/server/routes.ts +++ b/src/server/routes.ts @@ -282,7 +282,9 @@ async function handleNonStreamingResponse( /** * Handle GET /v1/models * - * Returns available models from all backends + * Returns available models from all backends. + * Lists both slash-format (for direct API callers) and dash-format + * (for OpenClaw custom provider integration) model IDs. */ export function handleModels(_req: Request, res: Response): void { const now = Math.floor(Date.now() / 1000); @@ -291,86 +293,38 @@ export function handleModels(_req: Request, res: Response): void { object: "list", data: [ // ─── Claude CLI models ───────────────────────────────────────── - { - id: "claude-opus-4", - object: "model", - owned_by: "anthropic", - created: now, - }, - { - id: "claude-sonnet-4", - object: "model", - owned_by: "anthropic", - created: now, - }, - { - id: "claude-haiku-4", - object: "model", - owned_by: "anthropic", - created: now, - }, + // Slash format (direct API usage) + { id: "claude-opus-4", object: "model", owned_by: "anthropic", created: now }, + { id: "claude-sonnet-4", object: "model", owned_by: "anthropic", created: now }, + { id: "claude-haiku-4", object: "model", owned_by: "anthropic", created: now }, + // ─── Cursor CLI models (popular subset) ──────────────────────── - { - id: "cursor/opus-4.6-thinking", - object: "model", - owned_by: "cursor", - created: now, - }, - { - id: "cursor/opus-4.6", - object: "model", - owned_by: "cursor", - created: now, - }, - { - id: "cursor/sonnet-4.5-thinking", - object: "model", - owned_by: "cursor", - created: now, - }, - { - id: "cursor/sonnet-4.5", - object: "model", - owned_by: "cursor", - created: now, - }, - { - id: "cursor/gpt-5.3-codex", - object: "model", - owned_by: "cursor", - created: now, - }, - { - id: "cursor/gpt-5.2", - object: "model", - owned_by: "cursor", - created: now, - }, - { - id: "cursor/gemini-3-pro", - object: "model", - owned_by: "cursor", - created: now, - }, - { - id: "cursor/auto", - object: "model", - owned_by: "cursor", - created: now, - }, + // Slash format (direct API usage) + { id: "cursor/auto", object: "model", owned_by: "cursor", created: now }, + { id: "cursor/opus-4.6-thinking", object: "model", owned_by: "cursor", created: now }, + { id: "cursor/opus-4.6", object: "model", owned_by: "cursor", created: now }, + { id: "cursor/sonnet-4.5-thinking", object: "model", owned_by: "cursor", created: now }, + { id: "cursor/sonnet-4.5", object: "model", owned_by: "cursor", created: now }, + { id: "cursor/gpt-5.3-codex", object: "model", owned_by: "cursor", created: now }, + { id: "cursor/gpt-5.2", object: "model", owned_by: "cursor", created: now }, + { id: "cursor/gemini-3-pro", object: "model", owned_by: "cursor", created: now }, + // Dash format (OpenClaw custom provider: model IDs can't contain "/") + { id: "cursor-auto", object: "model", owned_by: "cursor", created: now }, + { id: "cursor-opus-4.6-thinking", object: "model", owned_by: "cursor", created: now }, + { id: "cursor-opus-4.6", object: "model", owned_by: "cursor", created: now }, + { id: "cursor-sonnet-4.5-thinking", object: "model", owned_by: "cursor", created: now }, + { id: "cursor-sonnet-4.5", object: "model", owned_by: "cursor", created: now }, + { id: "cursor-gpt-5.3-codex", object: "model", owned_by: "cursor", created: now }, + { id: "cursor-gpt-5.2", object: "model", owned_by: "cursor", created: now }, + { id: "cursor-gemini-3-pro", object: "model", owned_by: "cursor", created: now }, + // ─── Gemini CLI models ───────────────────────────────────────── - { - id: "gemini-cli/gemini-2.5-pro", - object: "model", - owned_by: "google", - created: now, - }, - { - id: "gemini-cli/gemini-2.5-flash", - object: "model", - owned_by: "google", - created: now, - }, + // Slash format + { id: "gemini-cli/gemini-2.5-pro", object: "model", owned_by: "google", created: now }, + { id: "gemini-cli/gemini-2.5-flash", object: "model", owned_by: "google", created: now }, + // Dash format (OpenClaw custom provider) + { id: "gemini-cli-gemini-2.5-pro", object: "model", owned_by: "google", created: now }, + { id: "gemini-cli-gemini-2.5-flash", object: "model", owned_by: "google", created: now }, ], }); } diff --git a/src/subprocess/factory.ts b/src/subprocess/factory.ts index e3d030a..b590d7b 100644 --- a/src/subprocess/factory.ts +++ b/src/subprocess/factory.ts @@ -79,17 +79,27 @@ const CLAUDE_MODEL_MAP: Record = { /** * Resolve which backend to use and what CLI model name to pass. * - * Model routing rules: - * 1. "cursor/" prefix → Cursor CLI with - * 2. "gemini-cli/" prefix → Gemini CLI with - * 3. "claude/" or "claude-code-cli/" prefix → Claude CLI - * 4. Known Cursor model IDs → Cursor CLI - * 5. Known Gemini CLI model IDs → Gemini CLI - * 6. Known Claude aliases (opus/sonnet/haiku) → Claude CLI - * 7. Default → Claude CLI with "sonnet" + * Model routing rules (checked in order): + * + * --- Slash-prefix format (direct API usage) --- + * 1. "cursor/" → Cursor CLI with + * 2. "gemini-cli/" or "gemini/" → Gemini CLI with + * 3. "claude/" or "claude-code-cli/" → Claude CLI + * + * --- Dash-prefix format (OpenClaw custom provider sends model IDs like this) --- + * 4. "cursor-" → Cursor CLI with (e.g. "cursor-auto" → "auto") + * 5. "gemini-cli-" → Gemini CLI with + * 6. "claude-" → Claude CLI (e.g. "claude-opus-4" → "opus") + * + * --- Bare model IDs --- + * 7. Known Cursor model IDs (from CURSOR_MODELS set) → Cursor CLI + * 8. Known Gemini CLI model IDs → Gemini CLI + * 9. Known Claude aliases (opus/sonnet/haiku) → Claude CLI + * 10. GPT/Grok pattern match → Cursor CLI + * 11. Default → Claude CLI with "sonnet" */ export function resolveBackend(model: string): ResolvedBackend { - // 1. Explicit prefix routing + // ─── 1-3. Slash-prefix routing (direct API callers) ───────────────── if (model.startsWith("cursor/")) { const cliModel = model.slice("cursor/".length); return { backend: "cursor", cliModel: cliModel || "auto" }; @@ -112,7 +122,27 @@ export function resolveBackend(model: string): ResolvedBackend { return { backend: "claude", cliModel }; } - // 2. Known model IDs + // ─── 4-6. Dash-prefix routing (OpenClaw custom provider format) ───── + // When registered as `models.providers.cli-proxy`, OpenClaw strips the + // "cli-proxy/" prefix and sends only the model ID. Since model IDs can't + // contain "/" in OpenClaw, we use dashes: "cursor-auto", "cursor-opus-4.6", etc. + const cursorDashMatch = matchDashPrefix(model, "cursor-", CURSOR_MODELS); + if (cursorDashMatch) { + return { backend: "cursor", cliModel: cursorDashMatch }; + } + + if (model.startsWith("gemini-cli-")) { + const cliModel = model.slice("gemini-cli-".length); + return { backend: "gemini", cliModel }; + } + + // Claude dash-prefix: "claude-opus-4" → "opus", "claude-sonnet-4" → "sonnet" + const claudeDashMatch = matchClaudeDashPrefix(model); + if (claudeDashMatch) { + return { backend: "claude", cliModel: claudeDashMatch }; + } + + // ─── 7-9. Bare model IDs ─────────────────────────────────────────── if (CURSOR_MODELS.has(model)) { return { backend: "cursor", cliModel: model }; } @@ -125,15 +155,69 @@ export function resolveBackend(model: string): ResolvedBackend { return { backend: "claude", cliModel: CLAUDE_MODEL_MAP[model] }; } - // 3. Pattern matching + // ─── 10. Pattern matching ────────────────────────────────────────── if (model.startsWith("gpt-") || model.startsWith("grok")) { return { backend: "cursor", cliModel: model }; } - // Default to Claude CLI + // ─── 11. Default → Claude CLI ────────────────────────────────────── return { backend: "claude", cliModel: "sonnet" }; } +/** + * Match a dash-prefixed model ID against known Cursor models. + * + * OpenClaw sends "cursor-auto", "cursor-opus-4.6-thinking", etc. + * We try progressively longer suffixes to find the best match in CURSOR_MODELS. + * + * Example: "cursor-opus-4.6-thinking" + * → try "opus-4.6-thinking" (found in CURSOR_MODELS) → return "opus-4.6-thinking" + * + * Example: "cursor-auto" + * → try "auto" (found in CURSOR_MODELS) → return "auto" + */ +function matchDashPrefix(model: string, prefix: string, knownModels: Set): string | null { + if (!model.startsWith(prefix)) return null; + + const remainder = model.slice(prefix.length); + if (!remainder) return null; + + // Direct match: the remainder is a known model + if (knownModels.has(remainder)) { + return remainder; + } + + // If not a direct match, it's still likely a Cursor model we just + // don't have in our hardcoded set — pass it through anyway. + // The Cursor CLI will validate it. + return remainder; +} + +/** + * Match Claude dash-prefixed model IDs. + * + * Handles: "claude-opus-4" → "opus", "claude-sonnet-4" → "sonnet", "claude-haiku-4" → "haiku" + */ +function matchClaudeDashPrefix(model: string): string | null { + // Only match if it starts with "claude-" but NOT "claude-code-cli-" (handled elsewhere) + if (!model.startsWith("claude-") || model.startsWith("claude-code-cli-")) return null; + + const remainder = model.slice("claude-".length); + + // Check against known Claude model aliases + if (CLAUDE_MODEL_MAP[remainder]) { + return CLAUDE_MODEL_MAP[remainder]; + } + + // Also check the full model name (e.g. "claude-opus-4" → look up "opus-4") + // Map common OpenClaw-style names to CLI aliases + if (remainder.startsWith("opus")) return "opus"; + if (remainder.startsWith("sonnet")) return "sonnet"; + if (remainder.startsWith("haiku")) return "haiku"; + + return null; +} + /** * Create the appropriate subprocess for the given backend */ From 8398422593b0861264250456cbc6f5cff2c6908f Mon Sep 17 00:00:00 2001 From: jsmjsm <49445179+jsmjsm@users.noreply.github.com> Date: Mon, 16 Feb 2026 16:50:39 -0800 Subject: [PATCH 5/6] Update OpenClaw integration guide with both provider approaches Document both the simpler openai-provider setup (slash-format IDs) and the custom cli-proxy provider (dash-format IDs). Add verification steps, per-agent config, rate-limit fallback tips, and troubleshooting for the [object Object] content issue. Co-authored-by: Cursor --- docs/openclaw-integration.md | 254 +++++++++++++++++++++++++++++++++++ 1 file changed, 254 insertions(+) create mode 100644 docs/openclaw-integration.md diff --git a/docs/openclaw-integration.md b/docs/openclaw-integration.md new file mode 100644 index 0000000..c666cb7 --- /dev/null +++ b/docs/openclaw-integration.md @@ -0,0 +1,254 @@ +# OpenClaw Integration Guide + +This guide explains how to use the Multi-CLI API Proxy as a model provider in OpenClaw. + +## Prerequisites + +1. The proxy server running on `http://localhost:3456` +2. At least one CLI backend installed (Cursor Agent CLI, Gemini CLI, or Claude Code CLI) + +## Two Provider Approaches + +OpenClaw uses `provider/model` format for model references. The first `/` splits the +provider name from the model ID. You have two options for setting up the proxy: + +### Option A: Register as `openai` provider (simpler) + +If you register the proxy under the built-in `openai` provider, model IDs **can** +contain slashes (OpenClaw treats the full string after the first `/` as the model ID): + +| OpenClaw model ref | Proxy receives | Routes to | +|---------------------------------|-------------------------|-----------------------| +| `openai/cursor/opus-4.6` | `cursor/opus-4.6` | Cursor CLI: opus-4.6 | +| `openai/gemini-cli/gemini-2.5-pro` | `gemini-cli/gemini-2.5-pro` | Gemini CLI: gemini-2.5-pro | +| `openai/claude-sonnet-4` | `claude-sonnet-4` | Claude CLI: sonnet | + +### Option B: Register as a custom provider (e.g. `cli-proxy`) + +When registered as a custom provider, model IDs **cannot contain `/`**, so use +dash-separated IDs instead: + +| OpenClaw model ref | Proxy receives | Routes to | +|-----------------------------------------|-------------------------------|-------------------------------| +| `cli-proxy/cursor-auto` | `cursor-auto` | Cursor CLI: auto | +| `cli-proxy/cursor-opus-4.6-thinking` | `cursor-opus-4.6-thinking` | Cursor CLI: opus-4.6-thinking | +| `cli-proxy/gemini-cli-gemini-2.5-flash` | `gemini-cli-gemini-2.5-flash` | Gemini CLI: gemini-2.5-flash | +| `cli-proxy/claude-opus-4` | `claude-opus-4` | Claude CLI: opus | + +Both slash-format (`cursor/opus-4.6`) and dash-format (`cursor-opus-4.6`) are supported +by the proxy's backend resolver. + +## Option A: OpenAI Provider Configuration + +The quickest way — reuse the built-in `openai` provider slot: + +```bash +openclaw config set models.providers.openai.baseUrl "http://127.0.0.1:3456/v1" +openclaw config set models.providers.openai.apiKey "not-needed" +openclaw config set agents.defaults.model.primary "openai/cursor/opus-4.6" +``` + +Or edit `openclaw.json` directly: + +```json5 +{ + agents: { + defaults: { + model: { primary: "openai/cursor/opus-4.6" }, + models: { + "openai/cursor/auto": {}, + "openai/cursor/opus-4.6-thinking": {}, + "openai/cursor/opus-4.6": {}, + "openai/cursor/sonnet-4.5-thinking": {}, + "openai/cursor/sonnet-4.5": {}, + "openai/cursor/gpt-5.3-codex": {}, + "openai/gemini-cli/gemini-2.5-pro": {}, + "openai/claude-sonnet-4": {}, + }, + }, + }, + models: { + providers: { + openai: { + baseUrl: "http://127.0.0.1:3456/v1", + apiKey: "not-needed", + api: "openai-completions", + models: [ + { id: "cursor/auto", name: "Cursor Auto", contextWindow: 200000, maxTokens: 64000 }, + { id: "cursor/opus-4.6-thinking", name: "Claude 4.6 Opus (Thinking)", reasoning: true, contextWindow: 200000, maxTokens: 64000 }, + { id: "cursor/opus-4.6", name: "Claude 4.6 Opus", contextWindow: 200000, maxTokens: 64000 }, + { id: "cursor/sonnet-4.5-thinking", name: "Claude 4.5 Sonnet (Thinking)", reasoning: true, contextWindow: 200000, maxTokens: 64000 }, + { id: "cursor/sonnet-4.5", name: "Claude 4.5 Sonnet", contextWindow: 200000, maxTokens: 64000 }, + { id: "cursor/gpt-5.3-codex", name: "GPT-5.3 Codex", contextWindow: 128000, maxTokens: 16384 }, + { id: "cursor/gpt-5.2", name: "GPT-5.2", contextWindow: 128000, maxTokens: 16384 }, + { id: "cursor/gemini-3-pro", name: "Gemini 3 Pro (via Cursor)", contextWindow: 1048576, maxTokens: 65536 }, + { id: "gemini-cli/gemini-2.5-pro", name: "Gemini 2.5 Pro (CLI)", contextWindow: 1048576, maxTokens: 65536 }, + { id: "gemini-cli/gemini-2.5-flash", name: "Gemini 2.5 Flash (CLI)", contextWindow: 1048576, maxTokens: 65536 }, + { id: "claude-opus-4", name: "Claude Opus 4", contextWindow: 200000, maxTokens: 64000 }, + { id: "claude-sonnet-4", name: "Claude Sonnet 4", contextWindow: 200000, maxTokens: 64000 }, + { id: "claude-haiku-4", name: "Claude Haiku 4", contextWindow: 200000, maxTokens: 64000 }, + ], + }, + }, + }, +} +``` + +## Option B: Custom Provider Configuration + +If you prefer a dedicated provider name (or the `openai` slot is taken): + +```json5 +{ + agents: { + defaults: { + model: { primary: "cli-proxy/cursor-auto" }, + models: { + "cli-proxy/cursor-auto": { alias: "Cursor Auto" }, + "cli-proxy/cursor-opus-4.6-thinking": { alias: "Opus 4.6 Thinking" }, + "cli-proxy/cursor-opus-4.6": { alias: "Opus 4.6" }, + "cli-proxy/cursor-sonnet-4.5-thinking": { alias: "Sonnet 4.5 Thinking" }, + "cli-proxy/cursor-sonnet-4.5": { alias: "Sonnet 4.5" }, + "cli-proxy/cursor-gpt-5.3-codex": { alias: "GPT 5.3 Codex" }, + "cli-proxy/cursor-gpt-5.2": { alias: "GPT 5.2" }, + "cli-proxy/cursor-gemini-3-pro": { alias: "Gemini 3 Pro (via Cursor)" }, + "cli-proxy/gemini-cli-gemini-2.5-pro": { alias: "Gemini 2.5 Pro (CLI)" }, + "cli-proxy/gemini-cli-gemini-2.5-flash": { alias: "Gemini 2.5 Flash (CLI)" }, + "cli-proxy/claude-opus-4": { alias: "Claude Opus 4" }, + "cli-proxy/claude-sonnet-4": { alias: "Claude Sonnet 4" }, + "cli-proxy/claude-haiku-4": { alias: "Claude Haiku 4" }, + }, + }, + }, + models: { + mode: "merge", + providers: { + "cli-proxy": { + baseUrl: "http://localhost:3456/v1", + apiKey: "not-needed", + api: "openai-completions", + models: [ + { id: "cursor-auto", name: "Cursor Auto" }, + { id: "cursor-opus-4.6-thinking", name: "Opus 4.6 Thinking" }, + { id: "cursor-opus-4.6", name: "Opus 4.6" }, + { id: "cursor-sonnet-4.5-thinking", name: "Sonnet 4.5 Thinking" }, + { id: "cursor-sonnet-4.5", name: "Sonnet 4.5" }, + { id: "cursor-gpt-5.3-codex", name: "GPT 5.3 Codex" }, + { id: "cursor-gpt-5.2", name: "GPT 5.2" }, + { id: "cursor-gemini-3-pro", name: "Gemini 3 Pro (via Cursor)" }, + { id: "gemini-cli-gemini-2.5-pro", name: "Gemini 2.5 Pro (CLI)" }, + { id: "gemini-cli-gemini-2.5-flash", name: "Gemini 2.5 Flash (CLI)" }, + { id: "claude-opus-4", name: "Claude Opus 4" }, + { id: "claude-sonnet-4", name: "Claude Sonnet 4" }, + { id: "claude-haiku-4", name: "Claude Haiku 4" }, + ], + }, + }, + }, +} +``` + +## Per-Agent Configuration + +To assign a specific model to one agent only: + +```json5 +{ + agents: { + list: [ + { + id: "builder", + model: { primary: "openai/cursor/opus-4.6" }, + }, + { + id: "researcher", + model: { primary: "openai/gemini-cli/gemini-2.5-pro" }, + }, + ], + }, +} +``` + +## Switching Models in Chat + +Once configured, switch models in any OpenClaw session: + +``` +/model openai/cursor/opus-4.6-thinking +/model openai/gemini-cli/gemini-2.5-flash +/model openai/claude-opus-4 +``` + +Or with the custom provider: + +``` +/model cli-proxy/cursor-opus-4.6-thinking +/model cli-proxy/gemini-cli-gemini-2.5-flash +/model cli-proxy/claude-opus-4 +``` + +## Verifying the Setup + +```bash +# 1. Check proxy is running +curl http://localhost:3456/health + +# 2. List available models +curl http://localhost:3456/v1/models + +# 3. Run a quick test via OpenClaw +openclaw agent --local -m "Hello!" --session-id test --json + +# 4. Check which provider/model was used (look for "provider" and "model" in output) +``` + +## Running the Proxy as a Service + +### Option A: PM2 (recommended for development) + +```bash +npm install -g pm2 +pm2 start /path/to/claude-max-api-proxy/dist/server/standalone.js --name cli-proxy +pm2 save +pm2 startup # Follow instructions to enable auto-start on boot +``` + +### Option B: macOS LaunchAgent + +See [macos-setup.md](./macos-setup.md) for LaunchAgent configuration. + +## Troubleshooting + +### "Unknown model" errors + +The model is not in the `agents.defaults.models` allowlist. Add it: + +```bash +# For openai provider approach: +openclaw config set agents.defaults.models.openai/cursor/opus-4.6 '{}' +``` + +### "Connection refused" errors + +The proxy is not running. Start it: + +```bash +cd /path/to/claude-max-api-proxy +npm start +# or +pm2 start cli-proxy +``` + +### Empty responses or `[object Object]` + +Make sure you are running the latest version of the proxy (v1.1+). Older versions +did not handle multimodal (array-format) message content that OpenClaw sends. + +### Rate limit fallback + +OpenClaw has built-in failover. If the primary model hits a rate limit, it will +try fallback models. Configure fallbacks: + +```bash +openclaw config set agents.defaults.model.fallbacks '["openai/cursor/auto", "openai/gemini-cli/gemini-2.5-flash"]' +``` From f6d3218c825361be47f83365f451fad17317cb96 Mon Sep 17 00:00:00 2001 From: jsmjsm <49445179+jsmjsm@users.noreply.github.com> Date: Tue, 17 Feb 2026 11:19:21 -0800 Subject: [PATCH 6/6] Add --yolo flag to Cursor agent for auto-approving tool execution Without this flag, Cursor agent in headless (-p) mode cannot execute shell commands (e.g. python) because it waits for user confirmation that never comes. The --yolo flag (alias for --force) auto-approves all tool executions, matching Gemini CLI's -y behavior. Co-authored-by: Cursor --- src/subprocess/cursor.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/subprocess/cursor.ts b/src/subprocess/cursor.ts index fb223e7..3d22ad1 100644 --- a/src/subprocess/cursor.ts +++ b/src/subprocess/cursor.ts @@ -106,6 +106,7 @@ export class CursorSubprocess extends EventEmitter { "-p", // Print mode (non-interactive, reads from stdin) "--output-format", "stream-json", "--stream-partial-output", // Enable streaming deltas + "--yolo", // Auto-approve all tool executions (shell, write, etc.) ]; if (options.model) {