diff --git a/.env b/.env new file mode 100644 index 0000000..280992a --- /dev/null +++ b/.env @@ -0,0 +1,21 @@ +# 由 make env 自動產生,請勿手動編輯 +CURSOR_BRIDGE_HOST=127.0.0.1 +CURSOR_BRIDGE_PORT=8766 +CURSOR_BRIDGE_API_KEY= +CURSOR_BRIDGE_TIMEOUT_MS=3600000 +CURSOR_BRIDGE_MULTI_PORT=false +CURSOR_BRIDGE_VERBOSE=false +CURSOR_AGENT_BIN=agent +CURSOR_AGENT_NODE= +CURSOR_AGENT_SCRIPT= +CURSOR_BRIDGE_DEFAULT_MODEL=auto +CURSOR_BRIDGE_STRICT_MODEL=true +CURSOR_BRIDGE_MAX_MODE=true +CURSOR_BRIDGE_FORCE=false +CURSOR_BRIDGE_APPROVE_MCPS=false +CURSOR_BRIDGE_WORKSPACE= +CURSOR_BRIDGE_CHAT_ONLY_WORKSPACE=true +CURSOR_CONFIG_DIRS= +CURSOR_BRIDGE_TLS_CERT= +CURSOR_BRIDGE_TLS_KEY= +CURSOR_BRIDGE_SESSIONS_LOG= diff --git a/Makefile b/Makefile index 9e66ece..e11af0c 100644 --- a/Makefile +++ b/Makefile @@ -5,9 +5,9 @@ # ── 伺服器設定 ───────────────────────────────── HOST ?= 127.0.0.1 -PORT ?= 8765 +PORT ?= 8766 API_KEY ?= -TIMEOUT_MS ?= 300000 +TIMEOUT_MS ?= 3600000 MULTI_PORT ?= false VERBOSE ?= false @@ -17,7 +17,7 @@ AGENT_NODE ?= AGENT_SCRIPT ?= DEFAULT_MODEL ?= auto STRICT_MODEL ?= true -MAX_MODE ?= false +MAX_MODE ?= true FORCE ?= false APPROVE_MCPS ?= false diff --git a/internal/env/env.go b/internal/env/env.go index d70d848..239e3d8 100644 --- a/internal/env/env.go +++ b/internal/env/env.go @@ -141,7 +141,26 @@ func discoverAccountDirs(homeDir string) []string { return dirs } -func OsEnvToMap() EnvSource { +func parseDotEnv(path string) EnvSource { + data, err := os.ReadFile(path) + if err != nil { + return nil + } + m := make(EnvSource) + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + parts := strings.SplitN(line, "=", 2) + if len(parts) == 2 { + m[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1]) + } + } + return m +} + +func OsEnvToMap(cwdHint ...string) EnvSource { m := make(EnvSource) for _, kv := range os.Environ() { parts := strings.SplitN(kv, "=", 2) @@ -149,6 +168,22 @@ func OsEnvToMap() EnvSource { m[parts[0]] = parts[1] } } + + cwd := "" + if len(cwdHint) > 0 && cwdHint[0] != "" { + cwd = cwdHint[0] + } else { + cwd, _ = os.Getwd() + } + + if dotenv := parseDotEnv(filepath.Join(cwd, ".env")); dotenv != nil { + for k, v := range dotenv { + if _, exists := m[k]; !exists { + m[k] = v + } + } + } + return m } diff --git a/internal/handlers/chat.go b/internal/handlers/chat.go index 8a92021..0a6d0f5 100644 --- a/internal/handlers/chat.go +++ b/internal/handlers/chat.go @@ -96,8 +96,7 @@ func HandleChatCompletions(w http.ResponseWriter, r *http.Request, cfg config.Br return } if fit.Truncated { - fmt.Printf("[%s] Windows: prompt truncated for CreateProcess limit (%d -> %d chars, tail preserved).\n", - time.Now().UTC().Format(time.RFC3339), fit.OriginalLength, fit.FinalPromptLength) + logger.LogTruncation(fit.OriginalLength, fit.FinalPromptLength) } cmdArgs := fit.Args diff --git a/internal/logger/logger.go b/internal/logger/logger.go index d0db1e7..5d26d13 100644 --- a/internal/logger/logger.go +++ b/internal/logger/logger.go @@ -1,6 +1,7 @@ package logger import ( + "cursor-api-proxy/internal/config" "cursor-api-proxy/internal/pool" "fmt" "os" @@ -38,7 +39,11 @@ var roleEmoji = map[string]string{ } func ts() string { - return cGray + time.Now().UTC().Format(time.RFC3339Nano) + cReset + return cGray + time.Now().UTC().Format("15:04:05") + cReset +} + +func tsDate() string { + return cGray + time.Now().UTC().Format("2006-01-02 15:04:05") + cReset } func truncate(s string, max int) string { @@ -60,8 +65,66 @@ type TrafficMessage struct { Content string } +func LogServerStart(version, scheme, host string, port int, cfg config.BridgeConfig) { + fmt.Printf("\n%s%s╔══════════════════════════════════════════╗%s\n", cBold, cBCyan, cReset) + fmt.Printf("%s%s cursor-api-proxy %sv%s%s%s%s ready%s\n", + cBold, cBCyan, cReset, cBold, cWhite, version, cBCyan, cReset) + fmt.Printf("%s%s╚══════════════════════════════════════════╝%s\n\n", cBold, cBCyan, cReset) + url := fmt.Sprintf("%s://%s:%d", scheme, host, port) + fmt.Printf(" %s●%s listening %s%s%s\n", cBGreen, cReset, cBold, url, cReset) + fmt.Printf(" %s▸%s agent %s%s%s\n", cCyan, cReset, cDim, cfg.AgentBin, cReset) + fmt.Printf(" %s▸%s workspace %s%s%s\n", cCyan, cReset, cDim, cfg.Workspace, cReset) + fmt.Printf(" %s▸%s model %s%s%s\n", cCyan, cReset, cDim, cfg.DefaultModel, cReset) + fmt.Printf(" %s▸%s mode %s%s%s\n", cCyan, cReset, cDim, cfg.Mode, cReset) + + flags := []string{} + if cfg.Force { + flags = append(flags, "force") + } + if cfg.ApproveMcps { + flags = append(flags, "approve-mcps") + } + if cfg.MaxMode { + flags = append(flags, "max-mode") + } + if cfg.Verbose { + flags = append(flags, "verbose") + } + if cfg.ChatOnlyWorkspace { + flags = append(flags, "chat-only") + } + if cfg.RequiredKey != "" { + flags = append(flags, "api-key-required") + } + if len(flags) > 0 { + fmt.Printf(" %s▸%s flags %s%s%s\n", cCyan, cReset, cYellow, strings.Join(flags, " · "), cReset) + } + if len(cfg.ConfigDirs) > 0 { + fmt.Printf(" %s▸%s pool %s%d accounts%s\n", cCyan, cReset, cBGreen, len(cfg.ConfigDirs), cReset) + } + fmt.Println() +} + +func LogShutdown(sig string) { + fmt.Printf("\n%s %s⊘ %s received — shutting down gracefully…%s\n", tsDate(), cYellow, sig, cReset) +} + func LogIncoming(method, pathname, remoteAddress string) { - fmt.Printf("[%s] Incoming: %s %s (from %s)\n", time.Now().UTC().Format(time.RFC3339), method, pathname, remoteAddress) + methodColor := cBCyan + switch method { + case "POST": + methodColor = cBMagenta + case "GET": + methodColor = cBCyan + case "DELETE": + methodColor = cRed + } + fmt.Printf("%s %s%s%s%s %s%s%s %s(%s)%s\n", + ts(), + methodColor, cBold, method, cReset, + cWhite, pathname, cReset, + cDim, remoteAddress, cReset, + ) } func LogAccountAssigned(configDir string) { @@ -69,7 +132,7 @@ func LogAccountAssigned(configDir string) { return } name := filepath.Base(configDir) - fmt.Printf("[%s] %s→ account%s %s%s%s\n", time.Now().UTC().Format(time.RFC3339), cBCyan, cReset, cBold, name, cReset) + fmt.Printf("%s %s→%s account %s%s%s\n", ts(), cBCyan, cReset, cBold, name, cReset) } func LogAccountStats(verbose bool, stats []pool.AccountStat) { @@ -80,17 +143,17 @@ func LogAccountStats(verbose bool, stats []pool.AccountStat) { fmt.Printf("%s┌─ Account Stats %s┐%s\n", cGray, strings.Repeat("─", 44), cReset) for _, s := range stats { name := fmt.Sprintf("%-20s", filepath.Base(s.ConfigDir)) - active := fmt.Sprintf("%sdim%sactive:0%s", cDim, "", cReset) + active := fmt.Sprintf("%sactive:0%s", cDim, cReset) if s.ActiveRequests > 0 { active = fmt.Sprintf("%sactive:%d%s", cBCyan, s.ActiveRequests, cReset) } total := fmt.Sprintf("total:%s%d%s", cBold, s.TotalRequests, cReset) ok := fmt.Sprintf("%sok:%d%s", cGreen, s.TotalSuccess, cReset) - errStr := fmt.Sprintf("%sdim%serr:0%s", cDim, "", cReset) + errStr := fmt.Sprintf("%serr:0%s", cDim, cReset) if s.TotalErrors > 0 { errStr = fmt.Sprintf("%serr:%d%s", cRed, s.TotalErrors, cReset) } - rl := fmt.Sprintf("%sdim%srl:0%s", cDim, "", cReset) + rl := fmt.Sprintf("%srl:0%s", cDim, cReset) if s.TotalRateLimits > 0 { rl = fmt.Sprintf("%srl:%d%s", cYellow, s.TotalRateLimits, cReset) } @@ -114,7 +177,7 @@ func LogTrafficRequest(verbose bool, model string, messages []TrafficMessage, is if !verbose { return } - modeTag := fmt.Sprintf("%sdim%ssync%s", cDim, "", cReset) + modeTag := fmt.Sprintf("%ssync%s", cDim, cReset) if isStream { modeTag = fmt.Sprintf("%s⚡ stream%s", cBCyan, cReset) } @@ -142,7 +205,7 @@ func LogTrafficResponse(verbose bool, model, text string, isStream bool) { if !verbose { return } - modeTag := fmt.Sprintf("%sdim%ssync%s", cDim, "", cReset) + modeTag := fmt.Sprintf("%ssync%s", cDim, cReset) if isStream { modeTag = fmt.Sprintf("%s⚡ stream%s", cBGreen, cReset) } @@ -166,9 +229,14 @@ func AppendSessionLine(logPath, method, pathname, remoteAddress string, statusCo } } +func LogTruncation(originalLen, finalLen int) { + fmt.Printf("%s %s⚠ prompt truncated%s %s(%d → %d chars, tail preserved)%s\n", + ts(), cYellow, cReset, cDim, originalLen, finalLen, cReset) +} + func LogAgentError(logPath, method, pathname, remoteAddress string, exitCode int, stderr string) string { errMsg := fmt.Sprintf("Cursor CLI failed (exit %d): %s", exitCode, strings.TrimSpace(stderr)) - fmt.Fprintf(os.Stderr, "[%s] Agent error: %s\n", time.Now().UTC().Format(time.RFC3339), errMsg) + fmt.Fprintf(os.Stderr, "%s %s✗ agent error%s %s%s%s\n", ts(), cRed, cReset, cDim, errMsg, cReset) truncated := strings.TrimSpace(stderr) if len(truncated) > 200 { truncated = truncated[:200] diff --git a/internal/server/server.go b/internal/server/server.go index a992c06..6a7270b 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -7,6 +7,7 @@ import ( "cursor-api-proxy/internal/handlers" "cursor-api-proxy/internal/pool" "cursor-api-proxy/internal/process" + "cursor-api-proxy/internal/logger" "cursor-api-proxy/internal/router" "fmt" "net/http" @@ -98,34 +99,7 @@ func startSingleServer(opts ServerOptions) *http.Server { } }() - fmt.Printf("cursor-api-proxy listening on %s://%s:%d\n", scheme, cfg.Host, cfg.Port) - fmt.Printf("- agent bin: %s\n", cfg.AgentBin) - fmt.Printf("- workspace: %s\n", cfg.Workspace) - fmt.Printf("- mode: %s\n", cfg.Mode) - fmt.Printf("- default model: %s\n", cfg.DefaultModel) - fmt.Printf("- force: %v\n", cfg.Force) - fmt.Printf("- approve mcps: %v\n", cfg.ApproveMcps) - fmt.Printf("- required api key: %v\n", cfg.RequiredKey != "") - fmt.Printf("- sessions log: %s\n", cfg.SessionsLogPath) - if cfg.ChatOnlyWorkspace { - fmt.Println("- chat-only workspace: yes (isolated temp dir)") - } else { - fmt.Println("- chat-only workspace: no") - } - if cfg.Verbose { - fmt.Println("- verbose traffic: yes (CURSOR_BRIDGE_VERBOSE=true)") - } else { - fmt.Println("- verbose traffic: no") - } - if cfg.MaxMode { - fmt.Println("- max mode: yes (CURSOR_BRIDGE_MAX_MODE=true)") - } else { - fmt.Println("- max mode: no") - } - fmt.Printf("- Windows cmdline budget: %d (prompt tail truncation when over limit; Windows only)\n", cfg.WinCmdlineMax) - if len(cfg.ConfigDirs) > 0 { - fmt.Printf("- account pool: enabled with %d configuration directories\n", len(cfg.ConfigDirs)) - } + logger.LogServerStart(opts.Version, scheme, cfg.Host, cfg.Port, cfg) return srv } @@ -136,8 +110,7 @@ func SetupGracefulShutdown(servers []*http.Server, timeoutMs int) { go func() { sig := <-sigCh - fmt.Printf("\n[%s] %s received — shutting down gracefully…\n", - time.Now().UTC().Format(time.RFC3339), sig) + logger.LogShutdown(sig.String()) process.KillAllChildProcesses()