Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 94 additions & 0 deletions docs/CURSOR_SETUP.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# Cursor Provider Setup

onWatch can track Cursor AI usage quotas automatically by reading auth tokens from the local system.

## How It Works

onWatch detects your Cursor authentication from two sources:

1. **Cursor Desktop SQLite** (preferred) - reads `cursorAuth/accessToken` and `cursorAuth/refreshToken` from `~/Library/Application Support/Cursor/User/globalStorage/state.vscdb`
2. **macOS Keychain** (fallback) - reads `cursor-access-token` and `cursor-refresh-token` services

If both sources exist and the SQLite account is a free-tier plan while the keychain has a different account, onWatch prefers the keychain token (matching openusage behavior).

## Auto-Detection

No manual configuration needed. If Cursor Desktop is installed and you're logged in, onWatch will auto-detect your credentials on startup.

## Manual Configuration

If auto-detection fails, set the `CURSOR_TOKEN` environment variable:

```bash
export CURSOR_TOKEN="your_cursor_access_token"
```

Or add it to your `.env` file:

```
CURSOR_TOKEN=your_cursor_access_token
```

## Token Refresh

Cursor access tokens are short-lived JWTs. onWatch automatically refreshes them:

1. Before each poll, onWatch checks if the token expires within 5 minutes
2. If expiring, it calls the Cursor OAuth refresh endpoint (`https://api2.cursor.sh/oauth/token`)
3. The refreshed access token is written back to Cursor's SQLite database

If the refresh token is invalid (e.g., revoked), onWatch pauses polling and logs an error. You'll need to re-authenticate via the Cursor app.

## Supported Metrics

### Individual Accounts (Pro, Ultra, Free)
- **Total Usage** - percentage of plan limit used
- **Auto Mode** - auto-mode usage percentage
- **API Usage** - API/manual usage percentage
- **Credits** - combined credit grants + Stripe prepaid balance (dollars)
- **On-Demand** - spend limit usage (dollars, if configured)

### Team Accounts
- **Total Usage** - dollar-based spend tracking
- **Auto Mode** - auto-mode usage percentage
- **API Usage** - API manual usage percentage
- **Credits** - combined credit grants + Stripe balance (dollars)
- **On-Demand** - individual or pooled spend limit (dollars)

### Enterprise Accounts
- **Requests** - per-model request counts and limits

## Account Type Detection

onWatch automatically detects your account type:
- **Individual**: `planName` is "pro", "ultra", or "free", or `spendLimitUsage.limitType` is "user"
- **Team**: `planName` is "team"/"business", or `limitType` is "team", or `pooledLimit` is present
- **Enterprise**: `planName` is "enterprise" (uses request-based `/api/usage` instead of Connect RPC)

## API Endpoints Used

| Endpoint | Purpose |
|----------|---------|
| `POST /aiserver.v1.DashboardService/GetCurrentPeriodUsage` | Current usage metrics (Connect RPC) |
| `POST /aiserver.v1.DashboardService/GetPlanInfo` | Plan name and pricing (Connect RPC) |
| `POST /aiserver.v1.DashboardService/GetCreditGrantsBalance` | Credit grants balance (Connect RPC) |
| `GET https://cursor.com/api/auth/stripe` | Stripe prepaid balance (cookie auth) |
| `GET https://cursor.com/api/usage` | Request-based usage for enterprise (cookie auth) |
| `POST https://api2.cursor.sh/oauth/token` | OAuth token refresh |

## Troubleshooting

### "unauthorized - invalid or expired token"
onWatch will attempt to refresh your token automatically. If refresh fails, try:
1. Open Cursor Desktop and make sure you're logged in
2. Restart onWatch to trigger fresh token detection

### "session expired - re-authentication required"
Your refresh token has been revoked. You need to:
1. Re-authenticate in the Cursor app
2. Restart onWatch

### Free-tier account detected incorrectly
If onWatch detects your free-tier SQLite account instead of your paid keychain account, try:
1. Logging out of the free-tier account in Cursor Desktop
2. Restarting onWatch
258 changes: 258 additions & 0 deletions internal/agent/cursor_agent.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,258 @@
package agent

import (
"context"
"errors"
"log/slog"
"time"

"github.com/onllm-dev/onwatch/v2/internal/api"
"github.com/onllm-dev/onwatch/v2/internal/notify"
"github.com/onllm-dev/onwatch/v2/internal/store"
"github.com/onllm-dev/onwatch/v2/internal/tracker"
)

type CursorTokenRefreshFunc func() string
type CursorCredentialsRefreshFunc func() *api.CursorCredentials
type CursorTokenSaveFunc func(accessToken, refreshToken string) error

const cursorMaxAuthFailures = 3
const cursorRefreshBuffer = 5 * time.Minute

type CursorAgent struct {
client *api.CursorClient
store *store.Store
tracker *tracker.CursorTracker
interval time.Duration
logger *slog.Logger
sm *SessionManager
tokenRefresh CursorTokenRefreshFunc
credentialsRefresh CursorCredentialsRefreshFunc
tokenSave CursorTokenSaveFunc
lastToken string
notifier *notify.NotificationEngine
pollingCheck func() bool

authFailCount int
authPaused bool
lastFailedToken string
}

func (a *CursorAgent) SetPollingCheck(fn func() bool) {
a.pollingCheck = fn
}

func (a *CursorAgent) SetNotifier(n *notify.NotificationEngine) {
a.notifier = n
}

func (a *CursorAgent) SetTokenRefresh(fn CursorTokenRefreshFunc) {
a.tokenRefresh = fn
}

func (a *CursorAgent) SetCredentialsRefresh(fn CursorCredentialsRefreshFunc) {
a.credentialsRefresh = fn
}

func (a *CursorAgent) SetTokenSave(fn CursorTokenSaveFunc) {
a.tokenSave = fn
}

func NewCursorAgent(client *api.CursorClient, store *store.Store, tracker *tracker.CursorTracker, interval time.Duration, logger *slog.Logger, sm *SessionManager) *CursorAgent {
if logger == nil {
logger = slog.Default()
}
return &CursorAgent{
client: client,
store: store,
tracker: tracker,
interval: interval,
logger: logger,
sm: sm,
}
}

func (a *CursorAgent) Run(ctx context.Context) error {
a.logger.Info("Cursor agent started", "interval", a.interval)

defer func() {
if a.sm != nil {
a.sm.Close()
}
a.logger.Info("Cursor agent stopped")
}()

a.poll(ctx)

ticker := time.NewTicker(a.interval)
defer ticker.Stop()

for {
select {
case <-ticker.C:
a.poll(ctx)
case <-ctx.Done():
return nil
}
}
}

func (a *CursorAgent) poll(ctx context.Context) {
if a.pollingCheck != nil && !a.pollingCheck() {
return
}

if a.authPaused {
if a.tokenRefresh != nil {
newToken := a.tokenRefresh()
if newToken != "" && newToken != a.lastFailedToken {
a.authPaused = false
a.authFailCount = 0
a.client.SetToken(newToken)
a.logger.Info("Cursor auth recovered, resuming polling")
}
}
if a.authPaused {
return
}
}

refreshedThisPoll := false
if a.credentialsRefresh != nil {
creds := a.credentialsRefresh()
if creds != nil && api.NeedsCursorRefresh(creds) && creds.RefreshToken != "" {
a.logger.Info("Cursor token expiring soon, refreshing", "expires_in", creds.ExpiresIn.Round(time.Minute))
refreshedThisPoll = a.refreshToken(ctx, creds.RefreshToken)
}
}

if !refreshedThisPoll && a.tokenRefresh != nil {
newToken := a.tokenRefresh()
if newToken != "" && newToken != a.lastFailedToken && newToken != a.lastToken {
a.client.SetToken(newToken)
a.lastToken = newToken
}
}

snapshot, err := a.client.FetchQuotas(ctx)
if err != nil {
if ctx.Err() != nil {
return
}

if api.IsCursorAuthError(err) {
a.authFailCount++
a.lastFailedToken = a.client.GetToken()
a.logger.Warn("Cursor auth failure",
"error", err,
"fail_count", a.authFailCount,
)

if a.authFailCount >= cursorMaxAuthFailures {
if a.credentialsRefresh != nil {
creds := a.credentialsRefresh()
if creds != nil && creds.RefreshToken != "" {
a.logger.Info("Cursor: attempting token refresh due to auth failure")
if a.refreshToken(ctx, creds.RefreshToken) {
snapshot, err = a.client.FetchQuotas(ctx)
if err == nil {
a.authFailCount = 0
a.authPaused = false
goto processSnapshot
}
}
}
}
a.authPaused = true
a.logger.Warn("Cursor polling paused due to auth failures",
"fail_count", a.authFailCount,
)
}
return
}

if api.IsCursorSessionExpired(err) {
a.logger.Error("Cursor session expired - user must re-authenticate", "error", err)
a.authPaused = true
return
}

a.logger.Error("Failed to fetch Cursor quotas", "error", err)
return
}

a.authFailCount = 0
a.authPaused = false

processSnapshot:
if _, err := a.store.InsertCursorSnapshot(snapshot); err != nil {
a.logger.Error("Failed to insert Cursor snapshot", "error", err)
}

if err := a.tracker.Process(snapshot); err != nil {
a.logger.Error("Cursor tracker processing failed", "error", err)
}

if a.notifier != nil {
for _, q := range snapshot.Quotas {
a.notifier.Check(notify.QuotaStatus{
Provider: "cursor",
QuotaKey: q.Name,
Utilization: q.Utilization,
Limit: q.Limit,
})
}
}

if a.sm != nil {
var values []float64
for _, q := range snapshot.Quotas {
values = append(values, q.Utilization)
}
a.sm.ReportPoll(values)
}

a.logger.Info("Cursor poll complete",
"account_type", snapshot.AccountType,
"plan_name", snapshot.PlanName,
"quota_count", len(snapshot.Quotas),
)
}

func (a *CursorAgent) refreshToken(ctx context.Context, refreshToken string) bool {
a.logger.Info("Cursor: refreshing OAuth token")

oauthResp, err := api.RefreshCursorToken(ctx, refreshToken)
if err != nil {
if errors.Is(err, api.ErrCursorSessionExpired) {
a.logger.Error("Cursor session expired during refresh - user must re-authenticate")
a.authPaused = true
} else {
a.logger.Warn("Cursor token refresh failed", "error", err)
}
return false
}

return a.applyRefreshedCredentials(oauthResp)
}

func (a *CursorAgent) applyRefreshedCredentials(oauthResp *api.CursorOAuthResponse) bool {
if oauthResp == nil || oauthResp.AccessToken == "" {
return false
}

saveFn := a.tokenSave
if saveFn == nil {
saveFn = api.WriteCursorCredentials
}
if err := saveFn(oauthResp.AccessToken, oauthResp.RefreshToken); err != nil {
a.logger.Error("Failed to save refreshed Cursor credentials", "error", err)
return false
}

a.client.SetToken(oauthResp.AccessToken)
a.lastToken = oauthResp.AccessToken
a.lastFailedToken = ""
a.logger.Info("Cursor token refreshed successfully")
return true
}
Loading
Loading