merge: refactor/usecase

This commit is contained in:
王性驊 2026-04-03 17:46:37 +08:00
commit e379c79bd1
7 changed files with 530 additions and 0 deletions

28
pkg/usecase/cmdargs.go Normal file
View File

@ -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)
}

85
pkg/usecase/maxmode.go Normal file
View File

@ -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
}
}

72
pkg/usecase/runner.go Normal file
View File

@ -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
}

95
pkg/usecase/sanitizer.go Normal file
View File

@ -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
}

View File

@ -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)
}
}
}

36
pkg/usecase/token.go Normal file
View File

@ -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))
}

154
pkg/usecase/toolcall.go Normal file
View File

@ -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)<function_calls>\s*(.*?)\s*</function_calls>`)
var antmlInvokeRe = regexp.MustCompile(`(?s)<invoke\s+name="([^"]+)">\s*(.*?)\s*</invoke>`)
var antmlParamRe = regexp.MustCompile(`(?s)<parameter\s+name="([^"]+)">(.*?)</parameter>`)
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
}