203 lines
8.3 KiB
Go
203 lines
8.3 KiB
Go
package config
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
type Config struct {
|
|
Port int `yaml:"port"`
|
|
CursorCLIPath string `yaml:"cursor_cli_path"`
|
|
DefaultModel string `yaml:"default_model"`
|
|
Timeout int `yaml:"timeout"`
|
|
MaxConcurrent int `yaml:"max_concurrent"`
|
|
UseACP bool `yaml:"use_acp"`
|
|
ChatOnlyWorkspace bool `yaml:"chat_only_workspace"`
|
|
LogLevel string `yaml:"log_level"`
|
|
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.
|
|
//
|
|
// ChatOnlyWorkspace defaults to true. This is the cursor-api-proxy posture:
|
|
// every Cursor CLI / ACP child is spawned in an empty temp directory with
|
|
// HOME / CURSOR_CONFIG_DIR overridden so it cannot see the host user's real
|
|
// project files or global ~/.cursor rules. Set to false only if you really
|
|
// want the Cursor agent to have access to the cwd where cursor-adapter
|
|
// started.
|
|
func Defaults() Config {
|
|
return Config{
|
|
Port: 8976,
|
|
CursorCLIPath: "agent",
|
|
DefaultModel: "auto",
|
|
Timeout: 300,
|
|
MaxConcurrent: 5,
|
|
UseACP: false,
|
|
ChatOnlyWorkspace: true,
|
|
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
|
|
// ~/.cursor-adapter/config.yaml. When the file does not exist, a config with
|
|
// default values is returned without an error.
|
|
func Load(path string) (*Config, error) {
|
|
if path == "" {
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("resolving home directory: %w", err)
|
|
}
|
|
path = filepath.Join(home, ".cursor-adapter", "config.yaml")
|
|
}
|
|
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
c := Defaults()
|
|
return &c, nil
|
|
}
|
|
return nil, fmt.Errorf("reading config file %s: %w", path, err)
|
|
}
|
|
|
|
cfg := Defaults()
|
|
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
|
return nil, fmt.Errorf("parsing config file %s: %w", path, err)
|
|
}
|
|
|
|
if err := cfg.validate(); err != nil {
|
|
return nil, fmt.Errorf("validating config: %w", err)
|
|
}
|
|
|
|
return &cfg, nil
|
|
}
|
|
|
|
func (c *Config) validate() error {
|
|
if c.Port <= 0 {
|
|
return fmt.Errorf("port must be > 0, got %d", c.Port)
|
|
}
|
|
if c.CursorCLIPath == "" {
|
|
return fmt.Errorf("cursor_cli_path must not be empty")
|
|
}
|
|
if c.Timeout <= 0 {
|
|
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
|
|
}
|