opencode-cursor-agent/internal/config/config.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
}