merge: refactor/cli
This commit is contained in:
commit
8f1b7159ed
|
|
@ -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 <name>' 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 <account-name>")
|
||||
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
|
||||
}
|
||||
|
|
@ -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 <name> 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")
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
Loading…
Reference in New Issue