diff --git a/cmd/cli/accounts.go b/cmd/cli/accounts.go new file mode 100644 index 0000000..df1d999 --- /dev/null +++ b/cmd/cli/accounts.go @@ -0,0 +1,196 @@ +package cmd + +import ( + "cursor-api-proxy/internal/agent" + "encoding/json" + "fmt" + "os" + "path/filepath" +) + +type AccountInfo struct { + Name string + ConfigDir string + Authenticated bool + Email string + DisplayName string + AuthID string + Plan string + SubscriptionStatus string + ExpiresAt string +} + +func ReadAccountInfo(name, configDir string) AccountInfo { + info := AccountInfo{Name: name, ConfigDir: configDir} + + configFile := filepath.Join(configDir, "cli-config.json") + data, err := os.ReadFile(configFile) + if err != nil { + return info + } + + var raw struct { + AuthInfo *struct { + Email string `json:"email"` + DisplayName string `json:"displayName"` + AuthID string `json:"authId"` + } `json:"authInfo"` + } + if err := json.Unmarshal(data, &raw); err == nil && raw.AuthInfo != nil { + info.Authenticated = true + info.Email = raw.AuthInfo.Email + info.DisplayName = raw.AuthInfo.DisplayName + info.AuthID = raw.AuthInfo.AuthID + } + + statsigFile := filepath.Join(configDir, "statsig-cache.json") + statsigData, err := os.ReadFile(statsigFile) + if err != nil { + return info + } + + var statsigWrapper struct { + Data string `json:"data"` + } + if err := json.Unmarshal(statsigData, &statsigWrapper); err != nil || statsigWrapper.Data == "" { + return info + } + + var statsig struct { + User *struct { + Custom *struct { + IsEnterpriseUser bool `json:"isEnterpriseUser"` + StripeSubscriptionStatus string `json:"stripeSubscriptionStatus"` + StripeMembershipExpiration string `json:"stripeMembershipExpiration"` + } `json:"custom"` + } `json:"user"` + } + if err := json.Unmarshal([]byte(statsigWrapper.Data), &statsig); err != nil { + return info + } + + if statsig.User != nil && statsig.User.Custom != nil { + c := statsig.User.Custom + if c.IsEnterpriseUser { + info.Plan = "Enterprise" + } else if c.StripeSubscriptionStatus == "active" { + info.Plan = "Pro" + } else { + info.Plan = "Free" + } + info.SubscriptionStatus = c.StripeSubscriptionStatus + info.ExpiresAt = c.StripeMembershipExpiration + } + + return info +} + +func HandleAccountsList() error { + accountsDir := agent.AccountsDir() + + entries, err := os.ReadDir(accountsDir) + if err != nil { + fmt.Println("No accounts found. Use 'cursor-api-proxy login' to add one.") + return nil + } + + var names []string + for _, e := range entries { + if e.IsDir() { + names = append(names, e.Name()) + } + } + + if len(names) == 0 { + fmt.Println("No accounts found. Use 'cursor-api-proxy login' to add one.") + return nil + } + + fmt.Print("Cursor Accounts:\n\n") + + keychainToken := agent.ReadKeychainToken() + + for i, name := range names { + configDir := filepath.Join(accountsDir, name) + info := ReadAccountInfo(name, configDir) + + fmt.Printf(" %d. %s\n", i+1, name) + + if info.Authenticated { + cachedToken := agent.ReadCachedToken(configDir) + keychainMatchesAccount := keychainToken != "" && info.AuthID != "" && TokenSub(keychainToken) == info.AuthID + token := cachedToken + if token == "" && keychainMatchesAccount { + token = keychainToken + } + + var liveProfile *StripeProfile + var liveUsage *UsageData + if token != "" { + liveUsage, _ = FetchAccountUsage(token) + liveProfile, _ = FetchStripeProfile(token) + } + + if info.Email != "" { + display := "" + if info.DisplayName != "" { + display = " (" + info.DisplayName + ")" + } + fmt.Printf(" %s%s\n", info.Email, display) + } + + if info.Plan != "" && liveProfile == nil { + canceled := "" + if info.SubscriptionStatus == "canceled" { + canceled = " · canceled" + } + expiry := "" + if info.ExpiresAt != "" { + expiry = " · expires " + info.ExpiresAt + } + fmt.Printf(" %s%s%s\n", info.Plan, canceled, expiry) + } + fmt.Println(" Authenticated") + + if liveProfile != nil { + fmt.Printf(" %s\n", DescribePlan(liveProfile)) + } + if liveUsage != nil { + for _, line := range FormatUsageSummary(liveUsage) { + fmt.Println(line) + } + } + } else { + fmt.Println(" Not authenticated") + } + + fmt.Println("") + } + + fmt.Println("Tip: run 'cursor-api-proxy logout ' to remove an account.") + return nil +} + +func HandleLogout(accountName string) error { + if accountName == "" { + fmt.Fprintln(os.Stderr, "Error: Please specify the account name to remove.") + fmt.Fprintln(os.Stderr, "Usage: cursor-api-proxy logout ") + os.Exit(1) + } + + accountsDir := agent.AccountsDir() + configDir := filepath.Join(accountsDir, accountName) + + if _, err := os.Stat(configDir); os.IsNotExist(err) { + fmt.Fprintf(os.Stderr, "Account '%s' not found.\n", accountName) + os.Exit(1) + } + + if err := os.RemoveAll(configDir); err != nil { + fmt.Fprintf(os.Stderr, "Error removing account: %v\n", err) + os.Exit(1) + } + + fmt.Printf("Account '%s' removed.\n", accountName) + return nil +} diff --git a/cmd/cli/args.go b/cmd/cli/args.go new file mode 100644 index 0000000..05e1c05 --- /dev/null +++ b/cmd/cli/args.go @@ -0,0 +1,118 @@ +package cmd + +import "fmt" + +type ParsedArgs struct { + Tailscale bool + Help bool + Login bool + AccountsList bool + Logout bool + AccountName string + Proxies []string + ResetHwid bool + DeepClean bool + DryRun bool +} + +func ParseArgs(argv []string) (ParsedArgs, error) { + var args ParsedArgs + + for i := 0; i < len(argv); i++ { + arg := argv[i] + + switch arg { + case "login", "add-account": + args.Login = true + if i+1 < len(argv) && len(argv[i+1]) > 0 && argv[i+1][0] != '-' { + i++ + args.AccountName = argv[i] + } + + case "logout", "remove-account": + args.Logout = true + if i+1 < len(argv) && len(argv[i+1]) > 0 && argv[i+1][0] != '-' { + i++ + args.AccountName = argv[i] + } + + case "accounts", "list-accounts": + args.AccountsList = true + + case "reset-hwid", "reset": + args.ResetHwid = true + + case "--deep-clean": + args.DeepClean = true + + case "--dry-run": + args.DryRun = true + + case "--tailscale": + args.Tailscale = true + + case "--help", "-h": + args.Help = true + + default: + if len(arg) > len("--proxy=") && arg[:len("--proxy=")] == "--proxy=" { + raw := arg[len("--proxy="):] + parts := splitComma(raw) + for _, p := range parts { + if p != "" { + args.Proxies = append(args.Proxies, p) + } + } + } else { + return args, fmt.Errorf("Unknown argument: %s", arg) + } + } + } + + return args, nil +} + +func splitComma(s string) []string { + var result []string + start := 0 + for i := 0; i <= len(s); i++ { + if i == len(s) || s[i] == ',' { + part := trim(s[start:i]) + if part != "" { + result = append(result, part) + } + start = i + 1 + } + } + return result +} + +func trim(s string) string { + start := 0 + end := len(s) + for start < end && (s[start] == ' ' || s[start] == '\t') { + start++ + } + for end > start && (s[end-1] == ' ' || s[end-1] == '\t') { + end-- + } + return s[start:end] +} + +func PrintHelp(version string) { + fmt.Printf("cursor-api-proxy v%s\n\n", version) + fmt.Println("Usage:") + fmt.Println(" cursor-api-proxy [options]") + fmt.Println("") + fmt.Println("Commands:") + fmt.Println(" login [name] Log into a Cursor account (saved to ~/.cursor-api-proxy/accounts/)") + fmt.Println(" login [name] --proxy=... Same, but with a proxy from a comma-separated list") + fmt.Println(" logout Remove a saved Cursor account") + fmt.Println(" accounts List saved accounts with plan info") + fmt.Println(" reset-hwid Reset Cursor machine/telemetry IDs (anti-ban)") + fmt.Println(" reset-hwid --deep-clean Also wipe session storage and cookies") + fmt.Println("") + fmt.Println("Options:") + fmt.Println(" --tailscale Bind to 0.0.0.0 for tailnet/LAN access") + fmt.Println(" -h, --help Show this help message") +} diff --git a/cmd/cli/login.go b/cmd/cli/login.go new file mode 100644 index 0000000..f1eb22d --- /dev/null +++ b/cmd/cli/login.go @@ -0,0 +1,125 @@ +package cmd + +import ( + "bufio" + "cursor-api-proxy/internal/agent" + "cursor-api-proxy/internal/env" + "fmt" + "os" + "os/exec" + "os/signal" + "path/filepath" + "regexp" + "syscall" + "time" +) + +var loginURLRe = regexp.MustCompile(`https://cursor\.com/loginDeepControl.*?redirectTarget=cli`) + +func HandleLogin(accountName string, proxies []string) error { + e := env.OsEnvToMap() + loaded := env.LoadEnvConfig(e, "") + agentBin := loaded.AgentBin + + if accountName == "" { + accountName = fmt.Sprintf("account-%d", time.Now().UnixMilli()%10000) + } + + accountsDir := agent.AccountsDir() + configDir := filepath.Join(accountsDir, accountName) + dirWasNew := !fileExists(configDir) + + if err := os.MkdirAll(accountsDir, 0755); err != nil { + return fmt.Errorf("failed to create accounts dir: %w", err) + } + if err := os.MkdirAll(configDir, 0755); err != nil { + return fmt.Errorf("failed to create config dir: %w", err) + } + + fmt.Printf("Logging into Cursor account: %s\n", accountName) + fmt.Printf("Config: %s\n\n", configDir) + fmt.Println("Run the login command — complete the login in your browser.") + fmt.Println("") + + cleanupDir := func() { + if dirWasNew { + _ = os.RemoveAll(configDir) + } + } + + cmdEnv := make([]string, 0, len(e)+2) + for k, v := range e { + cmdEnv = append(cmdEnv, k+"="+v) + } + cmdEnv = append(cmdEnv, "CURSOR_CONFIG_DIR="+configDir) + cmdEnv = append(cmdEnv, "NO_OPEN_BROWSER=1") + + child := exec.Command(agentBin, "login") + child.Env = cmdEnv + child.Stdin = os.Stdin + child.Stderr = os.Stderr + + stdoutPipe, err := child.StdoutPipe() + if err != nil { + return fmt.Errorf("failed to create stdout pipe: %w", err) + } + + if err := child.Start(); err != nil { + cleanupDir() + if os.IsNotExist(err) { + return fmt.Errorf("could not find '%s'. Make sure the Cursor CLI is installed", agentBin) + } + return fmt.Errorf("error launching agent login: %w", err) + } + + // Handle cancellation signals + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP) + go func() { + sig := <-sigCh + _ = child.Process.Kill() + cleanupDir() + if sig == syscall.SIGINT { + fmt.Println("\n\nLogin cancelled.") + } + os.Exit(0) + }() + defer signal.Stop(sigCh) + + var stdoutBuf string + scanner := bufio.NewScanner(stdoutPipe) + for scanner.Scan() { + line := scanner.Text() + fmt.Println(line) + stdoutBuf += line + "\n" + + if loginURLRe.MatchString(stdoutBuf) { + match := loginURLRe.FindString(stdoutBuf) + if match != "" { + fmt.Printf("\nOpen this URL in your browser (incognito recommended):\n%s\n\n", match) + } + } + } + + if err := child.Wait(); err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + cleanupDir() + return fmt.Errorf("login failed with code %d", exitErr.ExitCode()) + } + return err + } + + // Cache keychain token for this account + token := agent.ReadKeychainToken() + if token != "" { + agent.WriteCachedToken(configDir, token) + } + + fmt.Printf("\nAccount '%s' saved — it will be auto-discovered when you start the proxy.\n", accountName) + return nil +} + +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} diff --git a/cmd/cli/resethwid.go b/cmd/cli/resethwid.go new file mode 100644 index 0000000..ba53c2b --- /dev/null +++ b/cmd/cli/resethwid.go @@ -0,0 +1,261 @@ +package cmd + +import ( + "crypto/rand" + "crypto/sha256" + "crypto/sha512" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "time" + + "github.com/google/uuid" +) + +func sha256hex() string { + b := make([]byte, 32) + _, _ = rand.Read(b) + h := sha256.Sum256(b) + return hex.EncodeToString(h[:]) +} + +func sha512hex() string { + b := make([]byte, 64) + _, _ = rand.Read(b) + h := sha512.Sum512(b) + return hex.EncodeToString(h[:]) +} + +func newUUID() string { + return uuid.New().String() +} + +func log(icon, msg string) { + fmt.Printf(" %s %s\n", icon, msg) +} + +func getCursorGlobalStorage() string { + switch runtime.GOOS { + case "darwin": + home, _ := os.UserHomeDir() + return filepath.Join(home, "Library", "Application Support", "Cursor", "User", "globalStorage") + case "windows": + appdata := os.Getenv("APPDATA") + return filepath.Join(appdata, "Cursor", "User", "globalStorage") + default: + xdg := os.Getenv("XDG_CONFIG_HOME") + if xdg == "" { + home, _ := os.UserHomeDir() + xdg = filepath.Join(home, ".config") + } + return filepath.Join(xdg, "Cursor", "User", "globalStorage") + } +} + +func getCursorRoot() string { + gs := getCursorGlobalStorage() + return filepath.Dir(filepath.Dir(gs)) +} + +func generateNewIDs() map[string]string { + return map[string]string{ + "telemetry.machineId": sha256hex(), + "telemetry.macMachineId": sha512hex(), + "telemetry.devDeviceId": newUUID(), + "telemetry.sqmId": "{" + fmt.Sprintf("%s", newUUID()+"") + "}", + "storage.serviceMachineId": newUUID(), + } +} + +func killCursor() { + log("", "Stopping Cursor processes...") + switch runtime.GOOS { + case "windows": + exec.Command("taskkill", "/F", "/IM", "Cursor.exe").Run() + default: + exec.Command("pkill", "-x", "Cursor").Run() + exec.Command("pkill", "-f", "Cursor.app").Run() + } + log("", "Cursor stopped (or was not running)") +} + +func updateStorageJSON(storagePath string, ids map[string]string) { + if _, err := os.Stat(storagePath); os.IsNotExist(err) { + log("", fmt.Sprintf("storage.json not found: %s", storagePath)) + return + } + + if runtime.GOOS == "darwin" { + exec.Command("chflags", "nouchg", storagePath).Run() + exec.Command("chmod", "644", storagePath).Run() + } + + data, err := os.ReadFile(storagePath) + if err != nil { + log("", fmt.Sprintf("storage.json read error: %v", err)) + return + } + + var obj map[string]interface{} + if err := json.Unmarshal(data, &obj); err != nil { + log("", fmt.Sprintf("storage.json parse error: %v", err)) + return + } + + for k, v := range ids { + obj[k] = v + } + + out, err := json.MarshalIndent(obj, "", " ") + if err != nil { + log("", fmt.Sprintf("storage.json marshal error: %v", err)) + return + } + + if err := os.WriteFile(storagePath, out, 0644); err != nil { + log("", fmt.Sprintf("storage.json write error: %v", err)) + return + } + log("", "storage.json updated") +} + +func updateStateVscdb(dbPath string, ids map[string]string) { + if _, err := os.Stat(dbPath); os.IsNotExist(err) { + log("", fmt.Sprintf("state.vscdb not found: %s", dbPath)) + return + } + + if runtime.GOOS == "darwin" { + exec.Command("chflags", "nouchg", dbPath).Run() + exec.Command("chmod", "644", dbPath).Run() + } + + if err := updateVscdbPureGo(dbPath, ids); err != nil { + log("", fmt.Sprintf("state.vscdb error: %v", err)) + } else { + log("", "state.vscdb updated") + } +} + +func updateMachineIDFile(machineID, cursorRoot string) { + var candidates []string + if runtime.GOOS == "linux" { + candidates = []string{ + filepath.Join(cursorRoot, "machineid"), + filepath.Join(cursorRoot, "machineId"), + } + } else { + candidates = []string{filepath.Join(cursorRoot, "machineId")} + } + + filePath := candidates[0] + for _, c := range candidates { + if _, err := os.Stat(c); err == nil { + filePath = c + break + } + } + + if err := os.MkdirAll(filepath.Dir(filePath), 0755); err != nil { + log("", fmt.Sprintf("machineId dir error: %v", err)) + return + } + + if runtime.GOOS == "darwin" { + if _, err := os.Stat(filePath); err == nil { + exec.Command("chflags", "nouchg", filePath).Run() + exec.Command("chmod", "644", filePath).Run() + } + } + + if err := os.WriteFile(filePath, []byte(machineID+"\n"), 0644); err != nil { + log("", fmt.Sprintf("machineId write error: %v", err)) + return + } + log("", fmt.Sprintf("machineId file updated (%s)", filepath.Base(filePath))) +} + +var dirsToWipe = []string{ + "Session Storage", "Local Storage", "IndexedDB", "Cache", "Code Cache", + "GPUCache", "Service Worker", "Network", "Cookies", "Cookies-journal", +} + +func deepClean(cursorRoot string) { + log("", "Deep-cleaning session data...") + wiped := 0 + for _, name := range dirsToWipe { + target := filepath.Join(cursorRoot, name) + if _, err := os.Stat(target); os.IsNotExist(err) { + continue + } + info, err := os.Stat(target) + if err != nil { + continue + } + if info.IsDir() { + if err := os.RemoveAll(target); err == nil { + wiped++ + } + } else { + if err := os.Remove(target); err == nil { + wiped++ + } + } + } + log("", fmt.Sprintf("Wiped %d cache/session items", wiped)) +} + +func HandleResetHwid(doDeepClean, dryRun bool) error { + fmt.Print("\nCursor HWID Reset\n\n") + fmt.Println(" Resets all machine / telemetry IDs so Cursor sees a fresh install.") + fmt.Print(" Cursor must be closed — it will be killed automatically.\n\n") + + globalStorage := getCursorGlobalStorage() + cursorRoot := getCursorRoot() + + if _, err := os.Stat(globalStorage); os.IsNotExist(err) { + fmt.Printf("Cursor config not found at:\n %s\n", globalStorage) + fmt.Println(" Make sure Cursor is installed and has been run at least once.") + os.Exit(1) + } + + if dryRun { + fmt.Println(" [DRY RUN] Would reset IDs in:") + fmt.Printf(" %s\n", filepath.Join(globalStorage, "storage.json")) + fmt.Printf(" %s\n", filepath.Join(globalStorage, "state.vscdb")) + fmt.Printf(" %s\n", filepath.Join(cursorRoot, "machineId")) + return nil + } + + killCursor() + + time.Sleep(800 * time.Millisecond) + + newIDs := generateNewIDs() + log("", "Generated new IDs:") + for k, v := range newIDs { + fmt.Printf(" %s: %s\n", k, v) + } + fmt.Println() + + log("", "Updating storage.json...") + updateStorageJSON(filepath.Join(globalStorage, "storage.json"), newIDs) + + log("", "Updating state.vscdb...") + updateStateVscdb(filepath.Join(globalStorage, "state.vscdb"), newIDs) + + log("", "Updating machineId file...") + updateMachineIDFile(newIDs["telemetry.machineId"], cursorRoot) + + if doDeepClean { + fmt.Println() + deepClean(cursorRoot) + } + + fmt.Print("\nHWID reset complete. You can now restart Cursor.\n\n") + return nil +} diff --git a/cmd/cli/sqlite.go b/cmd/cli/sqlite.go new file mode 100644 index 0000000..a8173b2 --- /dev/null +++ b/cmd/cli/sqlite.go @@ -0,0 +1,29 @@ +package cmd + +import ( + "database/sql" + "fmt" + + _ "modernc.org/sqlite" +) + +func updateVscdbPureGo(dbPath string, ids map[string]string) error { + db, err := sql.Open("sqlite", dbPath) + if err != nil { + return fmt.Errorf("open db: %w", err) + } + defer db.Close() + + _, err = db.Exec(`CREATE TABLE IF NOT EXISTS ItemTable (key TEXT PRIMARY KEY, value TEXT NOT NULL)`) + if err != nil { + return fmt.Errorf("create table: %w", err) + } + + for k, v := range ids { + _, err = db.Exec(`INSERT OR REPLACE INTO ItemTable (key, value) VALUES (?, ?)`, k, v) + if err != nil { + return fmt.Errorf("insert %s: %w", k, err) + } + } + return nil +} diff --git a/cmd/cli/usage.go b/cmd/cli/usage.go new file mode 100644 index 0000000..4999020 --- /dev/null +++ b/cmd/cli/usage.go @@ -0,0 +1,255 @@ +package cmd + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +type ModelUsage struct { + NumRequests int `json:"numRequests"` + NumRequestsTotal int `json:"numRequestsTotal"` + NumTokens int `json:"numTokens"` + MaxTokenUsage *int `json:"maxTokenUsage"` + MaxRequestUsage *int `json:"maxRequestUsage"` +} + +type UsageData struct { + StartOfMonth string `json:"startOfMonth"` + Models map[string]ModelUsage `json:"-"` +} + +type StripeProfile struct { + MembershipType string `json:"membershipType"` + SubscriptionStatus string `json:"subscriptionStatus"` + DaysRemainingOnTrial *int `json:"daysRemainingOnTrial"` + IsTeamMember bool `json:"isTeamMember"` + IsYearlyPlan bool `json:"isYearlyPlan"` +} + +func DecodeJWTPayload(token string) map[string]interface{} { + parts := strings.Split(token, ".") + if len(parts) < 2 { + return nil + } + padded := strings.ReplaceAll(parts[1], "-", "+") + padded = strings.ReplaceAll(padded, "_", "/") + data, err := base64.StdEncoding.DecodeString(padded + strings.Repeat("=", (4-len(padded)%4)%4)) + if err != nil { + return nil + } + var result map[string]interface{} + if err := json.Unmarshal(data, &result); err != nil { + return nil + } + return result +} + +func TokenSub(token string) string { + payload := DecodeJWTPayload(token) + if payload == nil { + return "" + } + if sub, ok := payload["sub"].(string); ok { + return sub + } + return "" +} + +func apiGet(path, token string) (map[string]interface{}, error) { + client := &http.Client{Timeout: 8 * time.Second} + req, err := http.NewRequest("GET", "https://api2.cursor.sh"+path, nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+token) + + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var result map[string]interface{} + if err := json.Unmarshal(data, &result); err != nil { + return nil, nil + } + return result, nil +} + +func FetchAccountUsage(token string) (*UsageData, error) { + raw, err := apiGet("/auth/usage", token) + if err != nil || raw == nil { + return nil, err + } + + startOfMonth, _ := raw["startOfMonth"].(string) + usage := &UsageData{ + StartOfMonth: startOfMonth, + Models: make(map[string]ModelUsage), + } + + for k, v := range raw { + if k == "startOfMonth" { + continue + } + data, err := json.Marshal(v) + if err != nil { + continue + } + var mu ModelUsage + if err := json.Unmarshal(data, &mu); err == nil { + usage.Models[k] = mu + } + } + return usage, nil +} + +func FetchStripeProfile(token string) (*StripeProfile, error) { + raw, err := apiGet("/auth/full_stripe_profile", token) + if err != nil || raw == nil { + return nil, err + } + + profile := &StripeProfile{ + MembershipType: fmt.Sprintf("%v", raw["membershipType"]), + SubscriptionStatus: fmt.Sprintf("%v", raw["subscriptionStatus"]), + IsTeamMember: raw["isTeamMember"] == true, + IsYearlyPlan: raw["isYearlyPlan"] == true, + } + if d, ok := raw["daysRemainingOnTrial"].(float64); ok { + di := int(d) + profile.DaysRemainingOnTrial = &di + } + return profile, nil +} + +func DescribePlan(profile *StripeProfile) string { + if profile == nil { + return "" + } + switch profile.MembershipType { + case "free_trial": + days := 0 + if profile.DaysRemainingOnTrial != nil { + days = *profile.DaysRemainingOnTrial + } + return fmt.Sprintf("Pro Trial (%dd left) — unlimited fast requests", days) + case "pro": + return "Pro — extended limits" + case "pro_plus": + return "Pro+ — extended limits" + case "ultra": + return "Ultra — extended limits" + case "free", "hobby": + return "Hobby (free) — limited agent requests" + default: + return fmt.Sprintf("%s · %s", profile.MembershipType, profile.SubscriptionStatus) + } +} + +var modelLabels = map[string]string{ + "gpt-4": "Fast Premium Requests", + "claude-sonnet-4-6": "Claude Sonnet 4.6", + "claude-sonnet-4-5-20250929-v1": "Claude Sonnet 4.5", + "claude-sonnet-4-20250514-v1": "Claude Sonnet 4", + "claude-opus-4-6-v1": "Claude Opus 4.6", + "claude-opus-4-5-20251101-v1": "Claude Opus 4.5", + "claude-opus-4-1-20250805-v1": "Claude Opus 4.1", + "claude-opus-4-20250514-v1": "Claude Opus 4", + "claude-haiku-4-5-20251001-v1": "Claude Haiku 4.5", + "claude-3-5-haiku-20241022-v1": "Claude 3.5 Haiku", + "gpt-5": "GPT-5", + "gpt-4o": "GPT-4o", + "o1": "o1", + "o3-mini": "o3-mini", + "cursor-small": "Cursor Small (free)", +} + +func modelLabel(key string) string { + if label, ok := modelLabels[key]; ok { + return label + } + return key +} + +func FormatUsageSummary(usage *UsageData) []string { + if usage == nil { + return nil + } + var lines []string + + start := "?" + if usage.StartOfMonth != "" { + if t, err := time.Parse(time.RFC3339, usage.StartOfMonth); err == nil { + start = t.Format("2006-01-02") + } else { + start = usage.StartOfMonth + } + } + lines = append(lines, fmt.Sprintf(" Billing period from %s", start)) + + if len(usage.Models) == 0 { + lines = append(lines, " No requests this billing period") + return lines + } + + type entry struct { + key string + usage ModelUsage + } + var entries []entry + for k, v := range usage.Models { + entries = append(entries, entry{k, v}) + } + + // Sort: entries with limits first, then by usage descending + for i := 1; i < len(entries); i++ { + for j := i; j > 0; j-- { + a, b := entries[j-1], entries[j] + aHasLimit := a.usage.MaxRequestUsage != nil + bHasLimit := b.usage.MaxRequestUsage != nil + if !aHasLimit && bHasLimit { + entries[j-1], entries[j] = entries[j], entries[j-1] + } else if aHasLimit == bHasLimit && a.usage.NumRequests < b.usage.NumRequests { + entries[j-1], entries[j] = entries[j], entries[j-1] + } else { + break + } + } + } + + for _, e := range entries { + used := e.usage.NumRequests + max := e.usage.MaxRequestUsage + label := modelLabel(e.key) + if max != nil && *max > 0 { + pct := int(float64(used) / float64(*max) * 100) + bar := makeBar(used, *max, 12) + lines = append(lines, fmt.Sprintf(" %s: %d/%d (%d%%) [%s]", label, used, *max, pct, bar)) + } else if used > 0 { + lines = append(lines, fmt.Sprintf(" %s: %d requests", label, used)) + } else { + lines = append(lines, fmt.Sprintf(" %s: 0 requests (unlimited)", label)) + } + } + + return lines +} + +func makeBar(used, max, width int) string { + fill := int(float64(used) / float64(max) * float64(width)) + if fill > width { + fill = width + } + return strings.Repeat("█", fill) + strings.Repeat("░", width-fill) +}