Files
magpie/internal/provider/cursor_usage.go
T

129 lines
3.9 KiB
Go

package provider
// How much of a Cursor plan's included usage is gone, as the CLI's own
// usage view reads it: the dashboard's current period, split into what Auto
// and Composer used and what the named (API) models did.
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"time"
"github.com/yetone/magpie/internal/proc"
)
// cursorBase is Cursor's API; a var so tests can point it elsewhere.
var cursorBase = "https://api2.cursor.sh"
// cursorKeychain reads cursor-agent's token from the macOS Keychain; a var
// so tests stay off the real one.
var cursorKeychain = runtime.GOOS == "darwin"
// cursorToken is the access token cursor-agent signed in with: in the
// Keychain on a Mac, in its auth.json elsewhere.
func cursorToken() (string, error) {
if cursorKeychain {
out, err := proc.Command("security", "find-generic-password", "-s", "cursor-access-token", "-a", "cursor-user", "-w").Output()
if tok := strings.TrimSpace(string(out)); err == nil && tok != "" {
return tok, nil
}
}
var auth struct {
AccessToken string `json:"accessToken"`
}
if path := cursorAuthPath(); path != "" && readJSON(path, &auth) && auth.AccessToken != "" {
return auth.AccessToken, nil
}
return "", errorf("cursor-agent is not signed in")
}
// cursorAuthPath is where cursor-agent keeps its sign-in outside the Keychain.
func cursorAuthPath() string {
home, err := os.UserHomeDir()
if err != nil {
return ""
}
switch runtime.GOOS {
case "windows":
dir := os.Getenv("APPDATA")
if dir == "" {
dir = filepath.Join(home, "AppData", "Roaming")
}
return filepath.Join(dir, "Cursor", "auth.json")
case "darwin":
return filepath.Join(home, ".cursor", "auth.json")
}
dir := os.Getenv("XDG_CONFIG_HOME")
if dir == "" {
dir = filepath.Join(home, ".config")
}
return filepath.Join(dir, "cursor", "auth.json")
}
func cursorSubscriptionUsage(ctx context.Context, plan string) SubscriptionQuota {
q := SubscriptionQuota{Provider: "cursor", Name: "Cursor", Icon: "cursor", Plan: plan, Windows: []QuotaWindow{}}
tok, err := cursorToken()
if err == nil {
q.Windows, err = cursorWindows(ctx, tok)
}
if err != nil {
q.Error = err.Error()
}
return q
}
// cursorWindows: the plan's included usage this billing period. An
// enterprise plan reports spend instead, and gets no windows.
func cursorWindows(ctx context.Context, token string) ([]QuotaWindow, error) {
var data struct {
BillingCycleEnd string `json:"billingCycleEnd"` // epoch millis
PlanUsage *struct {
Auto float64 `json:"autoPercentUsed"`
API float64 `json:"apiPercentUsed"`
Total float64 `json:"totalPercentUsed"`
} `json:"planUsage"`
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, cursorBase+"/aiserver.v1.DashboardService/GetCurrentPeriodUsage", bytes.NewReader([]byte("{}")))
if err != nil {
return []QuotaWindow{}, err
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Connect-Protocol-Version", "1")
res, err := http.DefaultClient.Do(req)
if err != nil {
return []QuotaWindow{}, err
}
defer res.Body.Close()
b, _ := io.ReadAll(io.LimitReader(res.Body, 1<<20))
if res.StatusCode < 200 || res.StatusCode >= 300 {
return []QuotaWindow{}, &accountStatusError{status: res.StatusCode}
}
if err := json.Unmarshal(b, &data); err != nil {
return []QuotaWindow{}, err
}
if data.PlanUsage == nil {
return []QuotaWindow{}, nil
}
var resets *time.Time
if ms, err := strconv.ParseInt(data.BillingCycleEnd, 10, 64); err == nil && ms > 0 {
t := time.UnixMilli(ms)
resets = &t
}
u := data.PlanUsage
// the two pools fit the line; the total goes in its tooltip
return []QuotaWindow{
{Name: "Auto + Composer", Used: u.Auto, ResetsAt: resets},
{Name: "API", Used: u.API, ResetsAt: resets},
{Name: "Total", Used: u.Total, ResetsAt: resets},
}, nil
}