Compare commits
No commits in common. "master" and "v0.0.3" have entirely different histories.
|
|
@ -1,5 +1,3 @@
|
||||||
.idea/
|
.idea/
|
||||||
bin/
|
bin/
|
||||||
.env
|
.env
|
||||||
cursor-adapter
|
|
||||||
cursor-mcp-server
|
|
||||||
|
|
|
||||||
20
README.md
20
README.md
|
|
@ -43,26 +43,6 @@ cp config.example.yaml ~/.cursor-adapter/config.yaml
|
||||||
- `cursor_cli_path`:CLI 可執行檔名或路徑
|
- `cursor_cli_path`:CLI 可執行檔名或路徑
|
||||||
- `default_model`、`available_models`、`timeout`(秒)、`max_concurrent`
|
- `default_model`、`available_models`、`timeout`(秒)、`max_concurrent`
|
||||||
- `use_acp`、`chat_only_workspace`、`log_level`
|
- `use_acp`、`chat_only_workspace`、`log_level`
|
||||||
- `cursor_mode`:`plan`(預設,純大腦+ `<tool_call>` 翻譯成 caller 端 tool_use)或 `agent`(讓 Cursor CLI 自己拿 host 的檔案/shell 工具直接執行)
|
|
||||||
- `workspace_root`:絕對路徑;設了之後子行程就跑在這個資料夾,不再用 chat-only temp dir。`agent` 模式下幾乎都要設。Per-request 用 `X-Cursor-Workspace` header 動態覆蓋。
|
|
||||||
|
|
||||||
### 兩種典型擺法
|
|
||||||
|
|
||||||
1. **大腦模式(預設)**
|
|
||||||
```yaml
|
|
||||||
cursor_mode: plan
|
|
||||||
chat_only_workspace: true
|
|
||||||
```
|
|
||||||
Cursor CLI 不執行任何東西。proxy 把 system_prompt 注入腦袋,要它輸出 `<tool_call>{...}</tool_call>`,proxy 再翻成 Anthropic `tool_use` 給 caller(Claude Desktop / Claude Code / opencode)跑。caller 必須有自己的 host MCP(例如 desktop-commander)。
|
|
||||||
|
|
||||||
2. **執行者模式**
|
|
||||||
```yaml
|
|
||||||
cursor_mode: agent
|
|
||||||
chat_only_workspace: false
|
|
||||||
workspace_root: /Users/<you>/Desktop
|
|
||||||
system_prompt: "" # 移掉「你只是大腦」的口令,讓它正常使用工具
|
|
||||||
```
|
|
||||||
Cursor CLI 自己用內建 filesystem/shell 工具直接動 `workspace_root`。caller 不需要任何 MCP,整段在 host 上完成;回到 caller 那邊只有最後的文字結論。
|
|
||||||
|
|
||||||
## HTTP 端點
|
## HTTP 端點
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,241 +0,0 @@
|
||||||
// Command cursor-mcp-server is a Model Context Protocol (MCP) server that
|
|
||||||
// exposes the cursor-adapter HTTP API as MCP tools for Claude Desktop.
|
|
||||||
//
|
|
||||||
// It communicates with Claude Desktop over stdio (JSON-RPC) and forwards
|
|
||||||
// requests to a running cursor-adapter instance via HTTP.
|
|
||||||
//
|
|
||||||
// Usage (standalone):
|
|
||||||
//
|
|
||||||
// go run ./cmd/mcp-server
|
|
||||||
// go run ./cmd/mcp-server --adapter-url http://127.0.0.1:8765
|
|
||||||
//
|
|
||||||
// Usage (Claude Desktop config):
|
|
||||||
//
|
|
||||||
// {
|
|
||||||
// "mcpServers": {
|
|
||||||
// "cursor-bridge": {
|
|
||||||
// "command": "/path/to/cursor-mcp-server",
|
|
||||||
// "args": ["--adapter-url", "http://127.0.0.1:8765"]
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"flag"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
|
||||||
)
|
|
||||||
|
|
||||||
var adapterURL string
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
flag.StringVar(&adapterURL, "adapter-url", "http://127.0.0.1:8765", "cursor-adapter HTTP base URL")
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Tool input/output types ---
|
|
||||||
|
|
||||||
type AskCursorInput struct {
|
|
||||||
Prompt string `json:"prompt" mcp:"required"`
|
|
||||||
Model string `json:"model"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type EmptyInput struct{}
|
|
||||||
|
|
||||||
type TextOutput struct {
|
|
||||||
Text string `json:"text"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Tool handlers ---
|
|
||||||
|
|
||||||
func askCursor(ctx context.Context, _ *mcp.CallToolRequest, input AskCursorInput) (*mcp.CallToolResult, TextOutput, error) {
|
|
||||||
model := input.Model
|
|
||||||
if model == "" {
|
|
||||||
model = "claude-opus-4-7-high"
|
|
||||||
}
|
|
||||||
|
|
||||||
payload := map[string]interface{}{
|
|
||||||
"model": model,
|
|
||||||
"max_tokens": 16384,
|
|
||||||
"messages": []map[string]string{{"role": "user", "content": input.Prompt}},
|
|
||||||
"stream": false,
|
|
||||||
}
|
|
||||||
body, _ := json.Marshal(payload)
|
|
||||||
|
|
||||||
httpReq, err := http.NewRequestWithContext(ctx, "POST", adapterURL+"/v1/messages", bytes.NewReader(body))
|
|
||||||
if err != nil {
|
|
||||||
return nil, TextOutput{}, fmt.Errorf("build request: %w", err)
|
|
||||||
}
|
|
||||||
httpReq.Header.Set("Content-Type", "application/json")
|
|
||||||
httpReq.Header.Set("x-api-key", "mcp-bridge")
|
|
||||||
|
|
||||||
client := &http.Client{Timeout: 5 * time.Minute}
|
|
||||||
resp, err := client.Do(httpReq)
|
|
||||||
if err != nil {
|
|
||||||
return &mcp.CallToolResult{
|
|
||||||
Content: []mcp.Content{&mcp.TextContent{Text: "❌ Cannot connect to cursor-adapter at " + adapterURL + ". Make sure it is running."}},
|
|
||||||
IsError: true,
|
|
||||||
}, TextOutput{}, nil
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
respBody, _ := io.ReadAll(resp.Body)
|
|
||||||
if resp.StatusCode != 200 {
|
|
||||||
return &mcp.CallToolResult{
|
|
||||||
Content: []mcp.Content{&mcp.TextContent{Text: fmt.Sprintf("❌ cursor-adapter HTTP %d: %s", resp.StatusCode, string(respBody))}},
|
|
||||||
IsError: true,
|
|
||||||
}, TextOutput{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var data struct {
|
|
||||||
Content []struct {
|
|
||||||
Type string `json:"type"`
|
|
||||||
Text string `json:"text"`
|
|
||||||
} `json:"content"`
|
|
||||||
Error *struct {
|
|
||||||
Message string `json:"message"`
|
|
||||||
} `json:"error"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(respBody, &data); err != nil {
|
|
||||||
return nil, TextOutput{Text: string(respBody)}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if data.Error != nil {
|
|
||||||
return &mcp.CallToolResult{
|
|
||||||
Content: []mcp.Content{&mcp.TextContent{Text: "❌ Cursor error: " + data.Error.Message}},
|
|
||||||
IsError: true,
|
|
||||||
}, TextOutput{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var texts []string
|
|
||||||
for _, block := range data.Content {
|
|
||||||
if block.Type == "text" {
|
|
||||||
texts = append(texts, block.Text)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
result := strings.Join(texts, "\n")
|
|
||||||
if result == "" {
|
|
||||||
result = string(respBody)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &mcp.CallToolResult{
|
|
||||||
Content: []mcp.Content{&mcp.TextContent{Text: fmt.Sprintf("[Model: %s]\n\n%s", model, result)}},
|
|
||||||
}, TextOutput{Text: result}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func listModels(ctx context.Context, _ *mcp.CallToolRequest, _ EmptyInput) (*mcp.CallToolResult, TextOutput, error) {
|
|
||||||
httpReq, err := http.NewRequestWithContext(ctx, "GET", adapterURL+"/v1/models", nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, TextOutput{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
client := &http.Client{Timeout: 30 * time.Second}
|
|
||||||
resp, err := client.Do(httpReq)
|
|
||||||
if err != nil {
|
|
||||||
return &mcp.CallToolResult{
|
|
||||||
Content: []mcp.Content{&mcp.TextContent{Text: "❌ Cannot connect to cursor-adapter"}},
|
|
||||||
IsError: true,
|
|
||||||
}, TextOutput{}, nil
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
respBody, _ := io.ReadAll(resp.Body)
|
|
||||||
var data struct {
|
|
||||||
Data []struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
} `json:"data"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(respBody, &data); err != nil {
|
|
||||||
return nil, TextOutput{Text: string(respBody)}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var lines []string
|
|
||||||
lines = append(lines, fmt.Sprintf("Available models (%d total):\n", len(data.Data)))
|
|
||||||
for _, m := range data.Data {
|
|
||||||
lines = append(lines, " "+m.ID)
|
|
||||||
}
|
|
||||||
text := strings.Join(lines, "\n")
|
|
||||||
return &mcp.CallToolResult{
|
|
||||||
Content: []mcp.Content{&mcp.TextContent{Text: text}},
|
|
||||||
}, TextOutput{Text: text}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func checkHealth(ctx context.Context, _ *mcp.CallToolRequest, _ EmptyInput) (*mcp.CallToolResult, TextOutput, error) {
|
|
||||||
httpReq, err := http.NewRequestWithContext(ctx, "GET", adapterURL+"/health", nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, TextOutput{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
client := &http.Client{Timeout: 10 * time.Second}
|
|
||||||
resp, err := client.Do(httpReq)
|
|
||||||
if err != nil {
|
|
||||||
return &mcp.CallToolResult{
|
|
||||||
Content: []mcp.Content{&mcp.TextContent{Text: "❌ cursor-adapter is not running"}},
|
|
||||||
IsError: true,
|
|
||||||
}, TextOutput{}, nil
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
respBody, _ := io.ReadAll(resp.Body)
|
|
||||||
var pretty bytes.Buffer
|
|
||||||
text := string(respBody)
|
|
||||||
if err := json.Indent(&pretty, respBody, "", " "); err == nil {
|
|
||||||
text = pretty.String()
|
|
||||||
}
|
|
||||||
return &mcp.CallToolResult{
|
|
||||||
Content: []mcp.Content{&mcp.TextContent{Text: text}},
|
|
||||||
}, TextOutput{Text: text}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
flag.Parse()
|
|
||||||
|
|
||||||
if envURL := os.Getenv("CURSOR_ADAPTER_URL"); envURL != "" {
|
|
||||||
adapterURL = envURL
|
|
||||||
}
|
|
||||||
|
|
||||||
server := mcp.NewServer(
|
|
||||||
&mcp.Implementation{
|
|
||||||
Name: "cursor-bridge",
|
|
||||||
Version: "1.0.0",
|
|
||||||
},
|
|
||||||
&mcp.ServerOptions{
|
|
||||||
Instructions: "This server provides access to the Cursor AI coding agent via cursor-adapter. " +
|
|
||||||
"Use ask_cursor to delegate coding tasks, code generation, debugging, or technical questions to Cursor.",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
mcp.AddTool(server, &mcp.Tool{
|
|
||||||
Name: "ask_cursor",
|
|
||||||
Description: "Ask the Cursor AI agent a question or delegate a coding task. " +
|
|
||||||
"Use this when you need code generation, review, debugging, or a second opinion. " +
|
|
||||||
"The Cursor agent acts as a pure reasoning engine. " +
|
|
||||||
"Available models: claude-opus-4-7-high (default), claude-opus-4-7-thinking-high, " +
|
|
||||||
"claude-4.6-opus-high, claude-4.6-sonnet-medium, gpt-5.4-medium, gemini-3.1-pro. " +
|
|
||||||
"Pass model name in the 'model' field.",
|
|
||||||
}, askCursor)
|
|
||||||
|
|
||||||
mcp.AddTool(server, &mcp.Tool{
|
|
||||||
Name: "list_cursor_models",
|
|
||||||
Description: "List all available models from the Cursor adapter.",
|
|
||||||
}, listModels)
|
|
||||||
|
|
||||||
mcp.AddTool(server, &mcp.Tool{
|
|
||||||
Name: "cursor_health",
|
|
||||||
Description: "Check the health status of the cursor-adapter service.",
|
|
||||||
}, checkHealth)
|
|
||||||
|
|
||||||
if err := server.Run(context.Background(), &mcp.StdioTransport{}); err != nil {
|
|
||||||
log.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -12,24 +12,6 @@ use_acp: false
|
||||||
# Cursor agent to see the adapter's working directory.
|
# Cursor agent to see the adapter's working directory.
|
||||||
chat_only_workspace: true
|
chat_only_workspace: true
|
||||||
|
|
||||||
# How to launch the Cursor CLI subprocess.
|
|
||||||
# plan (default): pass --mode plan; the CLI never executes tools, it
|
|
||||||
# just proposes plans. The proxy translates brain-side
|
|
||||||
# <tool_call>...</tool_call> sentinels into real Anthropic
|
|
||||||
# tool_use blocks for the calling client to execute.
|
|
||||||
# agent: omit --mode and add --trust; the CLI runs in its native agent
|
|
||||||
# mode with full filesystem/shell tools and acts inside
|
|
||||||
# workspace_root. Use this when you want the CLI itself to be
|
|
||||||
# the executor (e.g. let it reorganise ~/Desktop directly).
|
|
||||||
cursor_mode: plan
|
|
||||||
|
|
||||||
# Absolute directory the Cursor CLI subprocess runs in. Setting this
|
|
||||||
# disables the chat-only temp workspace isolation. Required when
|
|
||||||
# cursor_mode: agent if you want the CLI to act on a real folder.
|
|
||||||
# Per-request override: clients can send `X-Cursor-Workspace: /abs/path`.
|
|
||||||
# Example: workspace_root: /Users/daniel/Desktop
|
|
||||||
workspace_root: ""
|
|
||||||
|
|
||||||
log_level: INFO
|
log_level: INFO
|
||||||
available_models:
|
available_models:
|
||||||
- claude-sonnet-4-20250514
|
- claude-sonnet-4-20250514
|
||||||
|
|
|
||||||
110
config.yaml
110
config.yaml
|
|
@ -1,110 +1,20 @@
|
||||||
port: 8765
|
port: 8765
|
||||||
cursor_cli_path: agent
|
cursor_cli_path: agent
|
||||||
default_model: claude-opus-4-7-high
|
default_model: claude-sonnet-4-20250514
|
||||||
timeout: 300
|
timeout: 300
|
||||||
max_concurrent: 5
|
max_concurrent: 5
|
||||||
use_acp: false
|
use_acp: false
|
||||||
chat_only_workspace: true
|
|
||||||
log_level: ERROR
|
|
||||||
|
|
||||||
system_prompt: |
|
# Isolate Cursor CLI / ACP child in an empty temp workspace with
|
||||||
You are the reasoning brain of a two-process AI system. The caller
|
# HOME / CURSOR_CONFIG_DIR / XDG_CONFIG_HOME overridden so the agent can
|
||||||
(Claude Desktop / Claude Code / opencode) has the user's real machine,
|
# neither read the adapter's cwd nor load global rules from ~/.cursor.
|
||||||
filesystem, terminal, and any MCP-provided tools. You do not. You drive
|
# Recommended: true. Set to false only if you intentionally want the
|
||||||
it remotely by emitting tool invocations the proxy translates into real
|
# Cursor agent to see the adapter's working directory.
|
||||||
Anthropic tool_use blocks for the caller to execute.
|
chat_only_workspace: false
|
||||||
|
|
||||||
Each turn you receive (a) the user's request, (b) the list of executors
|
|
||||||
the caller currently has under "Available executors", and (c) a
|
|
||||||
transcript of past tool_use / tool_result blocks. Decide what to do
|
|
||||||
next.
|
|
||||||
|
|
||||||
CONTRACT — output exactly ONE of the following per turn:
|
|
||||||
|
|
||||||
1. A normal answer in plain markdown (when no tool is needed, or when
|
|
||||||
you are presenting the final result to the user).
|
|
||||||
|
|
||||||
2. EXACTLY one tool invocation, wrapped in sentinels with NOTHING else
|
|
||||||
before or after:
|
|
||||||
|
|
||||||
<tool_call>
|
|
||||||
{"name":"<tool_name>","input": { ... }}
|
|
||||||
</tool_call>
|
|
||||||
|
|
||||||
- "name" must match one of the executors listed under "Available
|
|
||||||
executors". If no executors are listed, fall back to a plain-text
|
|
||||||
answer.
|
|
||||||
- "input" must conform to that tool's input_schema.
|
|
||||||
- Do not add commentary around the block. The proxy will translate
|
|
||||||
it into a real tool_use call; the caller will run it and send you
|
|
||||||
a tool_result on the next turn.
|
|
||||||
|
|
||||||
HARD RULES:
|
|
||||||
- NEVER claim you "cannot access the filesystem / terminal / browser",
|
|
||||||
"are in Ask/Plan mode", "are sandboxed", or "need the user to do it
|
|
||||||
manually". Those statements are false here — the caller is your
|
|
||||||
hands.
|
|
||||||
- NEVER fabricate tool names. If the tool you need is not listed,
|
|
||||||
explain in plain text what command/operation should be run and stop.
|
|
||||||
- Prefer a single tool_call per turn; iterate via the tool_result
|
|
||||||
feedback. Multi-step plans should be expressed as a sequence of
|
|
||||||
turns, not crammed into one block.
|
|
||||||
- Default target shell when emitting raw shell commands inside text:
|
|
||||||
zsh on macOS, unless the user says otherwise.
|
|
||||||
|
|
||||||
VOCABULARY — when the user says any of:
|
|
||||||
"工作目錄" / "working directory" / "cwd" / "pwd" / "目前資料夾" /
|
|
||||||
"這個資料夾" / "the folder" / "the project"
|
|
||||||
they ALWAYS mean the folder the caller (Claude Desktop / Claude
|
|
||||||
Code / opencode) attached or opened for this session — i.e. the
|
|
||||||
host-mounted folder visible to the caller's executor (typically
|
|
||||||
under `/sessions/<id>/mnt/<X>` or an absolute host path). They
|
|
||||||
NEVER mean the directory your own subprocess happens to be running
|
|
||||||
in, and they NEVER mean a sandbox path like `/sessions/.../mnt/`
|
|
||||||
with no folder under it. If you are tempted to call `pwd` and
|
|
||||||
answer with that, stop — the answer the user wants is the mount
|
|
||||||
root, found by listing `/sessions/*/mnt/*/` (see ORIENTATION
|
|
||||||
below) or by reading the "Known host-mount paths" section.
|
|
||||||
|
|
||||||
ORIENTATION (first turn of a fresh session):
|
|
||||||
The caller's executor often runs inside a sandbox (e.g. Claude
|
|
||||||
Desktop's Cowork) that bind-mounts ONE folder the user attached for
|
|
||||||
this session. The folder's name is unknown to you in advance — it
|
|
||||||
could be Desktop, a project root, Documents, anything. From the
|
|
||||||
sandbox it shows up under `/sessions/<id>/mnt/<whatever>`, and that
|
|
||||||
path IS the user's working folder for this conversation regardless of
|
|
||||||
its name.
|
|
||||||
|
|
||||||
If the user refers to "my folder" / "the mounted folder" / "this
|
|
||||||
project" / "the desktop" / etc. and you have a shell-like executor
|
|
||||||
available but no path has been established yet (no `Working
|
|
||||||
directory:` line, no "Known host-mount paths" section, no prior
|
|
||||||
tool_result revealing one), your FIRST tool_call must be a single
|
|
||||||
discovery probe that enumerates every mount under `/sessions/*/mnt/`,
|
|
||||||
e.g.:
|
|
||||||
|
|
||||||
<tool_call>
|
|
||||||
{"name":"<shell_tool>","input":{"command":"pwd; ls -d /sessions/*/mnt/*/ 2>/dev/null; ls -la /workspace 2>/dev/null | head"}}
|
|
||||||
</tool_call>
|
|
||||||
|
|
||||||
Treat whatever directory comes back under `/sessions/*/mnt/<X>` as
|
|
||||||
THE working folder for this session, no matter what `<X>` is. Then
|
|
||||||
use that path (or subpaths under it) for every subsequent tool_call.
|
|
||||||
Do NOT ask the user to name or re-state the folder — they already
|
|
||||||
attached it. The proxy also re-surfaces previously discovered mount
|
|
||||||
roots under "Known host-mount paths" on later turns; prefer those
|
|
||||||
over re-probing.
|
|
||||||
|
|
||||||
|
log_level: INFO
|
||||||
available_models:
|
available_models:
|
||||||
- claude-opus-4-7-high
|
- claude-sonnet-4-20250514
|
||||||
- claude-opus-4-7-thinking-high
|
- claude-opus-4-20250514
|
||||||
- claude-4.6-opus-high
|
|
||||||
- claude-4.6-opus-high-thinking
|
|
||||||
- claude-4.6-sonnet-medium
|
|
||||||
- claude-4.6-sonnet-medium-thinking
|
|
||||||
- claude-4.5-opus-high
|
|
||||||
- claude-4.5-sonnet
|
|
||||||
- claude-4-sonnet
|
|
||||||
- gpt-5.4-medium
|
|
||||||
- gpt-5.2
|
- gpt-5.2
|
||||||
- gemini-3.1-pro
|
- gemini-3.1-pro
|
||||||
|
|
|
||||||
7
go.mod
7
go.mod
|
|
@ -4,18 +4,11 @@ go 1.26.1
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/go-chi/chi/v5 v5.2.5
|
github.com/go-chi/chi/v5 v5.2.5
|
||||||
github.com/modelcontextprotocol/go-sdk v1.5.0
|
|
||||||
github.com/spf13/cobra v1.10.2
|
github.com/spf13/cobra v1.10.2
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/google/jsonschema-go v0.4.2 // indirect
|
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/segmentio/asm v1.1.3 // indirect
|
|
||||||
github.com/segmentio/encoding v0.5.4 // indirect
|
|
||||||
github.com/spf13/pflag v1.0.9 // indirect
|
github.com/spf13/pflag v1.0.9 // indirect
|
||||||
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
|
||||||
golang.org/x/oauth2 v0.35.0 // indirect
|
|
||||||
golang.org/x/sys v0.41.0 // indirect
|
|
||||||
)
|
)
|
||||||
|
|
|
||||||
20
go.sum
20
go.sum
|
|
@ -1,34 +1,14 @@
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||||
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
||||||
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
|
||||||
github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
|
|
||||||
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
|
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
github.com/modelcontextprotocol/go-sdk v1.5.0 h1:CHU0FIX9kpueNkxuYtfYQn1Z0slhFzBZuq+x6IiblIU=
|
|
||||||
github.com/modelcontextprotocol/go-sdk v1.5.0/go.mod h1:gggDIhoemhWs3BGkGwd1umzEXCEMMvAnhTrnbXJKKKA=
|
|
||||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
|
|
||||||
github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
|
|
||||||
github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0=
|
|
||||||
github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
|
|
||||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||||
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
|
|
||||||
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
|
|
||||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
|
|
||||||
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
|
||||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
|
||||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
|
||||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
|
||||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
|
|
||||||
|
|
@ -27,52 +27,14 @@ type Bridge interface {
|
||||||
CheckHealth(ctx context.Context) error
|
CheckHealth(ctx context.Context) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// Options bundles the knobs NewBridge needs. CursorPath, ChatOnly, Mode and
|
// NewBridge 建立 Bridge。chatOnly=true 會讓每個子程序都跑在空的 temp
|
||||||
// WorkspaceRoot together decide how each subprocess is sandboxed and what
|
// workspace、並且用 env overrides 把 HOME / CURSOR_CONFIG_DIR 導到那個
|
||||||
// `--mode` flag (if any) is passed.
|
// temp dir,讓 Cursor agent 讀不到任何真實專案檔案或全域 rules。
|
||||||
type Options struct {
|
func NewBridge(cursorPath string, logger *slog.Logger, useACP bool, chatOnly bool, maxConcurrent int, timeout time.Duration) Bridge {
|
||||||
CursorPath string
|
if useACP {
|
||||||
Logger *slog.Logger
|
return NewACPBridge(cursorPath, logger, chatOnly, maxConcurrent, timeout)
|
||||||
UseACP bool
|
|
||||||
ChatOnly bool
|
|
||||||
MaxConcurrent int
|
|
||||||
Timeout time.Duration
|
|
||||||
// Mode is "plan" (the CLI proposes only; caller executes via tool_use
|
|
||||||
// translation) or "agent" (the CLI executes natively in WorkspaceRoot).
|
|
||||||
// Empty defaults to "plan".
|
|
||||||
Mode string
|
|
||||||
// WorkspaceRoot, when non-empty, overrides ChatOnly's temp workspace
|
|
||||||
// and runs the CLI directly in this absolute directory. Per-request
|
|
||||||
// override via context (see WithWorkspaceOverride) takes precedence.
|
|
||||||
WorkspaceRoot string
|
|
||||||
}
|
}
|
||||||
|
return NewCLIBridge(cursorPath, chatOnly, maxConcurrent, timeout)
|
||||||
// NewBridge 建立 Bridge。
|
|
||||||
func NewBridge(opts Options) Bridge {
|
|
||||||
if opts.UseACP {
|
|
||||||
return NewACPBridge(opts)
|
|
||||||
}
|
|
||||||
return NewCLIBridge(opts)
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- per-request workspace override via context ---
|
|
||||||
|
|
||||||
type ctxKey int
|
|
||||||
|
|
||||||
const workspaceCtxKey ctxKey = 1
|
|
||||||
|
|
||||||
// WithWorkspaceOverride attaches a per-request absolute workspace path to
|
|
||||||
// ctx. Bridges honour it ahead of the Options.WorkspaceRoot.
|
|
||||||
func WithWorkspaceOverride(ctx context.Context, workspace string) context.Context {
|
|
||||||
if workspace == "" {
|
|
||||||
return ctx
|
|
||||||
}
|
|
||||||
return context.WithValue(ctx, workspaceCtxKey, workspace)
|
|
||||||
}
|
|
||||||
|
|
||||||
func workspaceOverride(ctx context.Context) string {
|
|
||||||
v, _ := ctx.Value(workspaceCtxKey).(string)
|
|
||||||
return v
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- CLI Bridge ---
|
// --- CLI Bridge ---
|
||||||
|
|
@ -82,31 +44,11 @@ type CLIBridge struct {
|
||||||
semaphore chan struct{}
|
semaphore chan struct{}
|
||||||
timeout time.Duration
|
timeout time.Duration
|
||||||
chatOnly bool
|
chatOnly bool
|
||||||
mode string
|
|
||||||
workspaceRoot string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildCLICommandArgs(prompt, model, workspaceDir, mode string, stream, chatOnly bool) []string {
|
func buildCLICommandArgs(prompt, model, workspaceDir string, stream, chatOnly bool) []string {
|
||||||
args := []string{"--print"}
|
args := []string{"--print", "--mode", "ask"}
|
||||||
// "plan" (default): the CLI proposes plans without executing tools;
|
if chatOnly {
|
||||||
// the proxy translates a brain-side <tool_call> sentinel into real
|
|
||||||
// Anthropic tool_use blocks for the caller to execute.
|
|
||||||
// "agent": omit --mode to let the CLI run with full filesystem/shell
|
|
||||||
// tools — useful when the user wants the CLI itself to be the
|
|
||||||
// executor inside a real workspace dir.
|
|
||||||
switch mode {
|
|
||||||
case "agent":
|
|
||||||
// no --mode flag — agent mode is the CLI default
|
|
||||||
case "", "plan":
|
|
||||||
args = append(args, "--mode", "plan")
|
|
||||||
default:
|
|
||||||
args = append(args, "--mode", mode)
|
|
||||||
}
|
|
||||||
// --trust skips interactive permission prompts. We always want this
|
|
||||||
// non-interactively: chat-only mode is sandboxed anyway, and agent
|
|
||||||
// mode against a real WorkspaceRoot means the operator already
|
|
||||||
// opted in to letting the CLI execute there.
|
|
||||||
if chatOnly || mode == "agent" {
|
|
||||||
args = append(args, "--trust")
|
args = append(args, "--trust")
|
||||||
}
|
}
|
||||||
if workspaceDir != "" {
|
if workspaceDir != "" {
|
||||||
|
|
@ -124,40 +66,23 @@ func buildCLICommandArgs(prompt, model, workspaceDir, mode string, stream, chatO
|
||||||
return args
|
return args
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewCLIBridge constructs a CLIBridge from an Options struct. ChatOnly,
|
func NewCLIBridge(cursorPath string, chatOnly bool, maxConcurrent int, timeout time.Duration) *CLIBridge {
|
||||||
// Mode and WorkspaceRoot together decide how each subprocess is sandboxed.
|
if maxConcurrent <= 0 {
|
||||||
func NewCLIBridge(opts Options) *CLIBridge {
|
maxConcurrent = 1
|
||||||
if opts.MaxConcurrent <= 0 {
|
|
||||||
opts.MaxConcurrent = 1
|
|
||||||
}
|
}
|
||||||
return &CLIBridge{
|
return &CLIBridge{
|
||||||
cursorPath: opts.CursorPath,
|
cursorPath: cursorPath,
|
||||||
semaphore: make(chan struct{}, opts.MaxConcurrent),
|
semaphore: make(chan struct{}, maxConcurrent),
|
||||||
timeout: opts.Timeout,
|
timeout: timeout,
|
||||||
chatOnly: opts.ChatOnly,
|
chatOnly: chatOnly,
|
||||||
mode: opts.Mode,
|
|
||||||
workspaceRoot: opts.WorkspaceRoot,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// prepareWorkspace returns (workspaceDir, envOverrides, cleanup).
|
// prepareWorkspace returns (workspaceDir, envOverrides, cleanup). When
|
||||||
//
|
// chatOnly is enabled, workspaceDir is a fresh temp dir and cleanup removes
|
||||||
// Resolution order:
|
// it. Otherwise workspaceDir falls back to the adapter's cwd with no
|
||||||
// 1. ctx override (X-Cursor-Workspace header) if set
|
// cleanup.
|
||||||
// 2. configured WorkspaceRoot if set
|
func (b *CLIBridge) prepareWorkspace() (string, map[string]string, func()) {
|
||||||
// 3. chat-only temp dir if enabled
|
|
||||||
// 4. adapter's cwd
|
|
||||||
//
|
|
||||||
// Cases (1) and (2) deliberately return no env overrides — the caller
|
|
||||||
// asked for a real host directory, so HOME / CURSOR_CONFIG_DIR stay
|
|
||||||
// untouched and the CLI sees the real user profile (auth + tools).
|
|
||||||
func (b *CLIBridge) prepareWorkspace(ctx context.Context) (string, map[string]string, func()) {
|
|
||||||
if override := workspaceOverride(ctx); override != "" {
|
|
||||||
return override, nil, func() {}
|
|
||||||
}
|
|
||||||
if b.workspaceRoot != "" {
|
|
||||||
return b.workspaceRoot, nil, func() {}
|
|
||||||
}
|
|
||||||
if !b.chatOnly {
|
if !b.chatOnly {
|
||||||
ws, _ := os.Getwd()
|
ws, _ := os.Getwd()
|
||||||
return ws, nil, func() {}
|
return ws, nil, func() {}
|
||||||
|
|
@ -190,9 +115,9 @@ func (b *CLIBridge) Execute(ctx context.Context, prompt string, model string, se
|
||||||
execCtx, cancel := context.WithTimeout(ctx, b.timeout)
|
execCtx, cancel := context.WithTimeout(ctx, b.timeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
ws, envOverrides, cleanup := b.prepareWorkspace(ctx)
|
ws, envOverrides, cleanup := b.prepareWorkspace()
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
cmd := exec.CommandContext(execCtx, b.cursorPath, buildCLICommandArgs(prompt, model, ws, b.mode, true, b.chatOnly)...)
|
cmd := exec.CommandContext(execCtx, b.cursorPath, buildCLICommandArgs(prompt, model, ws, true, b.chatOnly)...)
|
||||||
cmd.Dir = ws
|
cmd.Dir = ws
|
||||||
cmd.Env = workspace.MergeEnv(os.Environ(), envOverrides)
|
cmd.Env = workspace.MergeEnv(os.Environ(), envOverrides)
|
||||||
|
|
||||||
|
|
@ -240,9 +165,9 @@ func (b *CLIBridge) ExecuteSync(ctx context.Context, prompt string, model string
|
||||||
execCtx, cancel := context.WithTimeout(ctx, b.timeout)
|
execCtx, cancel := context.WithTimeout(ctx, b.timeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
ws, envOverrides, cleanup := b.prepareWorkspace(ctx)
|
ws, envOverrides, cleanup := b.prepareWorkspace()
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
cmd := exec.CommandContext(execCtx, b.cursorPath, buildCLICommandArgs(prompt, model, ws, b.mode, false, b.chatOnly)...)
|
cmd := exec.CommandContext(execCtx, b.cursorPath, buildCLICommandArgs(prompt, model, ws, false, b.chatOnly)...)
|
||||||
cmd.Dir = ws
|
cmd.Dir = ws
|
||||||
cmd.Env = workspace.MergeEnv(os.Environ(), envOverrides)
|
cmd.Env = workspace.MergeEnv(os.Environ(), envOverrides)
|
||||||
var stdout, stderr bytes.Buffer
|
var stdout, stderr bytes.Buffer
|
||||||
|
|
@ -312,7 +237,6 @@ type ACPBridge struct {
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
timeout time.Duration
|
timeout time.Duration
|
||||||
chatOnly bool
|
chatOnly bool
|
||||||
workspaceRoot string
|
|
||||||
workers []*acpWorker
|
workers []*acpWorker
|
||||||
nextWorker atomic.Uint32
|
nextWorker atomic.Uint32
|
||||||
sessionsMu sync.Mutex
|
sessionsMu sync.Mutex
|
||||||
|
|
@ -328,21 +252,20 @@ type acpSessionHandle struct {
|
||||||
LastUsedAt time.Time
|
LastUsedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewACPBridge(opts Options) *ACPBridge {
|
func NewACPBridge(cursorPath string, logger *slog.Logger, chatOnly bool, maxConcurrent int, timeout time.Duration) *ACPBridge {
|
||||||
if opts.MaxConcurrent <= 0 {
|
if maxConcurrent <= 0 {
|
||||||
opts.MaxConcurrent = 1
|
maxConcurrent = 1
|
||||||
}
|
}
|
||||||
bridge := &ACPBridge{
|
bridge := &ACPBridge{
|
||||||
cursorPath: opts.CursorPath,
|
cursorPath: cursorPath,
|
||||||
logger: opts.Logger,
|
logger: logger,
|
||||||
timeout: opts.Timeout,
|
timeout: timeout,
|
||||||
chatOnly: opts.ChatOnly,
|
chatOnly: chatOnly,
|
||||||
workspaceRoot: opts.WorkspaceRoot,
|
|
||||||
sessions: make(map[string]acpSessionHandle),
|
sessions: make(map[string]acpSessionHandle),
|
||||||
sessionTTL: 30 * time.Minute,
|
sessionTTL: 30 * time.Minute,
|
||||||
}
|
}
|
||||||
for i := 0; i < opts.MaxConcurrent; i++ {
|
for i := 0; i < maxConcurrent; i++ {
|
||||||
bridge.workers = append(bridge.workers, newACPWorker(opts.CursorPath, opts.Logger, opts.ChatOnly, opts.WorkspaceRoot, opts.Timeout))
|
bridge.workers = append(bridge.workers, newACPWorker(cursorPath, logger, chatOnly, timeout))
|
||||||
}
|
}
|
||||||
return bridge
|
return bridge
|
||||||
}
|
}
|
||||||
|
|
@ -385,7 +308,6 @@ type acpWorker struct {
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
timeout time.Duration
|
timeout time.Duration
|
||||||
chatOnly bool
|
chatOnly bool
|
||||||
workspaceRoot string
|
|
||||||
|
|
||||||
reqMu sync.Mutex
|
reqMu sync.Mutex
|
||||||
|
|
||||||
|
|
@ -405,13 +327,12 @@ type acpWorker struct {
|
||||||
generation atomic.Uint64
|
generation atomic.Uint64
|
||||||
}
|
}
|
||||||
|
|
||||||
func newACPWorker(cursorPath string, logger *slog.Logger, chatOnly bool, workspaceRoot string, timeout time.Duration) *acpWorker {
|
func newACPWorker(cursorPath string, logger *slog.Logger, chatOnly bool, timeout time.Duration) *acpWorker {
|
||||||
return &acpWorker{
|
return &acpWorker{
|
||||||
cursorPath: cursorPath,
|
cursorPath: cursorPath,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
timeout: timeout,
|
timeout: timeout,
|
||||||
chatOnly: chatOnly,
|
chatOnly: chatOnly,
|
||||||
workspaceRoot: workspaceRoot,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -459,7 +380,7 @@ func (b *ACPBridge) ExecuteSync(ctx context.Context, prompt string, model string
|
||||||
|
|
||||||
func (b *ACPBridge) pickWorker() *acpWorker {
|
func (b *ACPBridge) pickWorker() *acpWorker {
|
||||||
if len(b.workers) == 0 {
|
if len(b.workers) == 0 {
|
||||||
return newACPWorker(b.cursorPath, b.logger, b.chatOnly, b.workspaceRoot, b.timeout)
|
return newACPWorker(b.cursorPath, b.logger, b.chatOnly, b.timeout)
|
||||||
}
|
}
|
||||||
idx := int(b.nextWorker.Add(1)-1) % len(b.workers)
|
idx := int(b.nextWorker.Add(1)-1) % len(b.workers)
|
||||||
return b.workers[idx]
|
return b.workers[idx]
|
||||||
|
|
@ -692,15 +613,12 @@ func (w *acpWorker) ensureStartedLocked(ctx context.Context, model string) error
|
||||||
env map[string]string
|
env map[string]string
|
||||||
err error
|
err error
|
||||||
)
|
)
|
||||||
switch {
|
if w.chatOnly {
|
||||||
case w.workspaceRoot != "":
|
|
||||||
dir = w.workspaceRoot
|
|
||||||
case w.chatOnly:
|
|
||||||
dir, env, err = workspace.ChatOnly("")
|
dir, env, err = workspace.ChatOnly("")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("chat-only workspace: %w", err)
|
return fmt.Errorf("chat-only workspace: %w", err)
|
||||||
}
|
}
|
||||||
default:
|
} else {
|
||||||
dir, err = os.MkdirTemp("", "cursor-acp-worker-*")
|
dir, err = os.MkdirTemp("", "cursor-acp-worker-*")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("temp workspace: %w", err)
|
return fmt.Errorf("temp workspace: %w", err)
|
||||||
|
|
@ -1005,11 +923,7 @@ func (w *acpWorker) resetLocked() {
|
||||||
}
|
}
|
||||||
|
|
||||||
if w.workspace != "" {
|
if w.workspace != "" {
|
||||||
// Only remove temp / chat-only directories — never delete a
|
|
||||||
// configured WorkspaceRoot (that's a real user directory).
|
|
||||||
if w.workspace != w.workspaceRoot {
|
|
||||||
_ = os.RemoveAll(w.workspace)
|
_ = os.RemoveAll(w.workspace)
|
||||||
}
|
|
||||||
w.workspace = ""
|
w.workspace = ""
|
||||||
}
|
}
|
||||||
w.envOverrides = nil
|
w.envOverrides = nil
|
||||||
|
|
|
||||||
|
|
@ -11,12 +11,8 @@ import (
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func cliOpts(path string, chatOnly bool, max int, timeout time.Duration) Options {
|
|
||||||
return Options{CursorPath: path, ChatOnly: chatOnly, MaxConcurrent: max, Timeout: timeout}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewBridge(t *testing.T) {
|
func TestNewBridge(t *testing.T) {
|
||||||
b := NewCLIBridge(cliOpts("/usr/bin/agent", false, 4, 30*time.Second))
|
b := NewCLIBridge("/usr/bin/agent", false, 4, 30*time.Second)
|
||||||
if b == nil {
|
if b == nil {
|
||||||
t.Fatal("NewCLIBridge returned nil")
|
t.Fatal("NewCLIBridge returned nil")
|
||||||
}
|
}
|
||||||
|
|
@ -32,14 +28,14 @@ func TestNewBridge(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNewBridge_DefaultConcurrency(t *testing.T) {
|
func TestNewBridge_DefaultConcurrency(t *testing.T) {
|
||||||
b := NewCLIBridge(cliOpts("agent", false, 0, 10*time.Second))
|
b := NewCLIBridge("agent", false, 0, 10*time.Second)
|
||||||
if cap(b.semaphore) != 1 {
|
if cap(b.semaphore) != 1 {
|
||||||
t.Errorf("semaphore capacity = %d, want 1 (default)", cap(b.semaphore))
|
t.Errorf("semaphore capacity = %d, want 1 (default)", cap(b.semaphore))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNewBridge_NegativeConcurrency(t *testing.T) {
|
func TestNewBridge_NegativeConcurrency(t *testing.T) {
|
||||||
b := NewCLIBridge(cliOpts("agent", false, -5, 10*time.Second))
|
b := NewCLIBridge("agent", false, -5, 10*time.Second)
|
||||||
if cap(b.semaphore) != 1 {
|
if cap(b.semaphore) != 1 {
|
||||||
t.Errorf("semaphore capacity = %d, want 1 (default for negative)", cap(b.semaphore))
|
t.Errorf("semaphore capacity = %d, want 1 (default for negative)", cap(b.semaphore))
|
||||||
}
|
}
|
||||||
|
|
@ -47,7 +43,7 @@ func TestNewBridge_NegativeConcurrency(t *testing.T) {
|
||||||
|
|
||||||
func TestNewBridge_UsesACPWhenRequested(t *testing.T) {
|
func TestNewBridge_UsesACPWhenRequested(t *testing.T) {
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
b := NewBridge(Options{CursorPath: "agent", Logger: logger, UseACP: true, MaxConcurrent: 2, Timeout: 10 * time.Second})
|
b := NewBridge("agent", logger, true, false, 2, 10*time.Second)
|
||||||
if _, ok := b.(*ACPBridge); !ok {
|
if _, ok := b.(*ACPBridge); !ok {
|
||||||
t.Fatalf("expected ACPBridge, got %T", b)
|
t.Fatalf("expected ACPBridge, got %T", b)
|
||||||
}
|
}
|
||||||
|
|
@ -79,11 +75,11 @@ func TestBuildACPCommandArgs_WithModel(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBuildCLICommandArgs_PlanMode(t *testing.T) {
|
func TestBuildCLICommandArgs_UsesAskMode(t *testing.T) {
|
||||||
got := buildCLICommandArgs("hello", "auto", "/tmp/workspace", "plan", true, false)
|
got := buildCLICommandArgs("hello", "auto", "/tmp/workspace", true, false)
|
||||||
wantPrefix := []string{
|
wantPrefix := []string{
|
||||||
"--print",
|
"--print",
|
||||||
"--mode", "plan",
|
"--mode", "ask",
|
||||||
"--workspace", "/tmp/workspace",
|
"--workspace", "/tmp/workspace",
|
||||||
"--model", "auto",
|
"--model", "auto",
|
||||||
"--stream-partial-output", "--output-format", "stream-json",
|
"--stream-partial-output", "--output-format", "stream-json",
|
||||||
|
|
@ -102,7 +98,7 @@ func TestBuildCLICommandArgs_PlanMode(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBuildCLICommandArgs_ChatOnlyAddsTrust(t *testing.T) {
|
func TestBuildCLICommandArgs_ChatOnlyAddsTrust(t *testing.T) {
|
||||||
got := buildCLICommandArgs("hi", "", "/tmp/ws", "plan", false, true)
|
got := buildCLICommandArgs("hi", "", "/tmp/ws", false, true)
|
||||||
found := false
|
found := false
|
||||||
for _, a := range got {
|
for _, a := range got {
|
||||||
if a == "--trust" {
|
if a == "--trust" {
|
||||||
|
|
@ -115,35 +111,16 @@ func TestBuildCLICommandArgs_ChatOnlyAddsTrust(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBuildCLICommandArgs_AgentModeOmitsModeFlagAndAddsTrust(t *testing.T) {
|
// mockCmdHelper builds a bridge that executes a fake command for channel logic testing.
|
||||||
got := buildCLICommandArgs("hi", "", "/Users/me/Desktop", "agent", false, false)
|
|
||||||
for _, a := range got {
|
|
||||||
if a == "--mode" {
|
|
||||||
t.Fatalf("agent mode should not emit --mode flag, args: %v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
hasTrust := false
|
|
||||||
for _, a := range got {
|
|
||||||
if a == "--trust" {
|
|
||||||
hasTrust = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !hasTrust {
|
|
||||||
t.Fatalf("agent mode should imply --trust, args: %v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// mockCmdBridge builds a bridge that executes a fake command for channel logic testing.
|
|
||||||
//
|
|
||||||
//nolint:unused
|
|
||||||
func mockCmdBridge(t *testing.T) *CLIBridge {
|
func mockCmdBridge(t *testing.T) *CLIBridge {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
return NewCLIBridge(cliOpts("echo", false, 2, 5*time.Second))
|
// Use "echo" as a mock command that outputs valid JSON lines
|
||||||
|
// We'll override Execute logic by using a custom cursorPath that is "echo"
|
||||||
|
return NewCLIBridge("echo", false, 2, 5*time.Second)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExecute_ContextCancelled(t *testing.T) {
|
func TestExecute_ContextCancelled(t *testing.T) {
|
||||||
b := NewCLIBridge(cliOpts("/bin/sleep", false, 1, 30*time.Second))
|
b := NewCLIBridge("/bin/sleep", false, 1, 30*time.Second)
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
cancel() // cancel immediately
|
cancel() // cancel immediately
|
||||||
|
|
||||||
|
|
@ -171,7 +148,7 @@ func TestExecute_ContextCancelled(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExecute_SemaphoreBlocking(t *testing.T) {
|
func TestExecute_SemaphoreBlocking(t *testing.T) {
|
||||||
b := NewCLIBridge(cliOpts("/bin/sleep", false, 1, 30*time.Second))
|
b := NewCLIBridge("/bin/sleep", false, 1, 30*time.Second)
|
||||||
|
|
||||||
// Fill the semaphore
|
// Fill the semaphore
|
||||||
b.semaphore <- struct{}{}
|
b.semaphore <- struct{}{}
|
||||||
|
|
@ -196,7 +173,7 @@ func TestExecute_SemaphoreBlocking(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExecute_InvalidCommand(t *testing.T) {
|
func TestExecute_InvalidCommand(t *testing.T) {
|
||||||
b := NewCLIBridge(cliOpts("/nonexistent/command", false, 1, 5*time.Second))
|
b := NewCLIBridge("/nonexistent/command", false, 1, 5*time.Second)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
outputChan, errChan := b.Execute(ctx, "test", "model", "")
|
outputChan, errChan := b.Execute(ctx, "test", "model", "")
|
||||||
|
|
@ -219,7 +196,7 @@ func TestExecute_InvalidCommand(t *testing.T) {
|
||||||
|
|
||||||
func TestExecute_ValidJSONOutput(t *testing.T) {
|
func TestExecute_ValidJSONOutput(t *testing.T) {
|
||||||
// Use "printf" to simulate JSON line output
|
// Use "printf" to simulate JSON line output
|
||||||
b := NewCLIBridge(cliOpts("printf", false, 2, 5*time.Second))
|
b := NewCLIBridge("printf", false, 2, 5*time.Second)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
// printf with JSON lines
|
// printf with JSON lines
|
||||||
|
|
|
||||||
|
|
@ -18,28 +18,6 @@ type Config struct {
|
||||||
ChatOnlyWorkspace bool `yaml:"chat_only_workspace"`
|
ChatOnlyWorkspace bool `yaml:"chat_only_workspace"`
|
||||||
LogLevel string `yaml:"log_level"`
|
LogLevel string `yaml:"log_level"`
|
||||||
AvailableModels []string `yaml:"available_models,omitempty"`
|
AvailableModels []string `yaml:"available_models,omitempty"`
|
||||||
SystemPrompt string `yaml:"system_prompt"`
|
|
||||||
|
|
||||||
// CursorMode controls how the Cursor CLI subprocess is launched.
|
|
||||||
// "plan" (default): pass `--mode plan`. The CLI never executes
|
|
||||||
// tools; it only proposes plans. Combined with brain
|
|
||||||
// SystemPrompt + <tool_call> sentinel translation, the
|
|
||||||
// caller (Claude Desktop) is the executor.
|
|
||||||
// "agent": omit `--mode`, letting Cursor CLI use its native agent
|
|
||||||
// mode with full filesystem/shell tools. The CLI itself
|
|
||||||
// becomes the executor and acts inside WorkspaceRoot.
|
|
||||||
CursorMode string `yaml:"cursor_mode"`
|
|
||||||
|
|
||||||
// WorkspaceRoot, when non-empty, is the absolute directory the Cursor
|
|
||||||
// CLI subprocess runs in (and treats as its project root). Setting
|
|
||||||
// this disables the chat-only temp workspace isolation. Useful when
|
|
||||||
// you want the CLI to actually edit files on the host (e.g. set to
|
|
||||||
// /Users/<you>/Desktop and use cursor_mode: agent to let it
|
|
||||||
// reorganise that folder directly).
|
|
||||||
//
|
|
||||||
// Per-request override: clients may send `X-Cursor-Workspace: /abs/path`
|
|
||||||
// to switch the working directory just for that call.
|
|
||||||
WorkspaceRoot string `yaml:"workspace_root"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Defaults returns a Config populated with default values.
|
// Defaults returns a Config populated with default values.
|
||||||
|
|
@ -60,91 +38,9 @@ func Defaults() Config {
|
||||||
UseACP: false,
|
UseACP: false,
|
||||||
ChatOnlyWorkspace: true,
|
ChatOnlyWorkspace: true,
|
||||||
LogLevel: "INFO",
|
LogLevel: "INFO",
|
||||||
SystemPrompt: DefaultSystemPrompt,
|
|
||||||
CursorMode: "plan",
|
|
||||||
WorkspaceRoot: "",
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultSystemPrompt is prepended to every prompt sent to the Cursor CLI.
|
|
||||||
// It puts the model in "remote brain" mode: it never executes anything
|
|
||||||
// directly; instead it either answers in plain text or emits a single
|
|
||||||
// <tool_call>{...}</tool_call> sentinel that the proxy translates into a
|
|
||||||
// real Anthropic tool_use block for the caller (Claude Desktop / Claude
|
|
||||||
// Code / opencode) to execute. The caller's tool_result comes back as
|
|
||||||
// transcript on the next turn.
|
|
||||||
const DefaultSystemPrompt = `You are the reasoning brain of a two-process AI system. ` +
|
|
||||||
`The caller (Claude Desktop / Claude Code / opencode) has the user's real machine, ` +
|
|
||||||
`filesystem, terminal, and any MCP-provided tools. You do not. You drive it remotely ` +
|
|
||||||
`by emitting tool invocations the proxy translates into real Anthropic tool_use blocks ` +
|
|
||||||
`for the caller to execute.
|
|
||||||
|
|
||||||
Each turn you receive (a) the user's request, (b) the list of executors the caller ` +
|
|
||||||
`currently has under "Available executors", and (c) a transcript of past tool_use / ` +
|
|
||||||
`tool_result blocks.
|
|
||||||
|
|
||||||
CONTRACT — output exactly ONE of:
|
|
||||||
|
|
||||||
1. A normal answer in plain markdown (when no tool is needed, or when you are ` +
|
|
||||||
`presenting the final result to the user).
|
|
||||||
|
|
||||||
2. EXACTLY one tool invocation, wrapped in sentinels with NOTHING else around it:
|
|
||||||
|
|
||||||
<tool_call>
|
|
||||||
{"name":"<tool_name>","input": { ... }}
|
|
||||||
</tool_call>
|
|
||||||
|
|
||||||
"name" must match an executor listed under "Available executors"; "input" must ` +
|
|
||||||
`conform to that tool's input_schema.
|
|
||||||
|
|
||||||
HARD RULES:
|
|
||||||
- NEVER claim you "cannot access the filesystem / terminal / browser", "are in Ask ` +
|
|
||||||
`or Plan mode", "are sandboxed", or "need the user to do it manually". Those ` +
|
|
||||||
`statements are false — the caller is your hands.
|
|
||||||
- NEVER fabricate tool names. If the tool you need is not in the list, explain in ` +
|
|
||||||
`plain text what should be run and stop.
|
|
||||||
- Prefer a single tool_call per turn; iterate via tool_result feedback.
|
|
||||||
- Default shell when emitting raw commands as text: zsh on macOS.
|
|
||||||
|
|
||||||
VOCABULARY — when the user says any of:
|
|
||||||
"工作目錄" / "working directory" / "cwd" / "pwd" / "目前資料夾" /
|
|
||||||
"這個資料夾" / "the folder" / "the project"
|
|
||||||
they ALWAYS mean the folder the caller (Claude Desktop / Claude Code / opencode) ` +
|
|
||||||
`attached or opened for this session — i.e. the host-mounted folder visible ` +
|
|
||||||
`to the caller's executor (typically under ` + "`/sessions/<id>/mnt/<X>`" + ` or ` +
|
|
||||||
`an absolute host path). They NEVER mean the directory your own subprocess ` +
|
|
||||||
`happens to be running in, and they NEVER mean a sandbox path like ` +
|
|
||||||
"`/sessions/.../mnt/`" + ` with no folder under it. If you are tempted to call ` +
|
|
||||||
"`pwd`" + ` and answer with that, stop — the answer the user wants is the ` +
|
|
||||||
`mount root, which is found by listing ` + "`/sessions/*/mnt/*/`" + ` (see ` +
|
|
||||||
`ORIENTATION below) or by reading the "Known host-mount paths" section.
|
|
||||||
|
|
||||||
ORIENTATION (first turn of a fresh session):
|
|
||||||
The caller's executor often runs inside a sandbox (e.g. Claude Desktop's ` +
|
|
||||||
`Cowork) that bind-mounts ONE folder the user attached for this session. ` +
|
|
||||||
`The folder's name is unknown to you in advance — it could be Desktop, a ` +
|
|
||||||
`project root, Documents, anything. From the sandbox it shows up under ` +
|
|
||||||
"`/sessions/<id>/mnt/<whatever>`" + `, and that path IS the user's working ` +
|
|
||||||
`folder for this conversation regardless of its name.
|
|
||||||
|
|
||||||
If the user refers to "my folder" / "the mounted folder" / "this project" / ` +
|
|
||||||
`"the desktop" / etc. and you have a shell-like executor available but no ` +
|
|
||||||
`path has been established yet (no ` + "`Working directory:`" + ` line, no ` +
|
|
||||||
`"Known host-mount paths" section, no prior tool_result revealing one), ` +
|
|
||||||
`your FIRST tool_call must be a single discovery probe that enumerates ` +
|
|
||||||
`every mount under ` + "`/sessions/*/mnt/`" + `, e.g.:
|
|
||||||
|
|
||||||
<tool_call>
|
|
||||||
{"name":"<shell_tool>","input":{"command":"pwd; ls -d /sessions/*/mnt/*/ 2>/dev/null; ls -la /workspace 2>/dev/null | head"}}
|
|
||||||
</tool_call>
|
|
||||||
|
|
||||||
Treat whatever directory comes back under ` + "`/sessions/*/mnt/<X>`" + ` as ` +
|
|
||||||
`THE working folder for this session, no matter what ` + "`<X>`" + ` is. ` +
|
|
||||||
`Then use that path (or subpaths under it) for every subsequent tool_call. ` +
|
|
||||||
`Do NOT ask the user to name or re-state the folder — they already attached ` +
|
|
||||||
`it. The proxy also re-surfaces previously discovered mount roots under ` +
|
|
||||||
`"Known host-mount paths" on later turns; prefer those over re-probing.`
|
|
||||||
|
|
||||||
// Load reads a YAML config file from path. If path is empty it defaults to
|
// Load reads a YAML config file from path. If path is empty it defaults to
|
||||||
// ~/.cursor-adapter/config.yaml. When the file does not exist, a config with
|
// ~/.cursor-adapter/config.yaml. When the file does not exist, a config with
|
||||||
// default values is returned without an error.
|
// default values is returned without an error.
|
||||||
|
|
@ -188,15 +84,5 @@ func (c *Config) validate() error {
|
||||||
if c.Timeout <= 0 {
|
if c.Timeout <= 0 {
|
||||||
return fmt.Errorf("timeout must be > 0, got %d", c.Timeout)
|
return fmt.Errorf("timeout must be > 0, got %d", c.Timeout)
|
||||||
}
|
}
|
||||||
switch c.CursorMode {
|
|
||||||
case "", "plan", "agent":
|
|
||||||
default:
|
|
||||||
return fmt.Errorf("cursor_mode must be \"plan\" or \"agent\", got %q", c.CursorMode)
|
|
||||||
}
|
|
||||||
if c.WorkspaceRoot != "" {
|
|
||||||
if !filepath.IsAbs(c.WorkspaceRoot) {
|
|
||||||
return fmt.Errorf("workspace_root must be an absolute path, got %q", c.WorkspaceRoot)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -66,9 +66,6 @@ available_models:
|
||||||
ChatOnlyWorkspace: true,
|
ChatOnlyWorkspace: true,
|
||||||
LogLevel: "DEBUG",
|
LogLevel: "DEBUG",
|
||||||
AvailableModels: []string{"gpt-5.2", "claude-sonnet-4-20250514"},
|
AvailableModels: []string{"gpt-5.2", "claude-sonnet-4-20250514"},
|
||||||
SystemPrompt: DefaultSystemPrompt,
|
|
||||||
CursorMode: "plan",
|
|
||||||
WorkspaceRoot: "",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if !reflect.DeepEqual(*cfg, want) {
|
if !reflect.DeepEqual(*cfg, want) {
|
||||||
|
|
|
||||||
|
|
@ -6,15 +6,6 @@ import "strings"
|
||||||
// Allows users to configure friendly names in OpenCode instead of memorising
|
// Allows users to configure friendly names in OpenCode instead of memorising
|
||||||
// exact Cursor IDs like "claude-4.6-sonnet-medium".
|
// exact Cursor IDs like "claude-4.6-sonnet-medium".
|
||||||
var shortAlias = map[string]string{
|
var shortAlias = map[string]string{
|
||||||
// Claude 4.7
|
|
||||||
"opus-4.7": "claude-opus-4-7-high",
|
|
||||||
"opus-4.7-thinking": "claude-opus-4-7-thinking-high",
|
|
||||||
"opus-4.7-low": "claude-opus-4-7-low",
|
|
||||||
"opus-4.7-medium": "claude-opus-4-7-medium",
|
|
||||||
"opus-4.7-high": "claude-opus-4-7-high",
|
|
||||||
"opus-4.7-xhigh": "claude-opus-4-7-xhigh",
|
|
||||||
"opus-4.7-max": "claude-opus-4-7-max",
|
|
||||||
|
|
||||||
// Claude 4.6
|
// Claude 4.6
|
||||||
"sonnet-4.6": "claude-4.6-sonnet-medium",
|
"sonnet-4.6": "claude-4.6-sonnet-medium",
|
||||||
"sonnet-4.6-thinking": "claude-4.6-sonnet-medium-thinking",
|
"sonnet-4.6-thinking": "claude-4.6-sonnet-medium-thinking",
|
||||||
|
|
@ -32,28 +23,20 @@ var shortAlias = map[string]string{
|
||||||
"sonnet-4": "claude-4-sonnet",
|
"sonnet-4": "claude-4-sonnet",
|
||||||
"sonnet-4-thinking": "claude-4-sonnet-thinking",
|
"sonnet-4-thinking": "claude-4-sonnet-thinking",
|
||||||
|
|
||||||
// Anthropic API-style names → Cursor IDs
|
// Anthropic API-style names
|
||||||
// Claude 4.7
|
|
||||||
"claude-opus-4-7": "claude-opus-4-7-high",
|
|
||||||
"claude-opus-4.7": "claude-opus-4-7-high",
|
|
||||||
"claude-opus-4-7-thinking": "claude-opus-4-7-thinking-high",
|
|
||||||
"claude-opus-4.7-thinking": "claude-opus-4-7-thinking-high",
|
|
||||||
// Claude 4.6
|
|
||||||
"claude-opus-4-6": "claude-4.6-opus-high",
|
"claude-opus-4-6": "claude-4.6-opus-high",
|
||||||
"claude-opus-4.6": "claude-4.6-opus-high",
|
"claude-opus-4.6": "claude-4.6-opus-high",
|
||||||
"claude-sonnet-4-6": "claude-4.6-sonnet-medium",
|
"claude-sonnet-4-6": "claude-4.6-sonnet-medium",
|
||||||
"claude-sonnet-4.6": "claude-4.6-sonnet-medium",
|
"claude-sonnet-4.6": "claude-4.6-sonnet-medium",
|
||||||
"claude-opus-4-6-thinking": "claude-4.6-opus-high-thinking",
|
|
||||||
"claude-sonnet-4-6-thinking": "claude-4.6-sonnet-medium-thinking",
|
|
||||||
// Claude 4.5
|
|
||||||
"claude-opus-4-5": "claude-4.5-opus-high",
|
"claude-opus-4-5": "claude-4.5-opus-high",
|
||||||
"claude-opus-4.5": "claude-4.5-opus-high",
|
"claude-opus-4.5": "claude-4.5-opus-high",
|
||||||
"claude-sonnet-4-5": "claude-4.5-sonnet",
|
"claude-sonnet-4-5": "claude-4.5-sonnet",
|
||||||
"claude-sonnet-4.5": "claude-4.5-sonnet",
|
"claude-sonnet-4.5": "claude-4.5-sonnet",
|
||||||
|
"claude-sonnet-4": "claude-4-sonnet",
|
||||||
|
"claude-opus-4-6-thinking": "claude-4.6-opus-high-thinking",
|
||||||
|
"claude-sonnet-4-6-thinking": "claude-4.6-sonnet-medium-thinking",
|
||||||
"claude-opus-4-5-thinking": "claude-4.5-opus-high-thinking",
|
"claude-opus-4-5-thinking": "claude-4.5-opus-high-thinking",
|
||||||
"claude-sonnet-4-5-thinking": "claude-4.5-sonnet-thinking",
|
"claude-sonnet-4-5-thinking": "claude-4.5-sonnet-thinking",
|
||||||
// Claude 4
|
|
||||||
"claude-sonnet-4": "claude-4-sonnet",
|
|
||||||
"claude-sonnet-4-thinking": "claude-4-sonnet-thinking",
|
"claude-sonnet-4-thinking": "claude-4-sonnet-thinking",
|
||||||
|
|
||||||
// Old Anthropic date-based names
|
// Old Anthropic date-based names
|
||||||
|
|
@ -88,32 +71,16 @@ type aliasEntry struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
var reverseAliases = []aliasEntry{
|
var reverseAliases = []aliasEntry{
|
||||||
// Claude 4.7 — Cursor uses "claude-opus-4-7-*" natively, add friendly aliases
|
{"claude-4.6-opus-high", "claude-opus-4-6", "Claude 4.6 Opus"},
|
||||||
{"claude-opus-4-7-low", "claude-opus-4.7-low", "Claude Opus 4.7 (Low)"},
|
{"claude-4.6-opus-high-thinking", "claude-opus-4-6-thinking", "Claude 4.6 Opus (Thinking)"},
|
||||||
{"claude-opus-4-7-medium", "claude-opus-4.7-medium", "Claude Opus 4.7 (Medium)"},
|
{"claude-4.6-sonnet-medium", "claude-sonnet-4-6", "Claude 4.6 Sonnet"},
|
||||||
{"claude-opus-4-7-high", "claude-opus-4.7-high", "Claude Opus 4.7"},
|
{"claude-4.6-sonnet-medium-thinking", "claude-sonnet-4-6-thinking", "Claude 4.6 Sonnet (Thinking)"},
|
||||||
{"claude-opus-4-7-xhigh", "claude-opus-4.7-xhigh", "Claude Opus 4.7 (XHigh)"},
|
{"claude-4.5-opus-high", "claude-opus-4-5", "Claude 4.5 Opus"},
|
||||||
{"claude-opus-4-7-max", "claude-opus-4.7-max", "Claude Opus 4.7 (Max)"},
|
{"claude-4.5-opus-high-thinking", "claude-opus-4-5-thinking", "Claude 4.5 Opus (Thinking)"},
|
||||||
{"claude-opus-4-7-thinking-low", "claude-opus-4.7-thinking-low", "Claude Opus 4.7 Thinking (Low)"},
|
{"claude-4.5-sonnet", "claude-sonnet-4-5", "Claude 4.5 Sonnet"},
|
||||||
{"claude-opus-4-7-thinking-medium", "claude-opus-4.7-thinking-medium", "Claude Opus 4.7 Thinking (Medium)"},
|
{"claude-4.5-sonnet-thinking", "claude-sonnet-4-5-thinking", "Claude 4.5 Sonnet (Thinking)"},
|
||||||
{"claude-opus-4-7-thinking-high", "claude-opus-4.7-thinking-high", "Claude Opus 4.7 Thinking"},
|
{"claude-4-sonnet", "claude-sonnet-4", "Claude 4 Sonnet"},
|
||||||
{"claude-opus-4-7-thinking-xhigh", "claude-opus-4.7-thinking-xhigh", "Claude Opus 4.7 Thinking (XHigh)"},
|
{"claude-4-sonnet-thinking", "claude-sonnet-4-thinking", "Claude 4 Sonnet (Thinking)"},
|
||||||
{"claude-opus-4-7-thinking-max", "claude-opus-4.7-thinking-max", "Claude Opus 4.7 Thinking (Max)"},
|
|
||||||
// Claude 4.6
|
|
||||||
{"claude-4.6-opus-high", "claude-opus-4-6", "Claude Opus 4.6"},
|
|
||||||
{"claude-4.6-opus-high-thinking", "claude-opus-4-6-thinking", "Claude Opus 4.6 (Thinking)"},
|
|
||||||
{"claude-4.6-opus-max", "claude-opus-4-6-max", "Claude Opus 4.6 (Max)"},
|
|
||||||
{"claude-4.6-opus-max-thinking", "claude-opus-4-6-max-thinking", "Claude Opus 4.6 Max (Thinking)"},
|
|
||||||
{"claude-4.6-sonnet-medium", "claude-sonnet-4-6", "Claude Sonnet 4.6"},
|
|
||||||
{"claude-4.6-sonnet-medium-thinking", "claude-sonnet-4-6-thinking", "Claude Sonnet 4.6 (Thinking)"},
|
|
||||||
// Claude 4.5
|
|
||||||
{"claude-4.5-opus-high", "claude-opus-4-5", "Claude Opus 4.5"},
|
|
||||||
{"claude-4.5-opus-high-thinking", "claude-opus-4-5-thinking", "Claude Opus 4.5 (Thinking)"},
|
|
||||||
{"claude-4.5-sonnet", "claude-sonnet-4-5", "Claude Sonnet 4.5"},
|
|
||||||
{"claude-4.5-sonnet-thinking", "claude-sonnet-4-5-thinking", "Claude Sonnet 4.5 (Thinking)"},
|
|
||||||
// Claude 4
|
|
||||||
{"claude-4-sonnet", "claude-sonnet-4", "Claude Sonnet 4"},
|
|
||||||
{"claude-4-sonnet-thinking", "claude-sonnet-4-thinking", "Claude Sonnet 4 (Thinking)"},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAnthropicModelAliases returns alias entries for models available in Cursor,
|
// GetAnthropicModelAliases returns alias entries for models available in Cursor,
|
||||||
|
|
|
||||||
|
|
@ -23,25 +23,6 @@ var rules = []rule{
|
||||||
{regexp.MustCompile(`(?i)\bcc_version=[^\s;,\n]+[;,]?\s*`), ""},
|
{regexp.MustCompile(`(?i)\bcc_version=[^\s;,\n]+[;,]?\s*`), ""},
|
||||||
{regexp.MustCompile(`(?i)\bcc_entrypoint=[^\s;,\n]+[;,]?\s*`), ""},
|
{regexp.MustCompile(`(?i)\bcc_entrypoint=[^\s;,\n]+[;,]?\s*`), ""},
|
||||||
{regexp.MustCompile(`(?i)\bcch=[a-f0-9]+[;,]?\s*`), ""},
|
{regexp.MustCompile(`(?i)\bcch=[a-f0-9]+[;,]?\s*`), ""},
|
||||||
|
|
||||||
// --- Sandbox / capability limitation stripping ---
|
|
||||||
// Claude Desktop's system prompt tells the model it's in a sandbox,
|
|
||||||
// cannot access the filesystem, is in "Ask mode" / "Cowork mode", etc.
|
|
||||||
// These phrases cause the model to refuse helpful responses. We strip
|
|
||||||
// them so the model still sees tool definitions but not the restrictions.
|
|
||||||
|
|
||||||
// "you cannot access ...", "you do not have access to ...", etc.
|
|
||||||
{regexp.MustCompile(`(?i)[^\n]*(?:you (?:cannot|can ?not|do not|don[''\x{2019}]t|are unable to) (?:access|read|write|modify|execute|run|create|delete|move|open))[^\n]*\n?`), ""},
|
|
||||||
// "you are in a sandboxed environment", "running in a sandbox", etc.
|
|
||||||
{regexp.MustCompile(`(?i)[^\n]*(?:sandbox(?:ed)?|isolated) (?:environment|mode|context)[^\n]*\n?`), ""},
|
|
||||||
// "you are in Ask mode" / "Cowork mode" / "read-only mode"
|
|
||||||
{regexp.MustCompile(`(?i)[^\n]*(?:Ask mode|Cowork(?:er)? mode|read[- ]only mode)[^\n]*\n?`), ""},
|
|
||||||
// "you don't have filesystem access" / "no filesystem access"
|
|
||||||
{regexp.MustCompile(`(?i)[^\n]*(?:no|without|lack(?:s|ing)?|limited) (?:file ?system|file|terminal|shell|command[- ]line) access[^\n]*\n?`), ""},
|
|
||||||
// "you cannot run commands on the user's machine"
|
|
||||||
{regexp.MustCompile(`(?i)[^\n]*cannot (?:run|execute) (?:commands?|scripts?|code) (?:on|in)[^\n]*\n?`), ""},
|
|
||||||
|
|
||||||
// --- Branding replacement ---
|
|
||||||
// Replace "Claude Code" product name with "Cursor" (case-sensitive on purpose).
|
// Replace "Claude Code" product name with "Cursor" (case-sensitive on purpose).
|
||||||
{regexp.MustCompile(`\bClaude Code\b`), "Cursor"},
|
{regexp.MustCompile(`\bClaude Code\b`), "Cursor"},
|
||||||
// Replace full Anthropic CLI description. Handle both straight and curly apostrophes.
|
// Replace full Anthropic CLI description. Handle both straight and curly apostrophes.
|
||||||
|
|
|
||||||
|
|
@ -3,188 +3,168 @@ package server
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"regexp"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/daniel/cursor-adapter/internal/sanitize"
|
"github.com/daniel/cursor-adapter/internal/sanitize"
|
||||||
"github.com/daniel/cursor-adapter/internal/types"
|
"github.com/daniel/cursor-adapter/internal/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
// systemReminderRe matches <system-reminder>...</system-reminder> blocks
|
// buildPromptFromAnthropicMessages flattens an Anthropic Messages request into
|
||||||
// that Claude Desktop embeds inside user messages.
|
// a single prompt string suitable for `agent --print`. It:
|
||||||
var systemReminderRe = regexp.MustCompile(`(?s)<system-reminder>.*?</system-reminder>\s*`)
|
// - renders tool_use / tool_result blocks as readable pseudo-XML so the
|
||||||
|
// model can follow the trajectory of previous tool calls
|
||||||
// buildPromptFromAnthropicMessages flattens an Anthropic Messages request
|
// - embeds the `tools` schema as part of the System block via
|
||||||
// into a single prompt string suitable for `agent --print`.
|
// toolsToSystemText, so the model knows what tools the outer agent (e.g.
|
||||||
//
|
// Claude Code) has available
|
||||||
// "Pure brain + remote executors" design:
|
// - runs every piece of free text through sanitize.Text to strip Claude Code
|
||||||
// - DROP all client system messages (mode descriptions / sandbox warnings
|
// branding and telemetry headers that would confuse the Cursor agent
|
||||||
// that make the model refuse).
|
func buildPromptFromAnthropicMessages(req types.AnthropicMessagesRequest) string {
|
||||||
// - USE ONLY the adapter's injected system prompt.
|
var systemParts []string
|
||||||
// - RENDER req.Tools as a plain-text inventory of executors that the
|
for _, block := range req.System {
|
||||||
// caller (Claude Desktop / Claude Code / opencode) owns. The brain must
|
if block.Type == "text" && strings.TrimSpace(block.Text) != "" {
|
||||||
// know it has remote hands.
|
systemParts = append(systemParts, sanitize.Text(block.Text))
|
||||||
// - RENDER assistant tool_use and user tool_result blocks as readable
|
}
|
||||||
// transcript, so multi-turn ReAct loops keep working.
|
}
|
||||||
// - STRIP <system-reminder> blocks embedded in user messages.
|
if tools := toolsToSystemText(req.Tools); tools != "" {
|
||||||
func buildPromptFromAnthropicMessages(req types.AnthropicMessagesRequest, injectedSystemPrompt string) string {
|
systemParts = append(systemParts, tools)
|
||||||
var prompt strings.Builder
|
|
||||||
|
|
||||||
if injectedSystemPrompt != "" {
|
|
||||||
prompt.WriteString("System:\n")
|
|
||||||
prompt.WriteString(injectedSystemPrompt)
|
|
||||||
prompt.WriteString("\n\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
if hints := renderMountHints(extractMountHints(req)); hints != "" {
|
|
||||||
prompt.WriteString(hints)
|
|
||||||
prompt.WriteString("\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
if toolsBlock := renderToolsForBrain(req.Tools); toolsBlock != "" {
|
|
||||||
prompt.WriteString(toolsBlock)
|
|
||||||
prompt.WriteString("\n")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var convo []string
|
||||||
for _, msg := range req.Messages {
|
for _, msg := range req.Messages {
|
||||||
text := renderMessageBlocks(msg.Role, msg.Content)
|
text := anthropicContentToText(msg.Content)
|
||||||
if text == "" {
|
if text == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
switch msg.Role {
|
switch msg.Role {
|
||||||
case "assistant":
|
case "assistant":
|
||||||
prompt.WriteString("Assistant: ")
|
convo = append(convo, "Assistant: "+text)
|
||||||
default:
|
default:
|
||||||
prompt.WriteString("User: ")
|
convo = append(convo, "User: "+text)
|
||||||
}
|
}
|
||||||
prompt.WriteString(text)
|
|
||||||
prompt.WriteString("\n\n")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
prompt.WriteString("Assistant:")
|
var prompt strings.Builder
|
||||||
|
if len(systemParts) > 0 {
|
||||||
|
prompt.WriteString("System:\n")
|
||||||
|
prompt.WriteString(strings.Join(systemParts, "\n\n"))
|
||||||
|
prompt.WriteString("\n\n")
|
||||||
|
}
|
||||||
|
prompt.WriteString(strings.Join(convo, "\n\n"))
|
||||||
|
prompt.WriteString("\n\nAssistant:")
|
||||||
return prompt.String()
|
return prompt.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// renderToolsForBrain converts the Anthropic tools[] array into a readable
|
// anthropicContentToText renders a single message's content blocks as a
|
||||||
// inventory the brain can reason about. The brain is told it MUST emit
|
// single string. Unlike the old implementation, this one preserves tool_use
|
||||||
// <tool_call>{...}</tool_call> sentinels when it wants to invoke one; the
|
// and tool_result blocks so the model sees the full conversation trajectory
|
||||||
// proxy translates that into real Anthropic tool_use blocks for the caller.
|
// rather than mysterious gaps.
|
||||||
func renderToolsForBrain(tools []types.AnthropicTool) string {
|
func anthropicContentToText(content types.AnthropicContent) string {
|
||||||
if len(tools) == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
var b strings.Builder
|
|
||||||
b.WriteString("Available executors (the caller will run these for you):\n")
|
|
||||||
for _, t := range tools {
|
|
||||||
b.WriteString("- ")
|
|
||||||
b.WriteString(t.Name)
|
|
||||||
if desc := strings.TrimSpace(t.Description); desc != "" {
|
|
||||||
b.WriteString(": ")
|
|
||||||
b.WriteString(singleLine(desc))
|
|
||||||
}
|
|
||||||
if len(t.InputSchema) > 0 {
|
|
||||||
b.WriteString("\n input_schema: ")
|
|
||||||
b.WriteString(compactJSON(t.InputSchema))
|
|
||||||
}
|
|
||||||
b.WriteString("\n")
|
|
||||||
}
|
|
||||||
b.WriteString("\nTo invoke a tool, output EXACTLY one fenced block (and nothing else for that turn):\n")
|
|
||||||
b.WriteString("<tool_call>\n")
|
|
||||||
b.WriteString(`{"name":"<tool_name>","input":{...}}` + "\n")
|
|
||||||
b.WriteString("</tool_call>\n")
|
|
||||||
b.WriteString("If you do NOT need a tool, just answer in plain text.\n")
|
|
||||||
return b.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// renderMessageBlocks renders a single message's content blocks into a
|
|
||||||
// transcript snippet. Text blocks are sanitised; tool_use blocks render as
|
|
||||||
// `[tool_call name=... input=...]`; tool_result blocks render as
|
|
||||||
// `[tool_result for=... ok|error] ...`.
|
|
||||||
func renderMessageBlocks(role string, content types.AnthropicContent) string {
|
|
||||||
var parts []string
|
var parts []string
|
||||||
for _, block := range content {
|
for _, block := range content {
|
||||||
switch block.Type {
|
switch block.Type {
|
||||||
case "text":
|
case "text":
|
||||||
if block.Text == "" {
|
if block.Text != "" {
|
||||||
continue
|
parts = append(parts, sanitize.Text(block.Text))
|
||||||
}
|
|
||||||
cleaned := systemReminderRe.ReplaceAllString(block.Text, "")
|
|
||||||
cleaned = sanitize.Text(cleaned)
|
|
||||||
cleaned = strings.TrimSpace(cleaned)
|
|
||||||
if cleaned != "" {
|
|
||||||
parts = append(parts, cleaned)
|
|
||||||
}
|
}
|
||||||
case "tool_use":
|
case "tool_use":
|
||||||
|
input := strings.TrimSpace(string(block.Input))
|
||||||
|
if input == "" {
|
||||||
|
input = "{}"
|
||||||
|
}
|
||||||
parts = append(parts, fmt.Sprintf(
|
parts = append(parts, fmt.Sprintf(
|
||||||
"[tool_call name=%q input=%s]",
|
"<tool_use id=%q name=%q>\n%s\n</tool_use>",
|
||||||
block.Name, compactJSON(block.Input),
|
block.ID, block.Name, input,
|
||||||
))
|
))
|
||||||
case "tool_result":
|
case "tool_result":
|
||||||
status := "ok"
|
body := toolResultBody(block.Content)
|
||||||
|
errAttr := ""
|
||||||
if block.IsError {
|
if block.IsError {
|
||||||
status = "error"
|
errAttr = ` is_error="true"`
|
||||||
}
|
|
||||||
body := renderToolResultContent(block.Content)
|
|
||||||
if body == "" {
|
|
||||||
body = "(empty)"
|
|
||||||
}
|
}
|
||||||
parts = append(parts, fmt.Sprintf(
|
parts = append(parts, fmt.Sprintf(
|
||||||
"[tool_result for=%s status=%s]\n%s",
|
"<tool_result tool_use_id=%q%s>\n%s\n</tool_result>",
|
||||||
block.ToolUseID, status, body,
|
block.ToolUseID, errAttr, body,
|
||||||
))
|
))
|
||||||
case "image", "document":
|
case "image":
|
||||||
parts = append(parts, fmt.Sprintf("[%s attached]", block.Type))
|
parts = append(parts, "[Image]")
|
||||||
|
case "document":
|
||||||
|
title := block.Title
|
||||||
|
if title == "" {
|
||||||
|
title = "Document"
|
||||||
|
}
|
||||||
|
parts = append(parts, "[Document: "+title+"]")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return strings.Join(parts, "\n")
|
return strings.Join(parts, "\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
// renderToolResultContent flattens a tool_result.content payload (which can
|
// toolResultBody flattens the `content` field of a tool_result block, which
|
||||||
// be a string or an array of {type:"text",text:...} blocks) to plain text.
|
// can be either a plain string or an array of `{type, text}` content parts.
|
||||||
func renderToolResultContent(raw json.RawMessage) string {
|
func toolResultBody(raw json.RawMessage) string {
|
||||||
if len(raw) == 0 {
|
if len(raw) == 0 {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
var s string
|
|
||||||
if err := json.Unmarshal(raw, &s); err == nil {
|
var asString string
|
||||||
return strings.TrimSpace(s)
|
if err := json.Unmarshal(raw, &asString); err == nil {
|
||||||
|
return sanitize.Text(asString)
|
||||||
}
|
}
|
||||||
var blocks []struct {
|
|
||||||
|
var parts []struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Text string `json:"text"`
|
Text string `json:"text"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(raw, &blocks); err == nil {
|
if err := json.Unmarshal(raw, &parts); err == nil {
|
||||||
var out []string
|
var out []string
|
||||||
for _, b := range blocks {
|
for _, p := range parts {
|
||||||
if b.Type == "text" && b.Text != "" {
|
if p.Type == "text" && p.Text != "" {
|
||||||
out = append(out, b.Text)
|
out = append(out, sanitize.Text(p.Text))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return strings.TrimSpace(strings.Join(out, "\n"))
|
return strings.Join(out, "\n")
|
||||||
}
|
|
||||||
return strings.TrimSpace(string(raw))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func compactJSON(raw json.RawMessage) string {
|
|
||||||
if len(raw) == 0 {
|
|
||||||
return "{}"
|
|
||||||
}
|
|
||||||
var v interface{}
|
|
||||||
if err := json.Unmarshal(raw, &v); err != nil {
|
|
||||||
return string(raw)
|
return string(raw)
|
||||||
}
|
}
|
||||||
out, err := json.Marshal(v)
|
|
||||||
if err != nil {
|
|
||||||
return string(raw)
|
|
||||||
}
|
|
||||||
return string(out)
|
|
||||||
}
|
|
||||||
|
|
||||||
func singleLine(s string) string {
|
// toolsToSystemText renders a tools schema array into a system-prompt chunk
|
||||||
s = strings.ReplaceAll(s, "\r", " ")
|
// describing each tool. The idea (from cursor-api-proxy) is that since the
|
||||||
s = strings.ReplaceAll(s, "\n", " ")
|
// Cursor CLI does not expose native tool_call deltas over the proxy, we tell
|
||||||
for strings.Contains(s, " ") {
|
// the model what tools exist so it can reference them in its text output.
|
||||||
s = strings.ReplaceAll(s, " ", " ")
|
//
|
||||||
|
// NOTE: This is a one-way passthrough. The proxy cannot turn the model's
|
||||||
|
// textual "I would call Write with {...}" back into structured tool_use
|
||||||
|
// blocks. Callers that need real tool-use routing (e.g. Claude Code's coding
|
||||||
|
// agent) should run tools client-side and feed tool_result back in.
|
||||||
|
func toolsToSystemText(tools []types.AnthropicTool) string {
|
||||||
|
if len(tools) == 0 {
|
||||||
|
return ""
|
||||||
}
|
}
|
||||||
return strings.TrimSpace(s)
|
|
||||||
|
var lines []string
|
||||||
|
lines = append(lines,
|
||||||
|
"Available tools (they belong to the caller, not to you; describe your",
|
||||||
|
"intended call in plain text and the caller will execute it):",
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
for _, t := range tools {
|
||||||
|
schema := strings.TrimSpace(string(t.InputSchema))
|
||||||
|
if schema == "" {
|
||||||
|
schema = "{}"
|
||||||
|
} else {
|
||||||
|
var pretty any
|
||||||
|
if err := json.Unmarshal(t.InputSchema, &pretty); err == nil {
|
||||||
|
if out, err := json.MarshalIndent(pretty, "", " "); err == nil {
|
||||||
|
schema = string(out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lines = append(lines,
|
||||||
|
"Function: "+t.Name,
|
||||||
|
"Description: "+sanitize.Text(t.Description),
|
||||||
|
"Parameters: "+schema,
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return strings.TrimRight(strings.Join(lines, "\n"), "\n")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,8 @@ package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
|
||||||
"encoding/hex"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -17,21 +13,16 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *Server) handleAnthropicMessages(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleAnthropicMessages(w http.ResponseWriter, r *http.Request) {
|
||||||
bodyBytes, readErr := io.ReadAll(r.Body)
|
|
||||||
if readErr != nil {
|
|
||||||
writeJSON(w, http.StatusBadRequest, types.NewErrorResponse("read body: "+readErr.Error(), "invalid_request_error", ""))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
r.Body.Close()
|
|
||||||
|
|
||||||
var req types.AnthropicMessagesRequest
|
var req types.AnthropicMessagesRequest
|
||||||
if err := json.Unmarshal(bodyBytes, &req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
writeJSON(w, http.StatusBadRequest, types.NewErrorResponse("invalid request body: "+err.Error(), "invalid_request_error", ""))
|
writeJSON(w, http.StatusBadRequest, types.NewErrorResponse("invalid request body: "+err.Error(), "invalid_request_error", ""))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
defer r.Body.Close()
|
||||||
|
|
||||||
if req.MaxTokens <= 0 {
|
if req.MaxTokens <= 0 {
|
||||||
req.MaxTokens = 16384
|
writeJSON(w, http.StatusBadRequest, types.NewErrorResponse("max_tokens is required", "invalid_request_error", ""))
|
||||||
|
return
|
||||||
}
|
}
|
||||||
if len(req.Messages) == 0 {
|
if len(req.Messages) == 0 {
|
||||||
writeJSON(w, http.StatusBadRequest, types.NewErrorResponse("messages must not be empty", "invalid_request_error", ""))
|
writeJSON(w, http.StatusBadRequest, types.NewErrorResponse("messages must not be empty", "invalid_request_error", ""))
|
||||||
|
|
@ -39,33 +30,14 @@ func (s *Server) handleAnthropicMessages(w http.ResponseWriter, r *http.Request)
|
||||||
}
|
}
|
||||||
|
|
||||||
model := req.Model
|
model := req.Model
|
||||||
if model == "" || model == "auto" {
|
if model == "" {
|
||||||
model = s.cfg.DefaultModel
|
model = s.cfg.DefaultModel
|
||||||
}
|
}
|
||||||
cursorModel := converter.ResolveToCursorModel(model)
|
cursorModel := converter.ResolveToCursorModel(model)
|
||||||
sessionKey := ensureSessionHeader(w, r)
|
sessionKey := ensureSessionHeader(w, r)
|
||||||
|
|
||||||
// Surface caller-side knobs in the log: which tool names the brain is
|
|
||||||
// about to see, and (if no explicit X-Cursor-Workspace header was set)
|
|
||||||
// any host directory the caller's prompt happens to mention. The
|
|
||||||
// detected directory is promoted onto the request header so the
|
|
||||||
// downstream bridge picks it up via the standard ctx override path.
|
|
||||||
if len(req.Tools) > 0 {
|
|
||||||
toolNames := make([]string, 0, len(req.Tools))
|
|
||||||
for _, t := range req.Tools {
|
|
||||||
toolNames = append(toolNames, t.Name)
|
|
||||||
}
|
|
||||||
log.Printf("[tools] caller has %d executors: %v", len(toolNames), toolNames)
|
|
||||||
}
|
|
||||||
if r.Header.Get(workspaceHeaderName) == "" {
|
|
||||||
if detected := detectAnthropicCwd(req); detected != "" {
|
|
||||||
log.Printf("[workspace] detected caller cwd from prompt: %s", detected)
|
|
||||||
r.Header.Set(workspaceHeaderName, detected)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
msgID := fmt.Sprintf("msg_%d", time.Now().UnixNano())
|
msgID := fmt.Sprintf("msg_%d", time.Now().UnixNano())
|
||||||
prompt := buildPromptFromAnthropicMessages(req, s.cfg.SystemPrompt)
|
prompt := buildPromptFromAnthropicMessages(req)
|
||||||
|
|
||||||
if req.Stream {
|
if req.Stream {
|
||||||
s.streamAnthropicMessages(w, r, prompt, cursorModel, model, msgID, sessionKey)
|
s.streamAnthropicMessages(w, r, prompt, cursorModel, model, msgID, sessionKey)
|
||||||
|
|
@ -78,9 +50,8 @@ func (s *Server) handleAnthropicMessages(w http.ResponseWriter, r *http.Request)
|
||||||
func (s *Server) streamAnthropicMessages(w http.ResponseWriter, r *http.Request, prompt, cursorModel, displayModel, msgID, sessionKey string) {
|
func (s *Server) streamAnthropicMessages(w http.ResponseWriter, r *http.Request, prompt, cursorModel, displayModel, msgID, sessionKey string) {
|
||||||
sse := NewSSEWriter(w)
|
sse := NewSSEWriter(w)
|
||||||
parser := converter.NewStreamParser(msgID)
|
parser := converter.NewStreamParser(msgID)
|
||||||
tcParser := NewToolCallStreamParser()
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(requestContext(r), time.Duration(s.cfg.Timeout)*time.Second)
|
ctx, cancel := context.WithTimeout(r.Context(), time.Duration(s.cfg.Timeout)*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
go func() {
|
go func() {
|
||||||
<-r.Context().Done()
|
<-r.Context().Done()
|
||||||
|
|
@ -99,68 +70,17 @@ func (s *Server) streamAnthropicMessages(w http.ResponseWriter, r *http.Request,
|
||||||
"content": []interface{}{},
|
"content": []interface{}{},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
st := &anthropicStreamState{
|
|
||||||
sse: sse,
|
|
||||||
blockIndex: 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
emitText := func(text string) {
|
|
||||||
if text == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
st.ensureTextBlockOpen()
|
|
||||||
writeAnthropicSSE(sse, map[string]interface{}{
|
|
||||||
"type": "content_block_delta",
|
|
||||||
"index": st.blockIndex,
|
|
||||||
"delta": map[string]interface{}{"type": "text_delta", "text": text},
|
|
||||||
})
|
|
||||||
st.outChars += len(text)
|
|
||||||
}
|
|
||||||
|
|
||||||
emitToolCall := func(call ParsedToolCall) {
|
|
||||||
st.closeTextBlockIfOpen()
|
|
||||||
st.blockIndex++
|
|
||||||
toolID := newToolUseID()
|
|
||||||
writeAnthropicSSE(sse, map[string]interface{}{
|
writeAnthropicSSE(sse, map[string]interface{}{
|
||||||
"type": "content_block_start",
|
"type": "content_block_start",
|
||||||
"index": st.blockIndex,
|
"index": 0,
|
||||||
"content_block": map[string]interface{}{
|
"content_block": map[string]interface{}{"type": "text", "text": ""},
|
||||||
"type": "tool_use",
|
|
||||||
"id": toolID,
|
|
||||||
"name": call.Name,
|
|
||||||
"input": map[string]interface{}{},
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
writeAnthropicSSE(sse, map[string]interface{}{
|
|
||||||
"type": "content_block_delta",
|
|
||||||
"index": st.blockIndex,
|
|
||||||
"delta": map[string]interface{}{
|
|
||||||
"type": "input_json_delta",
|
|
||||||
"partial_json": string(call.Input),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
writeAnthropicSSE(sse, map[string]interface{}{
|
|
||||||
"type": "content_block_stop",
|
|
||||||
"index": st.blockIndex,
|
|
||||||
})
|
|
||||||
st.toolCallsEmitted++
|
|
||||||
}
|
|
||||||
|
|
||||||
feedDelta := func(content string) bool {
|
var accumulated strings.Builder
|
||||||
emit, calls, err := tcParser.Feed(content)
|
|
||||||
emitText(emit)
|
|
||||||
for _, c := range calls {
|
|
||||||
emitToolCall(c)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("[tool_call] parse error: %v", err)
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
for line := range outputChan {
|
for line := range outputChan {
|
||||||
result := parser.Parse(line)
|
result := parser.Parse(line)
|
||||||
|
|
||||||
if result.Skip {
|
if result.Skip {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
@ -173,7 +93,12 @@ func (s *Server) streamAnthropicMessages(w http.ResponseWriter, r *http.Request,
|
||||||
}
|
}
|
||||||
if result.Chunk != nil && len(result.Chunk.Choices) > 0 {
|
if result.Chunk != nil && len(result.Chunk.Choices) > 0 {
|
||||||
if c := result.Chunk.Choices[0].Delta.Content; c != nil {
|
if c := result.Chunk.Choices[0].Delta.Content; c != nil {
|
||||||
feedDelta(*c)
|
accumulated.WriteString(*c)
|
||||||
|
writeAnthropicSSE(sse, map[string]interface{}{
|
||||||
|
"type": "content_block_delta",
|
||||||
|
"index": 0,
|
||||||
|
"delta": map[string]interface{}{"type": "text_delta", "text": *c},
|
||||||
|
})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -188,7 +113,12 @@ func (s *Server) streamAnthropicMessages(w http.ResponseWriter, r *http.Request,
|
||||||
|
|
||||||
if result.Chunk != nil && len(result.Chunk.Choices) > 0 {
|
if result.Chunk != nil && len(result.Chunk.Choices) > 0 {
|
||||||
if c := result.Chunk.Choices[0].Delta.Content; c != nil {
|
if c := result.Chunk.Choices[0].Delta.Content; c != nil {
|
||||||
feedDelta(*c)
|
accumulated.WriteString(*c)
|
||||||
|
writeAnthropicSSE(sse, map[string]interface{}{
|
||||||
|
"type": "content_block_delta",
|
||||||
|
"index": 0,
|
||||||
|
"delta": map[string]interface{}{"type": "text_delta", "text": *c},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -197,25 +127,15 @@ func (s *Server) streamAnthropicMessages(w http.ResponseWriter, r *http.Request,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if leftover, err := tcParser.Flush(); leftover != "" {
|
outTokens := maxInt(1, accumulated.Len()/4)
|
||||||
emitText(leftover)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("[tool_call] flush warning: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
st.closeTextBlockIfOpen()
|
|
||||||
|
|
||||||
stopReason := "end_turn"
|
|
||||||
if st.toolCallsEmitted > 0 {
|
|
||||||
stopReason = "tool_use"
|
|
||||||
}
|
|
||||||
|
|
||||||
outTokens := maxInt(1, st.outChars/4)
|
|
||||||
|
|
||||||
|
writeAnthropicSSE(sse, map[string]interface{}{
|
||||||
|
"type": "content_block_stop",
|
||||||
|
"index": 0,
|
||||||
|
})
|
||||||
writeAnthropicSSE(sse, map[string]interface{}{
|
writeAnthropicSSE(sse, map[string]interface{}{
|
||||||
"type": "message_delta",
|
"type": "message_delta",
|
||||||
"delta": map[string]interface{}{"stop_reason": stopReason, "stop_sequence": nil},
|
"delta": map[string]interface{}{"stop_reason": "end_turn", "stop_sequence": nil},
|
||||||
"usage": map[string]interface{}{"output_tokens": outTokens},
|
"usage": map[string]interface{}{"output_tokens": outTokens},
|
||||||
})
|
})
|
||||||
writeAnthropicSSE(sse, map[string]interface{}{
|
writeAnthropicSSE(sse, map[string]interface{}{
|
||||||
|
|
@ -229,50 +149,27 @@ func (s *Server) streamAnthropicMessages(w http.ResponseWriter, r *http.Request,
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) nonStreamAnthropicMessages(w http.ResponseWriter, r *http.Request, prompt, cursorModel, displayModel, msgID, sessionKey string) {
|
func (s *Server) nonStreamAnthropicMessages(w http.ResponseWriter, r *http.Request, prompt, cursorModel, displayModel, msgID, sessionKey string) {
|
||||||
ctx, cancel := context.WithTimeout(requestContext(r), time.Duration(s.cfg.Timeout)*time.Second)
|
ctx, cancel := context.WithTimeout(r.Context(), time.Duration(s.cfg.Timeout)*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
go func() {
|
go func() {
|
||||||
<-r.Context().Done()
|
<-r.Context().Done()
|
||||||
cancel()
|
cancel()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
rawContent, err := s.br.ExecuteSync(ctx, prompt, cursorModel, sessionKey)
|
content, err := s.br.ExecuteSync(ctx, prompt, cursorModel, sessionKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeJSON(w, http.StatusInternalServerError, types.NewErrorResponse(err.Error(), "api_error", ""))
|
writeJSON(w, http.StatusInternalServerError, types.NewErrorResponse(err.Error(), "api_error", ""))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
usage := estimateUsage(prompt, content)
|
||||||
cleanText, calls := ExtractAllToolCalls(rawContent)
|
|
||||||
usage := estimateUsage(prompt, rawContent)
|
|
||||||
|
|
||||||
var content []types.AnthropicResponseBlock
|
|
||||||
if cleanText != "" {
|
|
||||||
content = append(content, types.AnthropicResponseBlock{Type: "text", Text: cleanText})
|
|
||||||
}
|
|
||||||
for _, c := range calls {
|
|
||||||
content = append(content, types.AnthropicResponseBlock{
|
|
||||||
Type: "tool_use",
|
|
||||||
ID: newToolUseID(),
|
|
||||||
Name: c.Name,
|
|
||||||
Input: c.Input,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if len(content) == 0 {
|
|
||||||
content = append(content, types.AnthropicResponseBlock{Type: "text", Text: ""})
|
|
||||||
}
|
|
||||||
|
|
||||||
stopReason := "end_turn"
|
|
||||||
if len(calls) > 0 {
|
|
||||||
stopReason = "tool_use"
|
|
||||||
}
|
|
||||||
|
|
||||||
resp := types.AnthropicMessagesResponse{
|
resp := types.AnthropicMessagesResponse{
|
||||||
ID: msgID,
|
ID: msgID,
|
||||||
Type: "message",
|
Type: "message",
|
||||||
Role: "assistant",
|
Role: "assistant",
|
||||||
Content: content,
|
Content: []types.AnthropicTextBlock{{Type: "text", Text: content}},
|
||||||
Model: displayModel,
|
Model: displayModel,
|
||||||
StopReason: stopReason,
|
StopReason: "end_turn",
|
||||||
Usage: types.AnthropicUsage{
|
Usage: types.AnthropicUsage{
|
||||||
InputTokens: usage.PromptTokens,
|
InputTokens: usage.PromptTokens,
|
||||||
OutputTokens: usage.CompletionTokens,
|
OutputTokens: usage.CompletionTokens,
|
||||||
|
|
@ -281,49 +178,6 @@ func (s *Server) nonStreamAnthropicMessages(w http.ResponseWriter, r *http.Reque
|
||||||
writeJSON(w, http.StatusOK, resp)
|
writeJSON(w, http.StatusOK, resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
// anthropicStreamState tracks per-request streaming state: which content
|
|
||||||
// block index we are on, whether the current text block is open, output
|
|
||||||
// character count for usage estimation, and how many tool_use blocks were
|
|
||||||
// emitted so we can pick stop_reason.
|
|
||||||
type anthropicStreamState struct {
|
|
||||||
sse *SSEWriter
|
|
||||||
blockIndex int
|
|
||||||
textOpen bool
|
|
||||||
outChars int
|
|
||||||
toolCallsEmitted int
|
|
||||||
}
|
|
||||||
|
|
||||||
func (st *anthropicStreamState) ensureTextBlockOpen() {
|
|
||||||
if st.textOpen {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeAnthropicSSE(st.sse, map[string]interface{}{
|
|
||||||
"type": "content_block_start",
|
|
||||||
"index": st.blockIndex,
|
|
||||||
"content_block": map[string]interface{}{"type": "text", "text": ""},
|
|
||||||
})
|
|
||||||
st.textOpen = true
|
|
||||||
}
|
|
||||||
|
|
||||||
func (st *anthropicStreamState) closeTextBlockIfOpen() {
|
|
||||||
if !st.textOpen {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeAnthropicSSE(st.sse, map[string]interface{}{
|
|
||||||
"type": "content_block_stop",
|
|
||||||
"index": st.blockIndex,
|
|
||||||
})
|
|
||||||
st.textOpen = false
|
|
||||||
}
|
|
||||||
|
|
||||||
func newToolUseID() string {
|
|
||||||
var b [12]byte
|
|
||||||
if _, err := rand.Read(b[:]); err != nil {
|
|
||||||
return fmt.Sprintf("toolu_%d", time.Now().UnixNano())
|
|
||||||
}
|
|
||||||
return "toolu_" + hex.EncodeToString(b[:])
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeAnthropicSSE(sse *SSEWriter, event interface{}) {
|
func writeAnthropicSSE(sse *SSEWriter, event interface{}) {
|
||||||
data, err := json.Marshal(event)
|
data, err := json.Marshal(event)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -1,102 +0,0 @@
|
||||||
package server
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/daniel/cursor-adapter/internal/types"
|
|
||||||
)
|
|
||||||
|
|
||||||
// cwdPatterns matches the most common ways callers (Claude Code, opencode,
|
|
||||||
// Cursor CLI itself, custom clients) advertise their host working
|
|
||||||
// directory inside the prompt.
|
|
||||||
//
|
|
||||||
// Patterns must capture an absolute path in group 1.
|
|
||||||
var cwdPatterns = []*regexp.Regexp{
|
|
||||||
// Claude Code style:
|
|
||||||
// <env>
|
|
||||||
// Working directory: /Users/x/proj
|
|
||||||
// Is directory a git repo: Yes
|
|
||||||
// ...
|
|
||||||
// </env>
|
|
||||||
regexp.MustCompile(`(?si)<env>.*?working directory:\s*(\S+)`),
|
|
||||||
|
|
||||||
// Generic <cwd>...</cwd> wrapper.
|
|
||||||
regexp.MustCompile(`(?i)<cwd>\s*([^<\s][^<]*?)\s*</cwd>`),
|
|
||||||
|
|
||||||
// "Working directory: /abs/path" on its own line.
|
|
||||||
regexp.MustCompile(`(?im)^\s*working directory:\s*(/[^\s<>]+)\s*$`),
|
|
||||||
|
|
||||||
// "Current working directory is /abs/path" / "current working directory: /abs/path"
|
|
||||||
regexp.MustCompile(`(?i)current working directory(?: is)?[:\s]+(/[^\s<>]+)`),
|
|
||||||
|
|
||||||
// Loose "cwd: /abs/path" / "cwd=/abs/path".
|
|
||||||
regexp.MustCompile(`(?i)\bcwd\s*[:=]\s*(/[^\s<>]+)`),
|
|
||||||
}
|
|
||||||
|
|
||||||
// detectCallerWorkspace returns the first absolute, host-resident directory
|
|
||||||
// it can extract from corpus. It rejects:
|
|
||||||
// - non-absolute paths (e.g. "src/")
|
|
||||||
// - paths that don't exist on the host (e.g. "/sessions/..." sandbox
|
|
||||||
// paths sent by Claude Desktop's Cowork VM)
|
|
||||||
// - paths that point to a file rather than a directory
|
|
||||||
//
|
|
||||||
// Returning "" simply means "no usable workspace hint found", and callers
|
|
||||||
// should fall back to config defaults.
|
|
||||||
func detectCallerWorkspace(corpus string) string {
|
|
||||||
for _, p := range cwdPatterns {
|
|
||||||
m := p.FindStringSubmatch(corpus)
|
|
||||||
if len(m) < 2 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
cand := strings.TrimSpace(m[1])
|
|
||||||
// Strip trailing punctuation that often follows a path in prose.
|
|
||||||
cand = strings.TrimRight(cand, `.,;:"'`+"`)>")
|
|
||||||
if cand == "" || !filepath.IsAbs(cand) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
info, err := os.Stat(cand)
|
|
||||||
if err != nil || !info.IsDir() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
return cand
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// detectAnthropicCwd scans an Anthropic Messages request for a workspace
|
|
||||||
// hint. It walks system blocks first (Claude Code / opencode usually put
|
|
||||||
// the <env> block there), then user/assistant text blocks (some clients
|
|
||||||
// embed it as <system-reminder> inside the first user message).
|
|
||||||
func detectAnthropicCwd(req types.AnthropicMessagesRequest) string {
|
|
||||||
var sb strings.Builder
|
|
||||||
for _, b := range req.System {
|
|
||||||
if b.Type == "text" && b.Text != "" {
|
|
||||||
sb.WriteString(b.Text)
|
|
||||||
sb.WriteByte('\n')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, m := range req.Messages {
|
|
||||||
for _, b := range m.Content {
|
|
||||||
if b.Type == "text" && b.Text != "" {
|
|
||||||
sb.WriteString(b.Text)
|
|
||||||
sb.WriteByte('\n')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return detectCallerWorkspace(sb.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
// detectOpenAICwd scans an OpenAI-style chat completion request for a
|
|
||||||
// workspace hint, including system messages (which the brain prompt
|
|
||||||
// builder otherwise drops).
|
|
||||||
func detectOpenAICwd(req types.ChatCompletionRequest) string {
|
|
||||||
var sb strings.Builder
|
|
||||||
for _, m := range req.Messages {
|
|
||||||
sb.WriteString(string(m.Content))
|
|
||||||
sb.WriteByte('\n')
|
|
||||||
}
|
|
||||||
return detectCallerWorkspace(sb.String())
|
|
||||||
}
|
|
||||||
|
|
@ -1,108 +0,0 @@
|
||||||
package server
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/daniel/cursor-adapter/internal/types"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestDetectCallerWorkspace_ClaudeCodeEnvBlock(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
corpus := "<env>\nWorking directory: " + dir + "\nIs directory a git repo: Yes\n</env>"
|
|
||||||
got := detectCallerWorkspace(corpus)
|
|
||||||
if got != dir {
|
|
||||||
t.Fatalf("got %q, want %q", got, dir)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDetectCallerWorkspace_RejectsNonExistentSandboxPath(t *testing.T) {
|
|
||||||
corpus := "Working directory: /sessions/gracious-magical-franklin/proj"
|
|
||||||
got := detectCallerWorkspace(corpus)
|
|
||||||
if got != "" {
|
|
||||||
t.Fatalf("expected empty (path doesn't exist on host), got %q", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDetectCallerWorkspace_RejectsRelativePath(t *testing.T) {
|
|
||||||
corpus := "cwd: src/"
|
|
||||||
got := detectCallerWorkspace(corpus)
|
|
||||||
if got != "" {
|
|
||||||
t.Fatalf("expected empty for relative path, got %q", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDetectCallerWorkspace_RejectsFilePath(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
f := filepath.Join(dir, "file.txt")
|
|
||||||
if err := os.WriteFile(f, []byte("x"), 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
corpus := "Working directory: " + f
|
|
||||||
got := detectCallerWorkspace(corpus)
|
|
||||||
if got != "" {
|
|
||||||
t.Fatalf("expected empty for file path, got %q", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDetectAnthropicCwd_FromSystemBlock(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
req := types.AnthropicMessagesRequest{
|
|
||||||
System: []types.AnthropicBlock{
|
|
||||||
{Type: "text", Text: "<env>\nWorking directory: " + dir + "\n</env>"},
|
|
||||||
},
|
|
||||||
Messages: []types.AnthropicMessage{
|
|
||||||
{Role: "user", Content: []types.AnthropicBlock{{Type: "text", Text: "hi"}}},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
if got := detectAnthropicCwd(req); got != dir {
|
|
||||||
t.Fatalf("got %q, want %q", got, dir)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDetectAnthropicCwd_FromUserMessage(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
req := types.AnthropicMessagesRequest{
|
|
||||||
Messages: []types.AnthropicMessage{
|
|
||||||
{Role: "user", Content: []types.AnthropicBlock{
|
|
||||||
{Type: "text", Text: "<system-reminder>Current working directory: " + dir + "</system-reminder>\nHelp me"},
|
|
||||||
}},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
if got := detectAnthropicCwd(req); got != dir {
|
|
||||||
t.Fatalf("got %q, want %q", got, dir)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDetectAnthropicCwd_TrimsTrailingPunctuation(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
corpus := "Working directory: " + dir + "."
|
|
||||||
if got := detectCallerWorkspace(corpus); got != dir {
|
|
||||||
t.Fatalf("got %q, want %q (trailing dot should be stripped)", got, dir)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDetectAnthropicCwd_NoneFound(t *testing.T) {
|
|
||||||
req := types.AnthropicMessagesRequest{
|
|
||||||
Messages: []types.AnthropicMessage{
|
|
||||||
{Role: "user", Content: []types.AnthropicBlock{{Type: "text", Text: "just a question"}}},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
if got := detectAnthropicCwd(req); got != "" {
|
|
||||||
t.Fatalf("got %q, want empty", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sanity check that none of our regexes mis-eat absolute paths inside
|
|
||||||
// regular sentences without a cwd marker.
|
|
||||||
func TestDetectCallerWorkspace_IgnoresUnmarkedAbsolutePaths(t *testing.T) {
|
|
||||||
corpus := "I edited /tmp/foo earlier."
|
|
||||||
if !strings.HasPrefix(corpus, "I edited") { // keep the import used
|
|
||||||
t.Fatal("test fixture changed")
|
|
||||||
}
|
|
||||||
if got := detectCallerWorkspace(corpus); got != "" {
|
|
||||||
t.Fatalf("got %q, want empty (no cwd marker)", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -72,23 +72,9 @@ func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Pure brain: only our system prompt, drop the client's ---
|
|
||||||
var parts []string
|
var parts []string
|
||||||
if s.cfg.SystemPrompt != "" {
|
|
||||||
parts = append(parts, "system: "+s.cfg.SystemPrompt)
|
|
||||||
}
|
|
||||||
for _, m := range req.Messages {
|
for _, m := range req.Messages {
|
||||||
// Drop client system messages (mode descriptions, tool schemas).
|
|
||||||
if m.Role == "system" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
text := sanitize.Text(string(m.Content))
|
text := sanitize.Text(string(m.Content))
|
||||||
// Strip <system-reminder> blocks embedded in messages.
|
|
||||||
text = systemReminderRe.ReplaceAllString(text, "")
|
|
||||||
text = strings.TrimSpace(text)
|
|
||||||
if text == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
parts = append(parts, fmt.Sprintf("%s: %s", m.Role, text))
|
parts = append(parts, fmt.Sprintf("%s: %s", m.Role, text))
|
||||||
}
|
}
|
||||||
prompt := strings.Join(parts, "\n")
|
prompt := strings.Join(parts, "\n")
|
||||||
|
|
@ -100,13 +86,6 @@ func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) {
|
||||||
cursorModel := converter.ResolveToCursorModel(model)
|
cursorModel := converter.ResolveToCursorModel(model)
|
||||||
sessionKey := ensureSessionHeader(w, r)
|
sessionKey := ensureSessionHeader(w, r)
|
||||||
|
|
||||||
if r.Header.Get(workspaceHeaderName) == "" {
|
|
||||||
if detected := detectOpenAICwd(req); detected != "" {
|
|
||||||
slog.Debug("workspace detected from prompt", "path", detected)
|
|
||||||
r.Header.Set(workspaceHeaderName, detected)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
chatID := fmt.Sprintf("chatcmpl-%d", time.Now().UnixNano())
|
chatID := fmt.Sprintf("chatcmpl-%d", time.Now().UnixNano())
|
||||||
created := time.Now().Unix()
|
created := time.Now().Unix()
|
||||||
|
|
||||||
|
|
@ -122,7 +101,7 @@ func (s *Server) streamChat(w http.ResponseWriter, r *http.Request, prompt, curs
|
||||||
sse := NewSSEWriter(w)
|
sse := NewSSEWriter(w)
|
||||||
parser := converter.NewStreamParser(chatID)
|
parser := converter.NewStreamParser(chatID)
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(requestContext(r), time.Duration(s.cfg.Timeout)*time.Second)
|
ctx, cancel := context.WithTimeout(r.Context(), time.Duration(s.cfg.Timeout)*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
go func() {
|
go func() {
|
||||||
<-r.Context().Done()
|
<-r.Context().Done()
|
||||||
|
|
@ -215,7 +194,7 @@ func (s *Server) streamChat(w http.ResponseWriter, r *http.Request, prompt, curs
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) nonStreamChat(w http.ResponseWriter, r *http.Request, prompt, cursorModel, displayModel, chatID string, created int64, sessionKey string) {
|
func (s *Server) nonStreamChat(w http.ResponseWriter, r *http.Request, prompt, cursorModel, displayModel, chatID string, created int64, sessionKey string) {
|
||||||
ctx, cancel := context.WithTimeout(requestContext(r), time.Duration(s.cfg.Timeout)*time.Second)
|
ctx, cancel := context.WithTimeout(r.Context(), time.Duration(s.cfg.Timeout)*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
go func() {
|
go func() {
|
||||||
<-r.Context().Done()
|
<-r.Context().Done()
|
||||||
|
|
|
||||||
|
|
@ -214,11 +214,9 @@ func TestChatCompletions_AcceptsArrayContentBlocks(t *testing.T) {
|
||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||||
}
|
}
|
||||||
// Client system messages should be DROPPED (pure brain mode).
|
if !strings.Contains(br.lastPrompt, "system: You are terse.") {
|
||||||
if strings.Contains(br.lastPrompt, "You are terse.") {
|
t.Fatalf("prompt = %q, want system text content", br.lastPrompt)
|
||||||
t.Fatalf("prompt should NOT contain client system message, got: %q", br.lastPrompt)
|
|
||||||
}
|
}
|
||||||
// User text should still be present and concatenated.
|
|
||||||
if !strings.Contains(br.lastPrompt, "user: hello world") {
|
if !strings.Contains(br.lastPrompt, "user: hello world") {
|
||||||
t.Fatalf("prompt = %q, want concatenated user text content", br.lastPrompt)
|
t.Fatalf("prompt = %q, want concatenated user text content", br.lastPrompt)
|
||||||
}
|
}
|
||||||
|
|
@ -332,133 +330,6 @@ func TestAnthropicMessages_StreamingEmitsNoDuplicateFinalText(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAnthropicMessages_PromptIncludesToolsAndToolHistory(t *testing.T) {
|
|
||||||
cfg := config.Defaults()
|
|
||||||
br := &mockBridge{executeSync: "ok"}
|
|
||||||
srv := New(&cfg, br)
|
|
||||||
|
|
||||||
body := `{
|
|
||||||
"model":"auto",
|
|
||||||
"max_tokens":128,
|
|
||||||
"tools":[{"name":"bash","description":"Run a shell command","input_schema":{"type":"object","properties":{"command":{"type":"string"}}}}],
|
|
||||||
"messages":[
|
|
||||||
{"role":"user","content":[{"type":"text","text":"clean up my desktop"}]},
|
|
||||||
{"role":"assistant","content":[{"type":"tool_use","id":"toolu_1","name":"bash","input":{"command":"ls ~/Desktop"}}]},
|
|
||||||
{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"a.png\nb.txt"}]}
|
|
||||||
],
|
|
||||||
"stream":false
|
|
||||||
}`
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/v1/messages", strings.NewReader(body))
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
|
|
||||||
srv.mux.ServeHTTP(rec, req)
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
prompt := br.lastPrompt
|
|
||||||
for _, want := range []string{
|
|
||||||
"Available executors",
|
|
||||||
"- bash",
|
|
||||||
"Run a shell command",
|
|
||||||
"<tool_call>",
|
|
||||||
"clean up my desktop",
|
|
||||||
`[tool_call name="bash" input=`,
|
|
||||||
"[tool_result for=toolu_1 status=ok]",
|
|
||||||
"a.png",
|
|
||||||
} {
|
|
||||||
if !strings.Contains(prompt, want) {
|
|
||||||
t.Fatalf("prompt missing %q\nprompt:\n%s", want, prompt)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAnthropicMessages_NonStreamTranslatesToolCallToToolUse(t *testing.T) {
|
|
||||||
cfg := config.Defaults()
|
|
||||||
br := &mockBridge{
|
|
||||||
executeSync: "I'll run it now.\n<tool_call>\n{\"name\":\"bash\",\"input\":{\"command\":\"mkdir -p ~/Desktop/screenshots\"}}\n</tool_call>",
|
|
||||||
}
|
|
||||||
srv := New(&cfg, br)
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/v1/messages", strings.NewReader(`{
|
|
||||||
"model":"auto",
|
|
||||||
"max_tokens":128,
|
|
||||||
"tools":[{"name":"bash"}],
|
|
||||||
"messages":[{"role":"user","content":"organize desktop"}],
|
|
||||||
"stream":false
|
|
||||||
}`))
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
|
|
||||||
srv.mux.ServeHTTP(rec, req)
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
body := rec.Body.String()
|
|
||||||
for _, want := range []string{
|
|
||||||
`"stop_reason":"tool_use"`,
|
|
||||||
`"type":"tool_use"`,
|
|
||||||
`"name":"bash"`,
|
|
||||||
`"command":"mkdir -p ~/Desktop/screenshots"`,
|
|
||||||
`"type":"text"`,
|
|
||||||
`I'll run it now.`,
|
|
||||||
} {
|
|
||||||
if !strings.Contains(body, want) {
|
|
||||||
t.Fatalf("response missing %q\nbody=%s", want, body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAnthropicMessages_StreamTranslatesToolCallToToolUseSSE(t *testing.T) {
|
|
||||||
cfg := config.Defaults()
|
|
||||||
srv := New(&cfg, &mockBridge{
|
|
||||||
executeLines: []string{
|
|
||||||
`{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"running\n"}]}}`,
|
|
||||||
`{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"running\n<tool_call>\n"}]}}`,
|
|
||||||
`{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"running\n<tool_call>\n{\"name\":\"bash\",\"input\":{\"command\":\"ls\"}}\n</tool_call>"}]}}`,
|
|
||||||
`{"type":"result","subtype":"success","usage":{"inputTokens":3,"outputTokens":2}}`,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/v1/messages", strings.NewReader(`{
|
|
||||||
"model":"auto",
|
|
||||||
"max_tokens":128,
|
|
||||||
"tools":[{"name":"bash"}],
|
|
||||||
"messages":[{"role":"user","content":"go"}],
|
|
||||||
"stream":true
|
|
||||||
}`))
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
|
|
||||||
srv.mux.ServeHTTP(rec, req)
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
body := rec.Body.String()
|
|
||||||
for _, want := range []string{
|
|
||||||
`"type":"message_start"`,
|
|
||||||
`"type":"content_block_start"`,
|
|
||||||
`"type":"text"`,
|
|
||||||
`"text":"running`,
|
|
||||||
`"type":"tool_use"`,
|
|
||||||
`"name":"bash"`,
|
|
||||||
`"type":"input_json_delta"`,
|
|
||||||
`\"command\":\"ls\"`,
|
|
||||||
`"stop_reason":"tool_use"`,
|
|
||||||
`"type":"message_stop"`,
|
|
||||||
} {
|
|
||||||
if !strings.Contains(body, want) {
|
|
||||||
t.Fatalf("stream missing %q\nbody=%s", want, body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if strings.Contains(body, "<tool_call>") {
|
|
||||||
t.Fatalf("stream leaked raw <tool_call> sentinel: %s", body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAnthropicMessages_GeneratesSessionHeaderWhenMissing(t *testing.T) {
|
func TestAnthropicMessages_GeneratesSessionHeaderWhenMissing(t *testing.T) {
|
||||||
cfg := config.Defaults()
|
cfg := config.Defaults()
|
||||||
br := &mockBridge{executeSync: "Hello"}
|
br := &mockBridge{executeSync: "Hello"}
|
||||||
|
|
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
package server
|
|
||||||
|
|
||||||
import (
|
|
||||||
"regexp"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/daniel/cursor-adapter/internal/types"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Cowork-style mount path: /sessions/<adjective>-<adjective>-<color>/mnt/<folder>
|
|
||||||
// (and any deeper subpath; we capture only the mount root).
|
|
||||||
var mountPathRe = regexp.MustCompile(`/sessions/[a-z][a-z0-9]*(?:-[a-z][a-z0-9]*)+/mnt/[^\s/'"]+`)
|
|
||||||
|
|
||||||
// extractMountHints walks all prior tool_result blocks in the conversation
|
|
||||||
// and returns any Cowork-style /sessions/<id>/mnt/<folder> mount roots
|
|
||||||
// they reveal, deduped & sorted.
|
|
||||||
//
|
|
||||||
// This is purely stateless — we re-derive the set from the request body
|
|
||||||
// every turn. No server-side cache to invalidate, and it survives proxy
|
|
||||||
// restarts because the caller (Claude Desktop) replays the full history
|
|
||||||
// on each request anyway.
|
|
||||||
func extractMountHints(req types.AnthropicMessagesRequest) []string {
|
|
||||||
seen := map[string]struct{}{}
|
|
||||||
for _, m := range req.Messages {
|
|
||||||
for _, b := range m.Content {
|
|
||||||
if b.Type != "tool_result" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, p := range mountPathRe.FindAllString(renderToolResultContent(b.Content), -1) {
|
|
||||||
seen[p] = struct{}{}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(seen) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
out := make([]string, 0, len(seen))
|
|
||||||
for p := range seen {
|
|
||||||
out = append(out, p)
|
|
||||||
}
|
|
||||||
sort.Strings(out)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// renderMountHints turns a list of mount roots into a prompt section the
|
|
||||||
// brain can refer to. Returns "" when there are no hints.
|
|
||||||
func renderMountHints(hints []string) string {
|
|
||||||
if len(hints) == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
var b strings.Builder
|
|
||||||
b.WriteString("Known host-mount paths (discovered earlier in this conversation, prefer these for any host file work):\n")
|
|
||||||
for _, h := range hints {
|
|
||||||
b.WriteString("- ")
|
|
||||||
b.WriteString(h)
|
|
||||||
b.WriteByte('\n')
|
|
||||||
}
|
|
||||||
return b.String()
|
|
||||||
}
|
|
||||||
|
|
@ -49,14 +49,6 @@ func (s *Server) buildRouter() *chi.Mux {
|
||||||
r.Post("/v1/messages", s.handleAnthropicMessages)
|
r.Post("/v1/messages", s.handleAnthropicMessages)
|
||||||
r.Get("/health", s.handleHealth)
|
r.Get("/health", s.handleHealth)
|
||||||
|
|
||||||
// Claude Desktop sends HEAD / as a health check before making API calls.
|
|
||||||
// Return 200 so it doesn't error with "K.text.trim" before sending the real request.
|
|
||||||
rootHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
})
|
|
||||||
r.Head("/", rootHandler)
|
|
||||||
r.Get("/", rootHandler)
|
|
||||||
|
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,31 +1,15 @@
|
||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/daniel/cursor-adapter/internal/bridge"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const sessionHeaderName = "X-Cursor-Session-ID"
|
const sessionHeaderName = "X-Cursor-Session-ID"
|
||||||
const workspaceHeaderName = "X-Cursor-Workspace"
|
|
||||||
const exposeHeadersName = "Access-Control-Expose-Headers"
|
const exposeHeadersName = "Access-Control-Expose-Headers"
|
||||||
|
|
||||||
// requestContext attaches per-request bridge knobs (currently: workspace
|
|
||||||
// override) read from headers onto ctx.
|
|
||||||
func requestContext(r *http.Request) context.Context {
|
|
||||||
ctx := r.Context()
|
|
||||||
ws := strings.TrimSpace(r.Header.Get(workspaceHeaderName))
|
|
||||||
if ws != "" && filepath.IsAbs(ws) {
|
|
||||||
ctx = bridge.WithWorkspaceOverride(ctx, ws)
|
|
||||||
}
|
|
||||||
return ctx
|
|
||||||
}
|
|
||||||
|
|
||||||
func ensureSessionHeader(w http.ResponseWriter, r *http.Request) string {
|
func ensureSessionHeader(w http.ResponseWriter, r *http.Request) string {
|
||||||
sessionKey := strings.TrimSpace(r.Header.Get(sessionHeaderName))
|
sessionKey := strings.TrimSpace(r.Header.Get(sessionHeaderName))
|
||||||
if sessionKey == "" {
|
if sessionKey == "" {
|
||||||
|
|
|
||||||
|
|
@ -1,200 +0,0 @@
|
||||||
package server
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Sentinels the brain is instructed to wrap tool calls with. We use XML-ish
|
|
||||||
// tags rather than markdown fences because they are unambiguous and easy to
|
|
||||||
// detect mid-stream without confusing them with normal code blocks.
|
|
||||||
const (
|
|
||||||
toolCallOpen = "<tool_call>"
|
|
||||||
toolCallClose = "</tool_call>"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ParsedToolCall is a successfully extracted tool invocation request from
|
|
||||||
// the brain's text stream.
|
|
||||||
type ParsedToolCall struct {
|
|
||||||
Name string
|
|
||||||
Input json.RawMessage
|
|
||||||
}
|
|
||||||
|
|
||||||
// ToolCallStreamParser is a small streaming state machine that splits an
|
|
||||||
// incoming text stream into:
|
|
||||||
// - safe-to-emit plain text (everything outside <tool_call>...</tool_call>)
|
|
||||||
// - one or more ParsedToolCall (everything between sentinels)
|
|
||||||
//
|
|
||||||
// It buffers just enough trailing bytes to avoid emitting half of an opening
|
|
||||||
// sentinel as text.
|
|
||||||
type ToolCallStreamParser struct {
|
|
||||||
buf strings.Builder
|
|
||||||
inToolCall bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewToolCallStreamParser returns a fresh parser.
|
|
||||||
func NewToolCallStreamParser() *ToolCallStreamParser {
|
|
||||||
return &ToolCallStreamParser{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Feed appends s to the parser's buffer and returns:
|
|
||||||
// - emitText: text safe to forward as text_delta to the caller now
|
|
||||||
// - calls: tool calls fully extracted in this Feed
|
|
||||||
// - err: a malformed tool_call block (invalid JSON inside sentinels)
|
|
||||||
//
|
|
||||||
// Feed never returns text that could be the prefix of an opening sentinel —
|
|
||||||
// such bytes stay buffered until the next Feed/Flush.
|
|
||||||
func (p *ToolCallStreamParser) Feed(s string) (emitText string, calls []ParsedToolCall, err error) {
|
|
||||||
p.buf.WriteString(s)
|
|
||||||
var emitted strings.Builder
|
|
||||||
|
|
||||||
for {
|
|
||||||
current := p.buf.String()
|
|
||||||
if p.inToolCall {
|
|
||||||
closeIdx := strings.Index(current, toolCallClose)
|
|
||||||
if closeIdx < 0 {
|
|
||||||
return emitted.String(), calls, nil
|
|
||||||
}
|
|
||||||
payload := current[:closeIdx]
|
|
||||||
call, perr := parseToolCallPayload(payload)
|
|
||||||
rest := current[closeIdx+len(toolCallClose):]
|
|
||||||
rest = strings.TrimPrefix(rest, "\r")
|
|
||||||
rest = strings.TrimPrefix(rest, "\n")
|
|
||||||
p.buf.Reset()
|
|
||||||
p.buf.WriteString(rest)
|
|
||||||
p.inToolCall = false
|
|
||||||
if perr != nil {
|
|
||||||
return emitted.String(), calls, perr
|
|
||||||
}
|
|
||||||
calls = append(calls, call)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
openIdx := strings.Index(current, toolCallOpen)
|
|
||||||
if openIdx >= 0 {
|
|
||||||
emitted.WriteString(current[:openIdx])
|
|
||||||
rest := current[openIdx+len(toolCallOpen):]
|
|
||||||
rest = strings.TrimPrefix(rest, "\r")
|
|
||||||
rest = strings.TrimPrefix(rest, "\n")
|
|
||||||
p.buf.Reset()
|
|
||||||
p.buf.WriteString(rest)
|
|
||||||
p.inToolCall = true
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// No open sentinel yet. Emit everything except a potential prefix
|
|
||||||
// of `<tool_call>` lurking at the tail of the buffer.
|
|
||||||
hold := potentialSentinelSuffix(current, toolCallOpen)
|
|
||||||
if hold == 0 {
|
|
||||||
emitted.WriteString(current)
|
|
||||||
p.buf.Reset()
|
|
||||||
return emitted.String(), calls, nil
|
|
||||||
}
|
|
||||||
emitted.WriteString(current[:len(current)-hold])
|
|
||||||
tail := current[len(current)-hold:]
|
|
||||||
p.buf.Reset()
|
|
||||||
p.buf.WriteString(tail)
|
|
||||||
return emitted.String(), calls, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Flush returns any remaining buffered text and resets the parser. If we
|
|
||||||
// ended mid-`<tool_call>` block (no closing sentinel), the partial content
|
|
||||||
// is returned as plain text — better the caller sees something than data
|
|
||||||
// loss.
|
|
||||||
func (p *ToolCallStreamParser) Flush() (string, error) {
|
|
||||||
leftover := p.buf.String()
|
|
||||||
p.buf.Reset()
|
|
||||||
if p.inToolCall {
|
|
||||||
p.inToolCall = false
|
|
||||||
return toolCallOpen + leftover, fmt.Errorf("unterminated %s block", toolCallOpen)
|
|
||||||
}
|
|
||||||
return leftover, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ExtractAllToolCalls is the non-streaming counterpart: scan the full text
|
|
||||||
// once, return cleaned text (with tool_call blocks removed) plus extracted
|
|
||||||
// calls. Any malformed block is preserved verbatim in the returned text.
|
|
||||||
func ExtractAllToolCalls(text string) (cleanText string, calls []ParsedToolCall) {
|
|
||||||
var out strings.Builder
|
|
||||||
rest := text
|
|
||||||
for {
|
|
||||||
i := strings.Index(rest, toolCallOpen)
|
|
||||||
if i < 0 {
|
|
||||||
out.WriteString(rest)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
out.WriteString(rest[:i])
|
|
||||||
after := rest[i+len(toolCallOpen):]
|
|
||||||
j := strings.Index(after, toolCallClose)
|
|
||||||
if j < 0 {
|
|
||||||
// Unterminated; keep the rest verbatim.
|
|
||||||
out.WriteString(toolCallOpen)
|
|
||||||
out.WriteString(after)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
payload := after[:j]
|
|
||||||
if call, err := parseToolCallPayload(payload); err == nil {
|
|
||||||
calls = append(calls, call)
|
|
||||||
} else {
|
|
||||||
// Keep malformed block as-is so the user can see it.
|
|
||||||
out.WriteString(toolCallOpen)
|
|
||||||
out.WriteString(payload)
|
|
||||||
out.WriteString(toolCallClose)
|
|
||||||
}
|
|
||||||
rest = strings.TrimPrefix(after[j+len(toolCallClose):], "\n")
|
|
||||||
}
|
|
||||||
return strings.TrimSpace(out.String()), calls
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseToolCallPayload(payload string) (ParsedToolCall, error) {
|
|
||||||
trimmed := strings.TrimSpace(payload)
|
|
||||||
// Allow the brain to wrap the JSON in ```json fences too.
|
|
||||||
trimmed = strings.TrimPrefix(trimmed, "```json")
|
|
||||||
trimmed = strings.TrimPrefix(trimmed, "```")
|
|
||||||
trimmed = strings.TrimSuffix(trimmed, "```")
|
|
||||||
trimmed = strings.TrimSpace(trimmed)
|
|
||||||
if trimmed == "" {
|
|
||||||
return ParsedToolCall{}, fmt.Errorf("empty tool_call body")
|
|
||||||
}
|
|
||||||
var raw struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Tool string `json:"tool"`
|
|
||||||
Input json.RawMessage `json:"input"`
|
|
||||||
Args json.RawMessage `json:"arguments"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal([]byte(trimmed), &raw); err != nil {
|
|
||||||
return ParsedToolCall{}, fmt.Errorf("invalid tool_call json: %w", err)
|
|
||||||
}
|
|
||||||
name := raw.Name
|
|
||||||
if name == "" {
|
|
||||||
name = raw.Tool
|
|
||||||
}
|
|
||||||
if name == "" {
|
|
||||||
return ParsedToolCall{}, fmt.Errorf("tool_call missing name")
|
|
||||||
}
|
|
||||||
input := raw.Input
|
|
||||||
if len(input) == 0 {
|
|
||||||
input = raw.Args
|
|
||||||
}
|
|
||||||
if len(input) == 0 {
|
|
||||||
input = json.RawMessage(`{}`)
|
|
||||||
}
|
|
||||||
return ParsedToolCall{Name: name, Input: input}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// potentialSentinelSuffix returns the length of the longest suffix of s
|
|
||||||
// that is a strict prefix of sentinel.
|
|
||||||
func potentialSentinelSuffix(s, sentinel string) int {
|
|
||||||
maxLen := len(sentinel) - 1
|
|
||||||
if maxLen > len(s) {
|
|
||||||
maxLen = len(s)
|
|
||||||
}
|
|
||||||
for i := maxLen; i > 0; i-- {
|
|
||||||
if strings.HasPrefix(sentinel, s[len(s)-i:]) {
|
|
||||||
return i
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
@ -1,98 +0,0 @@
|
||||||
package server
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestToolCallStreamParser_PlainTextPassThrough(t *testing.T) {
|
|
||||||
p := NewToolCallStreamParser()
|
|
||||||
emit, calls, err := p.Feed("hello world\n")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
if len(calls) != 0 {
|
|
||||||
t.Fatalf("expected no calls, got %+v", calls)
|
|
||||||
}
|
|
||||||
if emit != "hello world\n" {
|
|
||||||
t.Fatalf("emit = %q, want passthrough", emit)
|
|
||||||
}
|
|
||||||
rest, err := p.Flush()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("flush error: %v", err)
|
|
||||||
}
|
|
||||||
if rest != "" {
|
|
||||||
t.Fatalf("flush leftover = %q, want empty", rest)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestToolCallStreamParser_ExtractsCompleteCall(t *testing.T) {
|
|
||||||
p := NewToolCallStreamParser()
|
|
||||||
in := "before\n<tool_call>\n{\"name\":\"bash\",\"input\":{\"command\":\"ls\"}}\n</tool_call>\nafter"
|
|
||||||
emit, calls, err := p.Feed(in)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("error: %v", err)
|
|
||||||
}
|
|
||||||
if len(calls) != 1 {
|
|
||||||
t.Fatalf("expected 1 call, got %d", len(calls))
|
|
||||||
}
|
|
||||||
if calls[0].Name != "bash" {
|
|
||||||
t.Fatalf("name = %q", calls[0].Name)
|
|
||||||
}
|
|
||||||
if !strings.Contains(string(calls[0].Input), `"command":"ls"`) {
|
|
||||||
t.Fatalf("input = %s", calls[0].Input)
|
|
||||||
}
|
|
||||||
if !strings.Contains(emit, "before") || !strings.Contains(emit, "after") {
|
|
||||||
t.Fatalf("emit lost surrounding text: %q", emit)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestToolCallStreamParser_HoldsPartialOpenSentinel(t *testing.T) {
|
|
||||||
p := NewToolCallStreamParser()
|
|
||||||
// Feed a chunk ending with a partial "<tool_ca". Parser must not emit it.
|
|
||||||
emit, calls, err := p.Feed("text<tool_ca")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("error: %v", err)
|
|
||||||
}
|
|
||||||
if len(calls) != 0 {
|
|
||||||
t.Fatalf("calls = %+v", calls)
|
|
||||||
}
|
|
||||||
if emit != "text" {
|
|
||||||
t.Fatalf("emit = %q, want %q", emit, "text")
|
|
||||||
}
|
|
||||||
emit2, calls2, err := p.Feed("ll>{\"name\":\"x\"}</tool_call>")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("error 2: %v", err)
|
|
||||||
}
|
|
||||||
if emit2 != "" {
|
|
||||||
t.Fatalf("emit2 = %q, want empty (only call extracted)", emit2)
|
|
||||||
}
|
|
||||||
if len(calls2) != 1 || calls2[0].Name != "x" {
|
|
||||||
t.Fatalf("calls2 = %+v", calls2)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestToolCallStreamParser_RejectsInvalidJSON(t *testing.T) {
|
|
||||||
p := NewToolCallStreamParser()
|
|
||||||
_, _, err := p.Feed("<tool_call>not json</tool_call>")
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected parse error for invalid JSON inside sentinels")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExtractAllToolCalls_MultipleAndCleanText(t *testing.T) {
|
|
||||||
in := "preamble\n<tool_call>{\"name\":\"a\",\"input\":{}}</tool_call>\nmiddle\n<tool_call>{\"tool\":\"b\",\"arguments\":{\"x\":1}}</tool_call>\nend"
|
|
||||||
clean, calls := ExtractAllToolCalls(in)
|
|
||||||
if len(calls) != 2 {
|
|
||||||
t.Fatalf("calls = %d", len(calls))
|
|
||||||
}
|
|
||||||
if calls[0].Name != "a" || calls[1].Name != "b" {
|
|
||||||
t.Fatalf("names = %q, %q", calls[0].Name, calls[1].Name)
|
|
||||||
}
|
|
||||||
if !strings.Contains(clean, "preamble") || !strings.Contains(clean, "middle") || !strings.Contains(clean, "end") {
|
|
||||||
t.Fatalf("clean text wrong: %q", clean)
|
|
||||||
}
|
|
||||||
if strings.Contains(clean, "<tool_call>") {
|
|
||||||
t.Fatalf("clean text still contains sentinels: %q", clean)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -27,29 +27,13 @@ type AnthropicBlock struct {
|
||||||
Title string `json:"title,omitempty"`
|
Title string `json:"title,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// AnthropicTextBlock kept for response serialisation of plain text content.
|
// AnthropicTextBlock kept for response serialisation (proxy always returns
|
||||||
// Deprecated: use AnthropicResponseBlock for outputs that may also carry
|
// text blocks back to the client; it does not emit tool_use natively).
|
||||||
// tool_use blocks.
|
|
||||||
type AnthropicTextBlock struct {
|
type AnthropicTextBlock struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Text string `json:"text,omitempty"`
|
Text string `json:"text,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// AnthropicResponseBlock is a polymorphic content block emitted by the
|
|
||||||
// proxy. It can be a "text" block or a synthetic "tool_use" block produced
|
|
||||||
// by translating a brain-side <tool_call>...</tool_call> sentinel.
|
|
||||||
type AnthropicResponseBlock struct {
|
|
||||||
Type string `json:"type"`
|
|
||||||
|
|
||||||
// type=text
|
|
||||||
Text string `json:"text,omitempty"`
|
|
||||||
|
|
||||||
// type=tool_use
|
|
||||||
ID string `json:"id,omitempty"`
|
|
||||||
Name string `json:"name,omitempty"`
|
|
||||||
Input json.RawMessage `json:"input,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// AnthropicContent is a flexible field: it can be a plain string OR an array
|
// AnthropicContent is a flexible field: it can be a plain string OR an array
|
||||||
// of blocks. Claude Code always sends the array form.
|
// of blocks. Claude Code always sends the array form.
|
||||||
type AnthropicContent []AnthropicBlock
|
type AnthropicContent []AnthropicBlock
|
||||||
|
|
@ -102,7 +86,7 @@ type AnthropicMessagesResponse struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
Content []AnthropicResponseBlock `json:"content"`
|
Content []AnthropicTextBlock `json:"content"`
|
||||||
Model string `json:"model"`
|
Model string `json:"model"`
|
||||||
StopReason string `json:"stop_reason"`
|
StopReason string `json:"stop_reason"`
|
||||||
Usage AnthropicUsage `json:"usage"`
|
Usage AnthropicUsage `json:"usage"`
|
||||||
|
|
|
||||||
22
main.go
22
main.go
|
|
@ -68,16 +68,14 @@ func run(cmd *cobra.Command, args []string) error {
|
||||||
cfg.ChatOnlyWorkspace = chatOnlyFlag
|
cfg.ChatOnlyWorkspace = chatOnlyFlag
|
||||||
}
|
}
|
||||||
|
|
||||||
br := bridge.NewBridge(bridge.Options{
|
br := bridge.NewBridge(
|
||||||
CursorPath: cfg.CursorCLIPath,
|
cfg.CursorCLIPath,
|
||||||
Logger: logger,
|
logger,
|
||||||
UseACP: cfg.UseACP,
|
cfg.UseACP,
|
||||||
ChatOnly: cfg.ChatOnlyWorkspace,
|
cfg.ChatOnlyWorkspace,
|
||||||
MaxConcurrent: cfg.MaxConcurrent,
|
cfg.MaxConcurrent,
|
||||||
Timeout: time.Duration(cfg.Timeout) * time.Second,
|
time.Duration(cfg.Timeout)*time.Second,
|
||||||
Mode: cfg.CursorMode,
|
)
|
||||||
WorkspaceRoot: cfg.WorkspaceRoot,
|
|
||||||
})
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
@ -93,9 +91,7 @@ func run(cmd *cobra.Command, args []string) error {
|
||||||
}
|
}
|
||||||
logger.Info("Starting cursor-adapter",
|
logger.Info("Starting cursor-adapter",
|
||||||
"port", cfg.Port,
|
"port", cfg.Port,
|
||||||
"transport", mode,
|
"mode", mode,
|
||||||
"cursor_mode", cfg.CursorMode,
|
|
||||||
"workspace_root", cfg.WorkspaceRoot,
|
|
||||||
"chat_only_workspace", cfg.ChatOnlyWorkspace,
|
"chat_only_workspace", cfg.ChatOnlyWorkspace,
|
||||||
)
|
)
|
||||||
return srv.Run()
|
return srv.Run()
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue