Files
magpie/internal/provider/cursor.go
T

319 lines
9.8 KiB
Go

package provider
// A Cursor subscription is served through the API cursor-agent talks to
// (gateway/cursor.go), with the account it is signed in to; here is who that
// account is, the models it offers, the sign-in, which is cursor-agent's own
// `login`, and the token that sign-in keeps.
import (
"bufio"
"context"
"encoding/base64"
"encoding/json"
"errors"
"os"
"os/exec"
"path/filepath"
"regexp"
"runtime"
"slices"
"strconv"
"strings"
"sync"
"time"
"github.com/yetone/magpie/internal/catalog"
"github.com/yetone/magpie/internal/proc"
)
// CursorExecutable finds the cursor-agent CLI; a var so tests can fake it.
var CursorExecutable = func() string {
for _, name := range []string{"cursor-agent", "agent"} {
if p, err := exec.LookPath(name); err == nil && (name == "cursor-agent" || isCursorAgent(p)) {
return p
}
}
home, _ := os.UserHomeDir()
for _, p := range []string{filepath.Join(home, ".local", "bin", "cursor-agent"), "/usr/local/bin/cursor-agent", "/opt/homebrew/bin/cursor-agent"} {
if st, err := os.Stat(p); err == nil && !st.IsDir() {
return p
}
}
return ""
}
// isCursorAgent tells Cursor's `agent` from any other program by that name.
func isCursorAgent(path string) bool {
real, err := filepath.EvalSymlinks(path)
return err == nil && strings.Contains(real, "cursor-agent")
}
var cursorStatus = &cliIdentity{name: "cursor", exe: func() string { return CursorExecutable() }, ask: func() (string, string, bool) { return askCursorIdentity() }}
// cursorIdentity is who Cursor's CLI says is signed in; see cliIdentity.
func cursorIdentity() (user, plan string, ok bool) { return cursorStatus.get() }
func forgetCursorStatus() { cursorStatus.forget() }
func askCursorIdentity() (user, plan string, ok bool) {
path := CursorExecutable()
if path == "" {
return "", "", false
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
out, _ := agentCommand(ctx, path, "about", "--format", "json").Output()
var about struct {
SubscriptionTier string `json:"subscriptionTier"`
UserEmail string `json:"userEmail"`
}
if json.Unmarshal(out, &about) != nil || strings.TrimSpace(about.UserEmail) == "" {
return "", "", false
}
return strings.TrimSpace(about.UserEmail), strings.TrimSpace(about.SubscriptionTier), true
}
func cursorAccount() (Provider, bool) {
user, plan, ok := cursorIdentity()
if !ok {
return Provider{}, false
}
acct := &Account{Agent: "cursor", User: user, Plan: plan}
acct.models = func() []catalog.Model { return []catalog.Model{{ID: "auto", Name: "Auto"}} }
acct.fetch = func(ctx context.Context) ([]catalog.Model, error) {
ms, err := cursorModels(ctx)
if err != nil {
return nil, err
}
return ms, catalog.SaveLive("cursor", "", ms)
}
return Provider{ID: "cursor", Name: "Cursor", Icon: "cursor", Website: "https://cursor.com", Account: acct}, true
}
var (
ansi = regexp.MustCompile(`\x1b\[[0-9;?]*[A-Za-z]`)
cursorModelL = regexp.MustCompile(`^([A-Za-z0-9][\w.:-]*) - (.+)$`)
)
// cursorModels lists what the account can use, as `cursor-agent models`
// prints it: "id - Name", one a line.
func cursorModels(ctx context.Context) ([]catalog.Model, error) {
path := CursorExecutable()
if path == "" {
return nil, errorf("cursor-agent is not installed")
}
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
out, err := agentCommand(ctx, path, "models").Output()
if err != nil {
return nil, errorf("cursor-agent models: %v", err)
}
return parseCursorModels(string(out)), nil
}
func parseCursorModels(out string) []catalog.Model {
var ms []catalog.Model
s := bufio.NewScanner(strings.NewReader(ansi.ReplaceAllString(out, "")))
for s.Scan() {
m := cursorModelL.FindStringSubmatch(strings.TrimSpace(s.Text()))
if m == nil {
continue
}
name := strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(m[2]), "(default)"))
name = strings.TrimSpace(strings.TrimSuffix(name, "(current)"))
ms = append(ms, catalog.Model{ID: m[1], Name: name, Context: cursorContext(m[1], name)})
}
return ms
}
// cursorDefaultContext is the context Cursor gives a model it doesn't name
// as a 1M one.
const cursorDefaultContext = 200_000
var (
cursorMillion = regexp.MustCompile(`\b(\d+)M\b`)
cursorVariant = regexp.MustCompile(`-(fast|none|low|medium|high|xhigh|extra-high|max|thinking)$`)
)
// cursorContext is how much of a conversation Cursor lets a model hold. Its
// ids ("claude-opus-5-5-high-fast") are its own, so no catalog knows them,
// and an agent given none took every Cursor model for its own default: a
// 1M one compacted at a fifth of it, and a 200K one — Cursor's own, where
// the name doesn't say 1M — was sent more than Cursor keeps. The name says
// which is which ("Claude Opus 5.5 1M"); under it, a model known to hold
// less keeps its own.
func cursorContext(id, name string) int {
if m := cursorMillion.FindStringSubmatch(name); m != nil {
n, _ := strconv.Atoi(m[1])
return n * 1_000_000
}
base := strings.TrimPrefix(id, "cursor-")
for {
b := cursorVariant.ReplaceAllString(base, "")
if b == base {
break
}
base = b
}
if base == "auto" { // Cursor's pick, not a model of that name
return cursorDefaultContext
}
if n := catalog.ContextOf(base); n > 0 && n < cursorDefaultContext {
return n
}
return cursorDefaultContext
}
// withCursorContexts fills in a saved list's contexts, fetched before
// magpie kept them.
func withCursorContexts(ms []catalog.Model) []catalog.Model {
out := slices.Clone(ms)
for i, m := range out {
if m.Context == 0 {
out[i].Context = cursorContext(m.ID, m.Name)
}
}
return out
}
var cursorLoginURL = regexp.MustCompile(`https://\S+`)
// startCursorSignIn runs `cursor-agent login` without its browser, hands its
// link to the window, and finishes when the CLI says the account is in.
func startCursorSignIn(s *signInFlow) error {
path := CursorExecutable()
if path == "" {
return errorf("install Cursor's CLI first: curl https://cursor.com/install -fsS | bash")
}
return runCLISignIn(s, "cursor-agent login", append(os.Environ(), "NO_OPEN_BROWSER=1"), true, nil, func() (string, string, bool) {
forgetCursorStatus()
return askCursorIdentity()
}, path, "login")
}
// cursorVersionFallback is the CLI version said when no install names one.
const cursorVersionFallback = "2026.09.23-86fc751"
var cursorVersionRe = regexp.MustCompile(`^\d{4}\.\d{2}\.\d{2}-[0-9a-f]+$`)
// CursorClientVersion is the cursor-agent the API is told it is talking
// to, "cli-<version>": the installed one, as the API turns away a version
// it no longer supports.
func CursorClientVersion() string {
v := ""
if p := CursorExecutable(); p != "" {
if real, err := filepath.EvalSymlinks(p); err == nil {
if d := filepath.Base(filepath.Dir(real)); cursorVersionRe.MatchString(d) {
v = d
}
}
}
if v == "" {
home, _ := os.UserHomeDir()
dirs := []string{filepath.Join(home, ".local", "share", "cursor-agent", "versions")}
if runtime.GOOS == "windows" {
dirs = append(dirs, filepath.Join(os.Getenv("LOCALAPPDATA"), "cursor-agent", "versions"))
}
for _, dir := range dirs {
es, _ := os.ReadDir(dir)
for _, e := range es {
if n := e.Name(); e.IsDir() && cursorVersionRe.MatchString(n) && n > v {
v = n
}
}
}
}
if v == "" {
v = cursorVersionFallback
}
return "cli-" + v
}
// cursorAuthFile is where cursor-agent keeps its sign-in off a Mac's keychain.
func cursorAuthFile() string {
home, _ := os.UserHomeDir()
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")
}
// readCursorToken is the access token cursor-agent signed in with.
func readCursorToken() string {
if runtime.GOOS == "darwin" {
out, err := proc.Command("security", "find-generic-password", "-s", "cursor-access-token", "-a", "cursor-user", "-w").Output()
if t := strings.TrimSpace(string(out)); err == nil && t != "" {
return t
}
}
b, err := os.ReadFile(cursorAuthFile())
if err != nil {
return ""
}
var a struct {
AccessToken string `json:"accessToken"`
}
json.Unmarshal(b, &a)
return a.AccessToken
}
// tokenExpiry is when a JWT runs out, zero when it doesn't say.
func tokenExpiry(tok string) time.Time {
parts := strings.Split(tok, ".")
if len(parts) != 3 {
return time.Time{}
}
b, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(parts[1], "="))
if err != nil {
return time.Time{}
}
var c struct {
Exp int64 `json:"exp"`
}
if json.Unmarshal(b, &c) != nil || c.Exp == 0 {
return time.Time{}
}
return time.Unix(c.Exp, 0)
}
var cursorRefresh sync.Mutex
// CursorToken is the token to call Cursor's API with. One about to run
// out is renewed by cursor-agent, which does that whenever it runs.
func CursorToken() (string, error) {
tok := readCursorToken()
if exp := tokenExpiry(tok); tok != "" && (exp.IsZero() || time.Until(exp) > 5*time.Minute) {
return tok, nil
}
if path := CursorExecutable(); path != "" {
cursorRefresh.Lock()
if t := readCursorToken(); t != tok && t != "" {
tok = t // renewed while this waited
} else {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
_ = agentCommand(ctx, path, "status").Run()
cancel()
tok = readCursorToken()
}
cursorRefresh.Unlock()
}
if tok == "" {
return "", errors.New("Cursor isn't signed in; sign in from magpie's Providers page or run `cursor-agent login`")
}
if exp := tokenExpiry(tok); !exp.IsZero() && time.Until(exp) <= 0 {
return "", errors.New("Cursor's sign-in has run out; sign in again from magpie's Providers page or run `cursor-agent login`")
}
return tok, nil
}