diff --git a/pkg/usecase/cmdargs.go b/pkg/usecase/cmdargs.go new file mode 100644 index 0000000..d5e8f6e --- /dev/null +++ b/pkg/usecase/cmdargs.go @@ -0,0 +1,28 @@ +package usecase + +import "cursor-api-proxy/internal/config" + +func BuildAgentFixedArgs(cfg config.BridgeConfig, workspaceDir, model string, stream bool) []string { + args := []string{"--print"} + if cfg.ApproveMcps { + args = append(args, "--approve-mcps") + } + if cfg.Force { + args = append(args, "--force") + } + if cfg.ChatOnlyWorkspace { + args = append(args, "--trust") + } + args = append(args, "--workspace", workspaceDir) + args = append(args, "--model", model) + if stream { + args = append(args, "--stream-partial-output", "--output-format", "stream-json") + } else { + args = append(args, "--output-format", "text") + } + return args +} + +func BuildAgentCmdArgs(cfg config.BridgeConfig, workspaceDir, model, prompt string, stream bool) []string { + return append(BuildAgentFixedArgs(cfg, workspaceDir, model, stream), prompt) +} diff --git a/pkg/usecase/maxmode.go b/pkg/usecase/maxmode.go new file mode 100644 index 0000000..291113b --- /dev/null +++ b/pkg/usecase/maxmode.go @@ -0,0 +1,85 @@ +package usecase + +import ( + "encoding/json" + "os" + "path/filepath" + "runtime" +) + +func getCandidates(agentScriptPath, configDirOverride string) []string { + if configDirOverride != "" { + return []string{filepath.Join(configDirOverride, "cli-config.json")} + } + + var result []string + + if dir := os.Getenv("CURSOR_CONFIG_DIR"); dir != "" { + result = append(result, filepath.Join(dir, "cli-config.json")) + } + + if agentScriptPath != "" { + agentDir := filepath.Dir(agentScriptPath) + result = append(result, filepath.Join(agentDir, "..", "data", "config", "cli-config.json")) + } + + home := os.Getenv("HOME") + if home == "" { + home = os.Getenv("USERPROFILE") + } + + switch runtime.GOOS { + case "windows": + local := os.Getenv("LOCALAPPDATA") + if local == "" { + local = filepath.Join(home, "AppData", "Local") + } + result = append(result, filepath.Join(local, "cursor-agent", "cli-config.json")) + case "darwin": + result = append(result, filepath.Join(home, "Library", "Application Support", "cursor-agent", "cli-config.json")) + default: + xdg := os.Getenv("XDG_CONFIG_HOME") + if xdg == "" { + xdg = filepath.Join(home, ".config") + } + result = append(result, filepath.Join(xdg, "cursor-agent", "cli-config.json")) + } + + return result +} + +func RunMaxModePreflight(agentScriptPath, configDirOverride string) { + for _, candidate := range getCandidates(agentScriptPath, configDirOverride) { + data, err := os.ReadFile(candidate) + if err != nil { + continue + } + + // Strip BOM if present + if len(data) >= 3 && data[0] == 0xEF && data[1] == 0xBB && data[2] == 0xBF { + data = data[3:] + } + + var raw map[string]interface{} + if err := json.Unmarshal(data, &raw); err != nil { + continue + } + if raw == nil || len(raw) <= 1 { + continue + } + + raw["maxMode"] = true + if model, ok := raw["model"].(map[string]interface{}); ok { + model["maxMode"] = true + } + + out, err := json.MarshalIndent(raw, "", " ") + if err != nil { + continue + } + if err := os.WriteFile(candidate, out, 0644); err != nil { + continue + } + return + } +} diff --git a/pkg/usecase/runner.go b/pkg/usecase/runner.go new file mode 100644 index 0000000..ee4b093 --- /dev/null +++ b/pkg/usecase/runner.go @@ -0,0 +1,72 @@ +package usecase + +import ( + "context" + "cursor-api-proxy/internal/config" + "cursor-api-proxy/pkg/infrastructure/process" + "os" + "path/filepath" +) + +func init() { + process.MaxModeFn = RunMaxModePreflight +} + +func cacheTokenForAccount(configDir string) { + if configDir == "" { + return + } + token := ReadKeychainToken() + if token != "" { + WriteCachedToken(configDir, token) + } +} + +func AccountsDir() string { + home := os.Getenv("HOME") + if home == "" { + home = os.Getenv("USERPROFILE") + } + return filepath.Join(home, ".cursor-api-proxy", "accounts") +} + +func RunAgentSync(cfg config.BridgeConfig, workspaceDir string, cmdArgs []string, tempDir, configDir string, ctx context.Context) (process.RunResult, error) { + opts := process.RunOptions{ + Cwd: workspaceDir, + TimeoutMs: cfg.TimeoutMs, + MaxMode: cfg.MaxMode, + ConfigDir: configDir, + Ctx: ctx, + } + + result, err := process.Run(cfg.AgentBin, cmdArgs, opts) + + cacheTokenForAccount(configDir) + if tempDir != "" { + os.RemoveAll(tempDir) + } + + return result, err +} + +func RunAgentStreamWithContext(cfg config.BridgeConfig, workspaceDir string, cmdArgs []string, onLine func(string), tempDir, configDir string, ctx context.Context) (process.StreamResult, error) { + opts := process.RunStreamingOptions{ + RunOptions: process.RunOptions{ + Cwd: workspaceDir, + TimeoutMs: cfg.TimeoutMs, + MaxMode: cfg.MaxMode, + ConfigDir: configDir, + Ctx: ctx, + }, + OnLine: onLine, + } + + result, err := process.RunStreaming(cfg.AgentBin, cmdArgs, opts) + + cacheTokenForAccount(configDir) + if tempDir != "" { + os.RemoveAll(tempDir) + } + + return result, err +} diff --git a/pkg/usecase/sanitizer.go b/pkg/usecase/sanitizer.go new file mode 100644 index 0000000..661df80 --- /dev/null +++ b/pkg/usecase/sanitizer.go @@ -0,0 +1,95 @@ +package usecase + +import "regexp" + +type rule struct { + pattern *regexp.Regexp + replacement string +} + +var rules = []rule{ + {regexp.MustCompile(`(?i)x-anthropic-billing-header:[^\n]*\n?`), ""}, + {regexp.MustCompile(`(?i)\bcc_version=[^\s;,\n]+[;,]?\s*`), ""}, + {regexp.MustCompile(`(?i)\bcc_entrypoint=[^\s;,\n]+[;,]?\s*`), ""}, + {regexp.MustCompile(`(?i)\bcch=[a-f0-9]+[;,]?\s*`), ""}, + {regexp.MustCompile(`\bClaude Code\b`), "Cursor"}, + {regexp.MustCompile(`(?i)Anthropic['']s official CLI for Claude`), "Cursor AI assistant"}, + {regexp.MustCompile(`\bAnthropic\b`), "Cursor"}, + {regexp.MustCompile(`(?i)anthropic\.com`), "cursor.com"}, + {regexp.MustCompile(`(?i)claude\.ai`), "cursor.sh"}, + {regexp.MustCompile(`^[;,\s]+`), ""}, +} + +func SanitizeText(text string) string { + for _, r := range rules { + text = r.pattern.ReplaceAllString(text, r.replacement) + } + return text +} + +func SanitizeMessages(messages []interface{}) []interface{} { + result := make([]interface{}, len(messages)) + for i, raw := range messages { + if raw == nil { + result[i] = raw + continue + } + m, ok := raw.(map[string]interface{}) + if !ok { + result[i] = raw + continue + } + newMsg := make(map[string]interface{}, len(m)) + for k, v := range m { + newMsg[k] = v + } + switch c := m["content"].(type) { + case string: + newMsg["content"] = SanitizeText(c) + case []interface{}: + newParts := make([]interface{}, len(c)) + for j, p := range c { + if pm, ok := p.(map[string]interface{}); ok && pm["type"] == "text" { + if t, ok := pm["text"].(string); ok { + newPart := make(map[string]interface{}, len(pm)) + for k, v := range pm { + newPart[k] = v + } + newPart["text"] = SanitizeText(t) + newParts[j] = newPart + continue + } + } + newParts[j] = p + } + newMsg["content"] = newParts + } + result[i] = newMsg + } + return result +} + +func SanitizeSystem(system interface{}) interface{} { + switch v := system.(type) { + case string: + return SanitizeText(v) + case []interface{}: + result := make([]interface{}, len(v)) + for i, p := range v { + if pm, ok := p.(map[string]interface{}); ok && pm["type"] == "text" { + if t, ok := pm["text"].(string); ok { + newPart := make(map[string]interface{}, len(pm)) + for k, val := range pm { + newPart[k] = val + } + newPart["text"] = SanitizeText(t) + result[i] = newPart + continue + } + } + result[i] = p + } + return result + } + return system +} diff --git a/pkg/usecase/sanitizer_test.go b/pkg/usecase/sanitizer_test.go new file mode 100644 index 0000000..393b510 --- /dev/null +++ b/pkg/usecase/sanitizer_test.go @@ -0,0 +1,60 @@ +package usecase + +import ( + "strings" + "testing" +) + +func TestSanitizeTextAnthropicBilling(t *testing.T) { + input := "x-anthropic-billing-header: abc123\nHello" + got := SanitizeText(input) + if strings.Contains(got, "x-anthropic-billing-header") { + t.Errorf("billing header not removed: %q", got) + } +} + +func TestSanitizeTextClaudeCode(t *testing.T) { + input := "I am Claude Code assistant" + got := SanitizeText(input) + if strings.Contains(got, "Claude Code") { + t.Errorf("'Claude Code' not replaced: %q", got) + } + if !strings.Contains(got, "Cursor") { + t.Errorf("'Cursor' not present in output: %q", got) + } +} + +func TestSanitizeTextAnthropic(t *testing.T) { + input := "Powered by Anthropic's technology and anthropic.com" + got := SanitizeText(input) + if strings.Contains(got, "Anthropic") { + t.Errorf("'Anthropic' not replaced: %q", got) + } + if strings.Contains(got, "anthropic.com") { + t.Errorf("'anthropic.com' not replaced: %q", got) + } +} + +func TestSanitizeTextNoChange(t *testing.T) { + input := "Hello, this is a normal message about cursor." + got := SanitizeText(input) + if got != input { + t.Errorf("unexpected change: %q -> %q", input, got) + } +} + +func TestSanitizeMessages(t *testing.T) { + messages := []interface{}{ + map[string]interface{}{"role": "user", "content": "Ask Claude Code something"}, + map[string]interface{}{"role": "system", "content": "Use Anthropic's tools"}, + } + result := SanitizeMessages(messages) + + for _, raw := range result { + m := raw.(map[string]interface{}) + c := m["content"].(string) + if strings.Contains(c, "Claude Code") || strings.Contains(c, "Anthropic") { + t.Errorf("found unsanitized content: %q", c) + } + } +} diff --git a/pkg/usecase/token.go b/pkg/usecase/token.go new file mode 100644 index 0000000..6dce4c6 --- /dev/null +++ b/pkg/usecase/token.go @@ -0,0 +1,36 @@ +package usecase + +import ( + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" +) + +const tokenFile = ".cursor-token" + +func ReadCachedToken(configDir string) string { + p := filepath.Join(configDir, tokenFile) + data, err := os.ReadFile(p) + if err != nil { + return "" + } + return strings.TrimSpace(string(data)) +} + +func WriteCachedToken(configDir, token string) { + p := filepath.Join(configDir, tokenFile) + _ = os.WriteFile(p, []byte(token), 0600) +} + +func ReadKeychainToken() string { + if runtime.GOOS != "darwin" { + return "" + } + out, err := exec.Command("security", "find-generic-password", "-s", "cursor-access-token", "-w").Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(out)) +} diff --git a/pkg/usecase/toolcall.go b/pkg/usecase/toolcall.go new file mode 100644 index 0000000..37f3677 --- /dev/null +++ b/pkg/usecase/toolcall.go @@ -0,0 +1,154 @@ +package usecase + +import ( + "encoding/json" + "regexp" + "strings" +) + +type ToolCall struct { + Name string + Arguments string // JSON string +} + +type ParsedResponse struct { + TextContent string + ToolCalls []ToolCall +} + +func (p *ParsedResponse) HasToolCalls() bool { + return len(p.ToolCalls) > 0 +} + +// Modified regex to handle nested JSON +var toolCallTagRe = regexp.MustCompile(`(?s)行政法规\s*(\{(?:[^{}]|\{[^{}]*\})*\})\s*ugalakh`) +var antmlFunctionCallsRe = regexp.MustCompile(`(?s)\s*(.*?)\s*`) +var antmlInvokeRe = regexp.MustCompile(`(?s)\s*(.*?)\s*`) +var antmlParamRe = regexp.MustCompile(`(?s)(.*?)`) + +func ExtractToolCalls(text string, toolNames map[string]bool) *ParsedResponse { + result := &ParsedResponse{} + + if locs := toolCallTagRe.FindAllStringSubmatchIndex(text, -1); len(locs) > 0 { + var calls []ToolCall + var textParts []string + last := 0 + for _, loc := range locs { + if loc[0] > last { + textParts = append(textParts, text[last:loc[0]]) + } + jsonStr := text[loc[2]:loc[3]] + if tc := parseToolCallJSON(jsonStr, toolNames); tc != nil { + calls = append(calls, *tc) + } else { + textParts = append(textParts, text[loc[0]:loc[1]]) + } + last = loc[1] + } + if last < len(text) { + textParts = append(textParts, text[last:]) + } + if len(calls) > 0 { + result.TextContent = strings.TrimSpace(strings.Join(textParts, "")) + result.ToolCalls = calls + return result + } + } + + if locs := antmlFunctionCallsRe.FindAllStringSubmatchIndex(text, -1); len(locs) > 0 { + var calls []ToolCall + var textParts []string + last := 0 + for _, loc := range locs { + if loc[0] > last { + textParts = append(textParts, text[last:loc[0]]) + } + block := text[loc[2]:loc[3]] + invokes := antmlInvokeRe.FindAllStringSubmatch(block, -1) + for _, inv := range invokes { + name := inv[1] + if toolNames != nil && len(toolNames) > 0 && !toolNames[name] { + continue + } + body := inv[2] + args := map[string]interface{}{} + params := antmlParamRe.FindAllStringSubmatch(body, -1) + for _, p := range params { + paramName := p[1] + paramValue := strings.TrimSpace(p[2]) + var jsonVal interface{} + if err := json.Unmarshal([]byte(paramValue), &jsonVal); err == nil { + args[paramName] = jsonVal + } else { + args[paramName] = paramValue + } + } + argsJSON, _ := json.Marshal(args) + calls = append(calls, ToolCall{Name: name, Arguments: string(argsJSON)}) + } + last = loc[1] + } + if last < len(text) { + textParts = append(textParts, text[last:]) + } + if len(calls) > 0 { + result.TextContent = strings.TrimSpace(strings.Join(textParts, "")) + result.ToolCalls = calls + return result + } + } + + result.TextContent = text + return result +} + +func parseToolCallJSON(jsonStr string, toolNames map[string]bool) *ToolCall { + var raw map[string]interface{} + if err := json.Unmarshal([]byte(jsonStr), &raw); err != nil { + return nil + } + name, _ := raw["name"].(string) + if name == "" { + return nil + } + if toolNames != nil && len(toolNames) > 0 && !toolNames[name] { + return nil + } + var argsStr string + switch a := raw["arguments"].(type) { + case string: + argsStr = a + case map[string]interface{}, []interface{}: + b, _ := json.Marshal(a) + argsStr = string(b) + default: + if p, ok := raw["parameters"]; ok { + b, _ := json.Marshal(p) + argsStr = string(b) + } else { + argsStr = "{}" + } + } + return &ToolCall{Name: name, Arguments: argsStr} +} + +func CollectToolNames(tools []interface{}) map[string]bool { + names := map[string]bool{} + for _, t := range tools { + m, ok := t.(map[string]interface{}) + if !ok { + continue + } + if m["type"] == "function" { + if fn, ok := m["function"].(map[string]interface{}); ok { + if name, ok := fn["name"].(string); ok { + names[name] = true + } + } + } + if name, ok := m["name"].(string); ok { + names[name] = true + } + } + return names +}