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 + 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//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 // {...} 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: {"name":"","input": { ... }} "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//mnt/`" + ` 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//mnt/`" + `, 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.: {"name":"","input":{"command":"pwd; ls -d /sessions/*/mnt/*/ 2>/dev/null; ls -la /workspace 2>/dev/null | head"}} Treat whatever directory comes back under ` + "`/sessions/*/mnt/`" + ` as ` + `THE working folder for this session, no matter what ` + "``" + ` 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 }