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) 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"]' +``` 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 c8ecaa1..9f52940 100644 --- a/src/adapter/openai-to-cli.ts +++ b/src/adapter/openai-to-cli.ts @@ -1,89 +1,66 @@ /** - * 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", -}; +import type { OpenAIChatRequest, OpenAIChatContent } from "../types/openai.js"; /** - * Extract Claude model alias from request model string + * 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). */ -export function extractModel(model: string): ClaudeModel { - // Try direct lookup - if (MODEL_MAP[model]) { - return MODEL_MAP[model]; +function contentToString(content: OpenAIChatContent): string { + if (typeof content === "string") { + return content; } - // Try stripping provider prefix - const stripped = model.replace(/^claude-code-cli\//, ""); - if (MODEL_MAP[stripped]) { - return MODEL_MAP[stripped]; + if (Array.isArray(content)) { + return content + .filter((part) => part.type === "text" && part.text) + .map((part) => part.text!) + .join(""); } - // Default to opus (Claude Max subscription) - return "opus"; + // Fallback for unexpected types + return String(content); } /** - * 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 { 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; } } 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..ac2e0bf 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,51 +266,65 @@ 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. + * 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); + res.json({ object: "list", data: [ - { - id: "claude-opus-4", - object: "model", - owned_by: "anthropic", - created: Math.floor(Date.now() / 1000), - }, - { - id: "claude-sonnet-4", - object: "model", - owned_by: "anthropic", - created: Math.floor(Date.now() / 1000), - }, - { - id: "claude-haiku-4", - object: "model", - owned_by: "anthropic", - created: Math.floor(Date.now() / 1000), - }, + // ─── Claude CLI models ───────────────────────────────────────── + // 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) ──────────────────────── + // 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 ───────────────────────────────────────── + // 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 }, ], }); } @@ -287,7 +337,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..3d22ad1 --- /dev/null +++ b/src/subprocess/cursor.ts @@ -0,0 +1,238 @@ +/** + * 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 + "--yolo", // Auto-approve all tool executions (shell, write, etc.) + ]; + + 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..b590d7b --- /dev/null +++ b/src/subprocess/factory.ts @@ -0,0 +1,263 @@ +/** + * 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 (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-3. Slash-prefix routing (direct API callers) ───────────────── + 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("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); + const cliModel = CLAUDE_MODEL_MAP[remainder] || "sonnet"; + return { backend: "claude", cliModel }; + } + + // ─── 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 }; + } + + if (GEMINI_CLI_MODELS.has(model)) { + return { backend: "gemini", cliModel: model }; + } + + if (CLAUDE_MODEL_MAP[model]) { + return { backend: "claude", cliModel: CLAUDE_MODEL_MAP[model] }; + } + + // ─── 10. Pattern matching ────────────────────────────────────────── + if (model.startsWith("gpt-") || model.startsWith("grok")) { + return { backend: "cursor", cliModel: model }; + } + + // ─── 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 + */ +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; +} 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 {