diff --git a/docs/CURSOR_SETUP.md b/docs/CURSOR_SETUP.md new file mode 100644 index 0000000..e710351 --- /dev/null +++ b/docs/CURSOR_SETUP.md @@ -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 \ No newline at end of file diff --git a/internal/agent/cursor_agent.go b/internal/agent/cursor_agent.go new file mode 100644 index 0000000..a8d7dbb --- /dev/null +++ b/internal/agent/cursor_agent.go @@ -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 +} diff --git a/internal/agent/cursor_agent_test.go b/internal/agent/cursor_agent_test.go new file mode 100644 index 0000000..9395090 --- /dev/null +++ b/internal/agent/cursor_agent_test.go @@ -0,0 +1,223 @@ +package agent + +import ( + "context" + "errors" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/onllm-dev/onwatch/v2/internal/api" + "github.com/onllm-dev/onwatch/v2/internal/store" + "github.com/onllm-dev/onwatch/v2/internal/tracker" +) + +func newTestCursorDeps(t *testing.T) (*store.Store, *tracker.CursorTracker, *SessionManager) { + t.Helper() + s, err := store.New(":memory:") + if err != nil { + t.Fatalf("store.New: %v", err) + } + t.Cleanup(func() { s.Close() }) + tr := tracker.NewCursorTracker(s, slog.Default()) + sm := NewSessionManager(s, "cursor", 5*time.Minute, slog.Default()) + return s, tr, sm +} + +func TestNewCursorAgent(t *testing.T) { + s, tr, sm := newTestCursorDeps(t) + client := api.NewCursorClient("test_token", slog.Default()) + agent := NewCursorAgent(client, s, tr, 30*time.Second, slog.Default(), sm) + if agent == nil { + t.Fatal("NewCursorAgent returned nil") + } +} + +func TestCursorAgent_SetTokenRefresh(t *testing.T) { + s, tr, sm := newTestCursorDeps(t) + client := api.NewCursorClient("test_token", slog.Default()) + agent := NewCursorAgent(client, s, tr, 30*time.Second, slog.Default(), sm) + + agent.SetTokenRefresh(func() string { + return "refreshed_token" + }) +} + +func TestCursorAgent_SetNotifier(t *testing.T) { + s, tr, sm := newTestCursorDeps(t) + client := api.NewCursorClient("test_token", slog.Default()) + agent := NewCursorAgent(client, s, tr, 30*time.Second, slog.Default(), sm) + + agent.SetNotifier(nil) // Should not panic +} + +func TestCursorAgent_SetPollingCheck(t *testing.T) { + s, tr, sm := newTestCursorDeps(t) + client := api.NewCursorClient("test_token", slog.Default()) + agent := NewCursorAgent(client, s, tr, 30*time.Second, slog.Default(), sm) + + agent.SetPollingCheck(func() bool { return true }) +} + +func TestCursorAgent_PollWithMockServer(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/aiserver.v1.DashboardService/GetCurrentPeriodUsage": + w.Write([]byte(`{ + "billingCycleStart": "1768399334000", + "billingCycleEnd": "1771077734000", + "planUsage": { + "totalSpend": 5000, + "remaining": 35000, + "limit": 40000, + "totalPercentUsed": 12.5, + "autoPercentUsed": 3.0, + "apiPercentUsed": 9.5 + }, + "enabled": true + }`)) + case "/aiserver.v1.DashboardService/GetPlanInfo": + w.Write([]byte(`{ + "planInfo": { + "planName": "Pro", + "includedAmountCents": 2000, + "price": "$20/mo" + } + }`)) + case "/aiserver.v1.DashboardService/GetCreditGrantsBalance": + w.Write([]byte(`{"hasCreditGrants": false, "totalCents": "0", "usedCents": "0"}`)) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + s, tr, sm := newTestCursorDeps(t) + client := api.NewCursorClient("test_token", slog.Default(), api.WithCursorBaseURL(server.URL)) + agent := NewCursorAgent(client, s, tr, 30*time.Second, slog.Default(), sm) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // Poll once manually + agent.poll(ctx) + + // Verify snapshot was stored + latest, err := s.QueryLatestCursor() + if err != nil { + t.Fatalf("QueryLatestCursor: %v", err) + } + if latest == nil { + t.Fatal("Expected snapshot to be stored") + } + if latest.AccountType != api.CursorAccountIndividual { + t.Errorf("AccountType = %q, want %q", latest.AccountType, api.CursorAccountIndividual) + } + if latest.PlanName != "Pro" { + t.Errorf("PlanName = %q, want %q", latest.PlanName, "Pro") + } +} + +func TestCursorAgent_PollDisabled(t *testing.T) { + s, tr, sm := newTestCursorDeps(t) + client := api.NewCursorClient("test_token", slog.Default()) + agent := NewCursorAgent(client, s, tr, 30*time.Second, slog.Default(), sm) + + // Set polling check to return false + agent.SetPollingCheck(func() bool { return false }) + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + // Poll should be skipped + agent.poll(ctx) + + // No snapshot should be stored + latest, err := s.QueryLatestCursor() + if err != nil { + t.Fatalf("QueryLatestCursor: %v", err) + } + if latest != nil { + t.Error("Expected nil snapshot when polling is disabled") + } +} + +func TestCursorAgent_SetCredentialsRefresh(t *testing.T) { + s, tr, sm := newTestCursorDeps(t) + client := api.NewCursorClient("test_token", slog.Default()) + agent := NewCursorAgent(client, s, tr, 30*time.Second, slog.Default(), sm) + + agent.SetCredentialsRefresh(func() *api.CursorCredentials { + return &api.CursorCredentials{ + AccessToken: "test_access", + RefreshToken: "test_refresh", + ExpiresAt: time.Now().Add(30 * time.Minute), + ExpiresIn: 30 * time.Minute, + Source: "sqlite", + } + }) +} + +func TestCursorAgent_ApplyRefreshedCredentials_SavesTokens(t *testing.T) { + s, tr, sm := newTestCursorDeps(t) + client := api.NewCursorClient("expired_token", slog.Default()) + agent := NewCursorAgent(client, s, tr, 30*time.Second, slog.Default(), sm) + + var savedAccess, savedRefresh string + agent.SetTokenSave(func(accessToken, refreshToken string) error { + savedAccess = accessToken + savedRefresh = refreshToken + return nil + }) + + ok := agent.applyRefreshedCredentials(&api.CursorOAuthResponse{ + AccessToken: "fresh_access_token", + RefreshToken: "fresh_refresh_token", + }) + if !ok { + t.Fatal("applyRefreshedCredentials returned false") + } + if savedAccess != "fresh_access_token" { + t.Fatalf("saved access token = %q, want fresh_access_token", savedAccess) + } + if savedRefresh != "fresh_refresh_token" { + t.Fatalf("saved refresh token = %q, want fresh_refresh_token", savedRefresh) + } + if got := client.GetToken(); got != "fresh_access_token" { + t.Fatalf("client token = %q, want fresh_access_token", got) + } + if agent.lastToken != "fresh_access_token" { + t.Fatalf("lastToken = %q, want fresh_access_token", agent.lastToken) + } +} + +func TestCursorAgent_ApplyRefreshedCredentials_FailsWhenSaveFails(t *testing.T) { + s, tr, sm := newTestCursorDeps(t) + client := api.NewCursorClient("expired_token", slog.Default()) + agent := NewCursorAgent(client, s, tr, 30*time.Second, slog.Default(), sm) + agent.lastFailedToken = "expired_token" + + agent.SetTokenSave(func(accessToken, refreshToken string) error { + return errors.New("save failed") + }) + + ok := agent.applyRefreshedCredentials(&api.CursorOAuthResponse{ + AccessToken: "fresh_access_token", + RefreshToken: "fresh_refresh_token", + }) + if ok { + t.Fatal("applyRefreshedCredentials returned true despite save failure") + } + if got := client.GetToken(); got != "expired_token" { + t.Fatalf("client token = %q, want expired_token", got) + } + if agent.lastToken != "" { + t.Fatalf("lastToken = %q, want empty", agent.lastToken) + } + if agent.lastFailedToken != "expired_token" { + t.Fatalf("lastFailedToken = %q, want expired_token", agent.lastFailedToken) + } +} diff --git a/internal/api/cursor_client.go b/internal/api/cursor_client.go new file mode 100644 index 0000000..c06afc1 --- /dev/null +++ b/internal/api/cursor_client.go @@ -0,0 +1,459 @@ +package api + +import ( + "bytes" + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "net/url" + "sync" + "time" +) + +var ( + ErrCursorUnauthorized = errors.New("cursor: unauthorized - invalid or expired token") + ErrCursorForbidden = errors.New("cursor: forbidden - access denied") + ErrCursorServerError = errors.New("cursor: server error") + ErrCursorNetworkError = errors.New("cursor: network error") + ErrCursorInvalidResponse = errors.New("cursor: invalid response") + ErrCursorSessionExpired = errors.New("cursor: session expired - re-authentication required") +) + +const ( + CursorBaseURL = "https://api2.cursor.sh" + CursorClientID = "KbZUR41cY7W6zRSdpSUJ7I7mLYBKOCmB" + CursorRefreshBuffer = 5 * time.Minute +) + +var cursorOAuthURL = "https://api2.cursor.sh/oauth/token" + +type CursorClient struct { + httpClient *http.Client + token string + tokenMu sync.RWMutex + baseURL string + logger *slog.Logger +} + +type CursorOption func(*CursorClient) + +func WithCursorBaseURL(url string) CursorOption { + return func(c *CursorClient) { + c.baseURL = url + } +} + +func WithCursorTimeout(timeout time.Duration) CursorOption { + return func(c *CursorClient) { + c.httpClient.Timeout = timeout + } +} + +func NewCursorClient(token string, logger *slog.Logger, opts ...CursorOption) *CursorClient { + client := &CursorClient{ + httpClient: &http.Client{ + Timeout: 30 * time.Second, + Transport: &http.Transport{ + MaxIdleConns: 1, + MaxIdleConnsPerHost: 1, + ResponseHeaderTimeout: 30 * time.Second, + IdleConnTimeout: 30 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + ForceAttemptHTTP2: true, + }, + }, + token: token, + baseURL: CursorBaseURL, + logger: logger, + } + + for _, opt := range opts { + opt(client) + } + + return client +} + +func (c *CursorClient) SetToken(token string) { + c.tokenMu.Lock() + defer c.tokenMu.Unlock() + c.token = token +} + +func (c *CursorClient) getToken() string { + c.tokenMu.RLock() + defer c.tokenMu.RUnlock() + return c.token +} + +func (c *CursorClient) GetToken() string { + return c.getToken() +} + +func (c *CursorClient) FetchQuotas(ctx context.Context) (*CursorSnapshot, error) { + token := c.getToken() + + usage, err := c.fetchCurrentPeriodUsage(ctx, token) + if err != nil { + return nil, fmt.Errorf("cursor: fetch usage: %w", err) + } + + planInfo, err := c.fetchPlanInfo(ctx, token) + if err != nil { + c.logger.Warn("cursor: failed to fetch plan info, continuing without", "error", err) + } + + planName := "" + if planInfo != nil { + planName = planInfo.PlanInfo.PlanName + } + normalizedPlan := NormalizeCursorPlanName(planName) + + var requestUsage *CursorRequestUsageResponse + if shouldFetchCursorRequestBasedUsage(usage, normalizedPlan) { + ru, err := c.fetchRequestBasedUsage(ctx, token) + if err != nil { + c.logger.Warn("cursor: failed to fetch request-based usage", "error", err) + } else { + requestUsage = ru + } + } + useRequestBased := shouldUseCursorRequestBasedUsage(usage, requestUsage) + + var creditGrants *CursorCreditGrantsResponse + var stripeResp *CursorStripeResponse + + if !useRequestBased { + cg, err := c.fetchCreditGrants(ctx, token) + if err != nil { + c.logger.Debug("cursor: failed to fetch credit grants", "error", err) + } else { + creditGrants = cg + } + + sr, err := c.fetchStripeBalance(ctx, token) + if err != nil { + c.logger.Debug("cursor: failed to fetch Stripe balance", "error", err) + } else { + stripeResp = sr + } + } + + accountType := DetermineCursorAccountType(planName, usage, useRequestBased) + snapshot := ToCursorSnapshot(usage, planInfo, creditGrants, stripeResp, requestUsage, useRequestBased) + + c.logger.Debug("cursor: quotas fetched", + "account_type", accountType, + "plan_name", planName, + "quota_count", len(snapshot.Quotas), + ) + + return snapshot, nil +} + +func shouldFetchCursorRequestBasedUsage(usage *CursorUsageResponse, normalizedPlan string) bool { + if usage == nil || !usage.Enabled { + return false + } + + hasPlanUsage := usage.PlanUsage != nil + hasPlanUsageLimit := hasPlanUsage && usage.PlanUsage.Limit > 0 + if hasPlanUsageLimit { + return false + } + + return normalizedPlan == "" || normalizedPlan == "enterprise" || normalizedPlan == "team" +} + +func shouldUseCursorRequestBasedUsage(usage *CursorUsageResponse, requestUsage *CursorRequestUsageResponse) bool { + if usage == nil || !usage.Enabled || requestUsage == nil || len(requestUsage.Models) == 0 { + return false + } + + return usage.PlanUsage == nil || usage.PlanUsage.Limit <= 0 +} + +func (c *CursorClient) fetchCurrentPeriodUsage(ctx context.Context, token string) (*CursorUsageResponse, error) { + body, err := c.connectPost(ctx, token, "/aiserver.v1.DashboardService/GetCurrentPeriodUsage", nil) + if err != nil { + return nil, err + } + return ParseCursorUsageResponse(body) +} + +func (c *CursorClient) fetchPlanInfo(ctx context.Context, token string) (*CursorPlanInfoResponse, error) { + body, err := c.connectPost(ctx, token, "/aiserver.v1.DashboardService/GetPlanInfo", nil) + if err != nil { + return nil, err + } + return ParseCursorPlanInfoResponse(body) +} + +func (c *CursorClient) fetchCreditGrants(ctx context.Context, token string) (*CursorCreditGrantsResponse, error) { + body, err := c.connectPost(ctx, token, "/aiserver.v1.DashboardService/GetCreditGrantsBalance", nil) + if err != nil { + return nil, err + } + return ParseCursorCreditGrantsResponse(body) +} + +func (c *CursorClient) fetchStripeBalance(ctx context.Context, token string) (*CursorStripeResponse, error) { + userID := ExtractJWTSubject(token) + if userID == "" { + c.logger.Debug("cursor: cannot extract user ID from token for Stripe endpoint") + return nil, nil + } + + sessionToken := url.QueryEscape(userID) + "%3A%3A" + url.QueryEscape(token) + + reqCtx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, "https://cursor.com/api/auth/stripe", nil) + if err != nil { + return nil, fmt.Errorf("cursor: create Stripe request: %w", err) + } + req.Header.Set("Cookie", "WorkosCursorSessionToken="+sessionToken) + + resp, err := c.httpClient.Do(req) + if err != nil { + if ctx.Err() != nil { + return nil, ctx.Err() + } + return nil, fmt.Errorf("%w: %v", ErrCursorNetworkError, err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<16)) + if err != nil { + return nil, fmt.Errorf("%w: reading Stripe body: %v", ErrCursorInvalidResponse, err) + } + + if resp.StatusCode != http.StatusOK { + c.logger.Debug("cursor: Stripe endpoint returned non-200", "status", resp.StatusCode) + return nil, nil + } + + return ParseCursorStripeResponse(body) +} + +func (c *CursorClient) fetchRequestBasedUsage(ctx context.Context, token string) (*CursorRequestUsageResponse, error) { + userID := ExtractJWTSubject(token) + if userID == "" { + return nil, fmt.Errorf("cursor: cannot extract user ID from token for request-based usage") + } + + sessionToken := url.QueryEscape(userID) + "%3A%3A" + url.QueryEscape(token) + + reqCtx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + + reqURL := fmt.Sprintf("https://cursor.com/api/usage?user=%s", url.QueryEscape(userID)) + req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, reqURL, nil) + if err != nil { + return nil, fmt.Errorf("cursor: create request-based usage request: %w", err) + } + req.Header.Set("Cookie", "WorkosCursorSessionToken="+sessionToken) + + resp, err := c.httpClient.Do(req) + if err != nil { + if ctx.Err() != nil { + return nil, ctx.Err() + } + return nil, fmt.Errorf("%w: %v", ErrCursorNetworkError, err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<16)) + if err != nil { + return nil, fmt.Errorf("%w: reading usage body: %v", ErrCursorInvalidResponse, err) + } + + switch { + case resp.StatusCode == http.StatusOK: + case resp.StatusCode == http.StatusUnauthorized: + return nil, ErrCursorUnauthorized + case resp.StatusCode == http.StatusForbidden: + return nil, ErrCursorForbidden + case resp.StatusCode >= 500: + return nil, ErrCursorServerError + default: + return nil, fmt.Errorf("cursor: unexpected status %d for usage endpoint", resp.StatusCode) + } + + return ParseCursorRequestUsageResponse(body) +} + +func (c *CursorClient) connectPost(ctx context.Context, token, path string, payload interface{}) ([]byte, error) { + var bodyReader io.Reader + if payload != nil { + data, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("cursor: marshal request: %w", err) + } + bodyReader = bytes.NewReader(data) + } else { + bodyReader = bytes.NewReader([]byte("{}")) + } + + reqCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, c.baseURL+path, bodyReader) + if err != nil { + return nil, fmt.Errorf("cursor: create request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Connect-Protocol-Version", "1") + + c.logger.Debug("cursor: Connect RPC request", "path", path, "token", redactCursorToken(token)) + + resp, err := c.httpClient.Do(req) + if err != nil { + if ctx.Err() != nil { + return nil, ctx.Err() + } + return nil, fmt.Errorf("%w: %v", ErrCursorNetworkError, err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<16)) + if err != nil { + return nil, fmt.Errorf("%w: reading body: %v", ErrCursorInvalidResponse, err) + } + + switch { + case resp.StatusCode == http.StatusOK: + return body, nil + case resp.StatusCode == http.StatusUnauthorized: + return nil, ErrCursorUnauthorized + case resp.StatusCode == http.StatusForbidden: + return nil, ErrCursorForbidden + case resp.StatusCode >= 500: + return nil, ErrCursorServerError + default: + return nil, fmt.Errorf("cursor: unexpected status %d for %s", resp.StatusCode, path) + } +} + +func RefreshCursorToken(ctx context.Context, refreshToken string) (*CursorOAuthResponse, error) { + reqBody := map[string]string{ + "grant_type": "refresh_token", + "client_id": CursorClientID, + "refresh_token": refreshToken, + } + + data, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("cursor: marshal refresh request: %w", err) + } + + reqCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, cursorOAuthURL, bytes.NewReader(data)) + if err != nil { + return nil, fmt.Errorf("cursor: create refresh request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + if ctx.Err() != nil { + return nil, ctx.Err() + } + return nil, fmt.Errorf("cursor: refresh network error: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<16)) + if err != nil { + return nil, fmt.Errorf("cursor: read refresh response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("cursor: refresh failed with HTTP %d: %s", resp.StatusCode, string(body)) + } + + oauthResp, err := ParseCursorOAuthResponse(body) + if err != nil { + return nil, fmt.Errorf("cursor: parse refresh response: %w", err) + } + + if oauthResp.ShouldLogout { + return nil, ErrCursorSessionExpired + } + + if oauthResp.AccessToken == "" { + return nil, fmt.Errorf("cursor: refresh returned empty access token") + } + + return oauthResp, nil +} + +func redactCursorToken(token string) string { + if token == "" { + return "(empty)" + } + if len(token) < 8 { + return "***...***" + } + return token[:4] + "***...***" + token[len(token)-3:] +} + +func NeedsCursorRefresh(creds *CursorCredentials) bool { + if creds == nil || creds.AccessToken == "" { + return true + } + if creds.ExpiresAt.IsZero() { + return false + } + return creds.ExpiresIn < CursorRefreshBuffer +} + +// IsCursorAuthError returns true if the error indicates an auth failure (401/403). +func IsCursorAuthError(err error) bool { + return errors.Is(err, ErrCursorUnauthorized) || errors.Is(err, ErrCursorForbidden) +} + +// IsCursorSessionExpired returns true if the error indicates the session is expired. +func IsCursorSessionExpired(err error) bool { + return errors.Is(err, ErrCursorSessionExpired) +} + +// WriteCursorTokenToSQLite writes a token back to Cursor's state.vscdb. +func WriteCursorTokenToSQLite(dbPath, key, value string) error { + // This needs to use database/sql with the modernc.org/sqlite driver + // to write back to Cursor's state.vscdb. We open it as a separate connection. + // We use parameterized queries for safety. + db, err := openCursorStateDB(dbPath) + if err != nil { + return err + } + defer db.Close() + + _, err = db.Exec( + "INSERT OR REPLACE INTO ItemTable (key, value) VALUES (?, ?)", + key, value, + ) + return err +} + +func openCursorStateDB(dbPath string) (*sql.DB, error) { + //nolint:rowserrcheck + db, err := sql.Open("sqlite", dbPath+"?mode=rw") + if err != nil { + return nil, fmt.Errorf("cursor: open state db: %w", err) + } + db.SetMaxOpenConns(1) + return db, nil +} diff --git a/internal/api/cursor_client_test.go b/internal/api/cursor_client_test.go new file mode 100644 index 0000000..424493a --- /dev/null +++ b/internal/api/cursor_client_test.go @@ -0,0 +1,351 @@ +package api + +import ( + "context" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestNewCursorClient(t *testing.T) { + logger := slog.Default() + client := NewCursorClient("test_token", logger) + if client == nil { + t.Fatal("NewCursorClient returned nil") + } + if client.baseURL != CursorBaseURL { + t.Errorf("baseURL = %q, want %q", client.baseURL, CursorBaseURL) + } +} + +func TestNewCursorClient_WithOptions(t *testing.T) { + logger := slog.Default() + client := NewCursorClient("test_token", logger, + WithCursorBaseURL("http://localhost:1234"), + WithCursorTimeout(5*time.Second), + ) + if client.baseURL != "http://localhost:1234" { + t.Errorf("baseURL = %q, want custom", client.baseURL) + } +} + +func TestCursorClient_SetToken(t *testing.T) { + logger := slog.Default() + client := NewCursorClient("initial_token", logger) + + client.SetToken("new_token") + if client.getToken() != "new_token" { + t.Errorf("getToken() = %q, want %q", client.getToken(), "new_token") + } +} + +func TestCursorClient_FetchQuotas_IndividualSuccess(t *testing.T) { + usageHandled := false + planInfoHandled := false + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/aiserver.v1.DashboardService/GetCurrentPeriodUsage" { + usageHandled = true + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{ + "billingCycleStart": "1768399334000", + "billingCycleEnd": "1771077734000", + "planUsage": { + "totalSpend": 5000, + "remaining": 35000, + "limit": 40000, + "totalPercentUsed": 12.5, + "autoPercentUsed": 3.0, + "apiPercentUsed": 9.5 + }, + "enabled": true + }`)) + return + } + if r.URL.Path == "/aiserver.v1.DashboardService/GetPlanInfo" { + planInfoHandled = true + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{ + "planInfo": { + "planName": "Pro", + "includedAmountCents": 2000, + "price": "$20/mo" + } + }`)) + return + } + if r.URL.Path == "/aiserver.v1.DashboardService/GetCreditGrantsBalance" { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{ + "hasCreditGrants": true, + "totalCents": "5000", + "usedCents": "2000" + }`)) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + logger := slog.Default() + client := NewCursorClient("test_token", logger, WithCursorBaseURL(server.URL)) + snapshot, err := client.FetchQuotas(context.Background()) + if err != nil { + t.Fatalf("FetchQuotas: %v", err) + } + + if !usageHandled { + t.Error("usage endpoint not called") + } + if !planInfoHandled { + t.Error("plan info endpoint not called") + } + if snapshot.AccountType != CursorAccountIndividual { + t.Errorf("AccountType = %q, want %q", snapshot.AccountType, CursorAccountIndividual) + } + if snapshot.PlanName != "Pro" { + t.Errorf("PlanName = %q, want %q", snapshot.PlanName, "Pro") + } + if len(snapshot.Quotas) == 0 { + t.Error("Expected at least one quota") + } +} + +func TestCursorClient_FetchQuotas_Unauthorized(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + })) + defer server.Close() + + logger := slog.Default() + client := NewCursorClient("bad_token", logger, WithCursorBaseURL(server.URL)) + _, err := client.FetchQuotas(context.Background()) + if err == nil { + t.Error("Expected error for 401") + } + if !IsCursorAuthError(err) { + t.Errorf("Expected auth error, got %v", err) + } +} + +func TestCursorClient_FetchQuotas_ServerError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + logger := slog.Default() + client := NewCursorClient("test_token", logger, WithCursorBaseURL(server.URL)) + _, err := client.FetchQuotas(context.Background()) + if err == nil { + t.Error("Expected error for 500") + } +} + +func TestCursorClient_ConnectRPCHeaders(t *testing.T) { + var receivedHeaders http.Header + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedHeaders = r.Header + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{ + "billingCycleStart": "1768399334000", + "billingCycleEnd": "1771077734000", + "planUsage": { + "totalSpend": 5000, + "remaining": 35000, + "limit": 40000, + "totalPercentUsed": 12.5 + }, + "enabled": true + }`)) + })) + defer server.Close() + + logger := slog.Default() + client := NewCursorClient("test_bearer_token", logger, WithCursorBaseURL(server.URL)) + _, _ = client.FetchQuotas(context.Background()) + + if receivedHeaders.Get("Authorization") != "Bearer test_bearer_token" { + t.Errorf("Authorization = %q, want %q", receivedHeaders.Get("Authorization"), "Bearer test_bearer_token") + } + if receivedHeaders.Get("Connect-Protocol-Version") != "1" { + t.Errorf("Connect-Protocol-Version = %q, want %q", receivedHeaders.Get("Connect-Protocol-Version"), "1") + } + if receivedHeaders.Get("Content-Type") != "application/json" { + t.Errorf("Content-Type = %q, want application/json", receivedHeaders.Get("Content-Type")) + } +} + +func TestCursorClient_RedirectToken(t *testing.T) { + logger := slog.Default() + client := NewCursorClient("initial", logger) + + if client.getToken() != "initial" { + t.Errorf("Initial token = %q, want %q", client.getToken(), "initial") + } + + client.SetToken("updated") + if client.getToken() != "updated" { + t.Errorf("Updated token = %q, want %q", client.getToken(), "updated") + } +} + +func TestRedactCursorToken(t *testing.T) { + tests := []struct { + token string + expected string + }{ + {"", "(empty)"}, + {"abc", "***...***"}, + {"eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9", "eyJh***...***CJ9"}, + } + + for _, tt := range tests { + got := redactCursorToken(tt.token) + if got != tt.expected { + t.Errorf("redactCursorToken(%q) = %q, want %q", tt.token, got, tt.expected) + } + } +} + +func TestRefreshCursorToken_Success(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + t.Errorf("Expected POST, got %s", r.Method) + } + if r.Header.Get("Content-Type") != "application/json" { + t.Errorf("Content-Type = %q, want application/json", r.Header.Get("Content-Type")) + } + + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{ + "access_token": "new_access_token", + "id_token": "new_id_token", + "refresh_token": "new_refresh_token", + "shouldLogout": false + }`)) + })) + defer server.Close() + + cursorOAuthURL = server.URL + defer func() { cursorOAuthURL = "https://api2.cursor.sh/oauth/token" }() + + resp, err := RefreshCursorToken(context.Background(), "old_refresh_token") + if err != nil { + t.Fatalf("RefreshCursorToken: %v", err) + } + if resp.AccessToken != "new_access_token" { + t.Errorf("AccessToken = %q, want %q", resp.AccessToken, "new_access_token") + } + if resp.RefreshToken != "new_refresh_token" { + t.Errorf("RefreshToken = %q, want %q", resp.RefreshToken, "new_refresh_token") + } + if resp.ShouldLogout { + t.Error("ShouldLogout should be false") + } +} + +func TestRefreshCursorToken_SessionExpired(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{ + "access_token": "", + "id_token": "", + "shouldLogout": true + }`)) + })) + defer server.Close() + + cursorOAuthURL = server.URL + defer func() { cursorOAuthURL = "https://api2.cursor.sh/oauth/token" }() + + _, err := RefreshCursorToken(context.Background(), "expired_refresh_token") + if err == nil { + t.Error("Expected error for shouldLogout=true") + } + if !IsCursorSessionExpired(err) { + t.Errorf("Expected ErrCursorSessionExpired, got %v", err) + } +} + +func TestRefreshCursorToken_HTTPError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(`{"error":"invalid_request"}`)) + })) + defer server.Close() + + cursorOAuthURL = server.URL + defer func() { cursorOAuthURL = "https://api2.cursor.sh/oauth/token" }() + + _, err := RefreshCursorToken(context.Background(), "bad_token") + if err == nil { + t.Error("Expected error for HTTP 400") + } +} + +func TestShouldFetchCursorRequestBasedUsage(t *testing.T) { + tests := []struct { + name string + usage *CursorUsageResponse + normalizedPlan string + want bool + }{ + { + name: "missing plan info still attempts request-based usage", + usage: &CursorUsageResponse{ + Enabled: true, + PlanUsage: &CursorPlanUsage{Limit: 0}, + }, + normalizedPlan: "", + want: true, + }, + { + name: "team account with zero plan limit attempts request-based usage", + usage: &CursorUsageResponse{ + Enabled: true, + PlanUsage: &CursorPlanUsage{Limit: 0}, + }, + normalizedPlan: "team", + want: true, + }, + { + name: "standard usage skips request-based endpoint", + usage: &CursorUsageResponse{ + Enabled: true, + PlanUsage: &CursorPlanUsage{Limit: 100}, + }, + normalizedPlan: "", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := shouldFetchCursorRequestBasedUsage(tt.usage, tt.normalizedPlan); got != tt.want { + t.Fatalf("shouldFetchCursorRequestBasedUsage() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestShouldUseCursorRequestBasedUsage(t *testing.T) { + usage := &CursorUsageResponse{ + Enabled: true, + PlanUsage: &CursorPlanUsage{Limit: 0}, + } + requestUsage := &CursorRequestUsageResponse{ + Models: map[string]CursorModelUsage{ + "gpt-4.1": {NumRequests: 10, MaxRequestUsage: 100}, + }, + } + + if !shouldUseCursorRequestBasedUsage(usage, requestUsage) { + t.Fatal("expected request-based usage to be used when the plan limit is unavailable") + } + if shouldUseCursorRequestBasedUsage(&CursorUsageResponse{Enabled: true, PlanUsage: &CursorPlanUsage{Limit: 100}}, requestUsage) { + t.Fatal("did not expect request-based usage when a standard plan limit is available") + } +} diff --git a/internal/api/cursor_token.go b/internal/api/cursor_token.go new file mode 100644 index 0000000..c58a2cb --- /dev/null +++ b/internal/api/cursor_token.go @@ -0,0 +1,208 @@ +package api + +import ( + "database/sql" + "encoding/json" + "errors" + "fmt" + "log/slog" + "os" + "path/filepath" + "strings" + "sync/atomic" + "time" + + _ "modernc.org/sqlite" +) + +var cursorTestMode atomic.Bool + +const cursorStateDBFilename = "state.vscdb" + +func SetCursorTestMode(enabled bool) { + cursorTestMode.Store(enabled) +} + +type cursorAuthState struct { + AccessToken string + RefreshToken string + Source string // "sqlite" or "keychain" + Email string + Membership string + ExpiresAt time.Time + ExpiresIn time.Duration +} + +// DetectCursorToken attempts to auto-detect the Cursor access token. +// Returns empty string if not found. +func DetectCursorToken(logger *slog.Logger) string { + state := detectCursorAuthPlatform(logger) + if state == nil { + return "" + } + if state.AccessToken != "" { + return state.AccessToken + } + return "" +} + +// DetectCursorCredentials attempts to auto-detect full Cursor credentials. +// Returns nil if not found. +func DetectCursorCredentials(logger *slog.Logger) *CursorCredentials { + state := detectCursorAuthPlatform(logger) + if state == nil { + return nil + } + return &CursorCredentials{ + AccessToken: state.AccessToken, + RefreshToken: state.RefreshToken, + ExpiresAt: state.ExpiresAt, + ExpiresIn: state.ExpiresIn, + Source: state.Source, + } +} + +type cursorStateRow struct { + Key string + Value string +} + +func parseCursorStateRows(data []byte) map[string]string { + var rows []cursorStateRow + if err := json.Unmarshal(data, &rows); err != nil { + return nil + } + result := make(map[string]string) + for _, row := range rows { + result[row.Key] = row.Value + } + return result +} + +func cursorStateDBPathForOS(home, goos string) string { + if home == "" { + return "" + } + switch goos { + case "darwin": + return filepath.Join(home, "Library", "Application Support", "Cursor", "User", "globalStorage", cursorStateDBFilename) + case "windows": + return filepath.Join(home, "AppData", "Roaming", "Cursor", "User", "globalStorage", cursorStateDBFilename) + default: + return filepath.Join(home, ".config", "Cursor", "User", "globalStorage", cursorStateDBFilename) + } +} + +func readCursorSQLiteAuth(logger *slog.Logger) (accessToken, refreshToken, email, membership string) { + if logger == nil { + logger = slog.Default() + } + if cursorTestMode.Load() { + return + } + + dbPath := getCursorStateDBPath() + if dbPath == "" { + return + } + if _, err := os.Stat(dbPath); err != nil { + logger.Debug("cursor: state.vscdb not found", "path", dbPath) + return + } + + at, err := readCursorStateValue(dbPath, "cursorAuth/accessToken") + if err != nil { + logger.Debug("cursor: failed to read accessToken from SQLite", "error", err) + } else { + accessToken = strings.TrimSpace(at) + } + + rt, err := readCursorStateValue(dbPath, "cursorAuth/refreshToken") + if err != nil { + logger.Debug("cursor: failed to read refreshToken from SQLite", "error", err) + } else { + refreshToken = strings.TrimSpace(rt) + } + + em, err := readCursorStateValue(dbPath, "cursorAuth/cachedEmail") + if err == nil { + email = strings.TrimSpace(em) + } + + mt, err := readCursorStateValue(dbPath, "cursorAuth/stripeMembershipType") + if err == nil { + membership = strings.ToLower(strings.TrimSpace(mt)) + } + + logger.Info("cursor: auth detected from SQLite", + "has_access_token", accessToken != "", + "has_refresh_token", refreshToken != "", + "membership", membership, + ) + return +} + +// readCursorStateValue reads a single key from Cursor's state.vscdb (ItemTable). +func readCursorStateValue(dbPath, key string) (string, error) { + db, err := sql.Open("sqlite", dbPath+"?mode=ro") + if err != nil { + return "", fmt.Errorf("cursor: open state db: %w", err) + } + defer db.Close() + + var value string + err = db.QueryRow("SELECT value FROM ItemTable WHERE key = ? LIMIT 1", key).Scan(&value) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return "", nil + } + return "", fmt.Errorf("cursor: query state db: %w", err) + } + return value, nil +} + +// buildCursorAuthState builds cursorAuthState from tokens. When includeStripeMembership +// is true, stripe membership type is read from state.vscdb when the DB path exists. +func buildCursorAuthState(accessToken, refreshToken, source string, logger *slog.Logger, includeStripeMembership bool) *cursorAuthState { + if logger == nil { + logger = slog.Default() + } + state := &cursorAuthState{ + AccessToken: accessToken, + RefreshToken: refreshToken, + Source: source, + } + + if accessToken != "" { + expUnix := ExtractJWTExpiry(accessToken) + if expUnix > 0 { + state.ExpiresAt = time.Unix(expUnix, 0) + state.ExpiresIn = time.Until(state.ExpiresAt) + } + if includeStripeMembership { + membership := "" + dbPath := getCursorStateDBPath() + if dbPath != "" { + mt, err := readCursorStateValue(dbPath, "cursorAuth/stripeMembershipType") + if err == nil { + membership = strings.ToLower(strings.TrimSpace(mt)) + } + } + state.Membership = membership + } + } + + logger.Debug("cursor: auth state built", + "source", source, + "has_access_token", accessToken != "", + "has_refresh_token", refreshToken != "", + "expires_in", state.ExpiresIn.Round(time.Minute), + ) + return state +} + +// WriteCursorCredentials persists refreshed Cursor OAuth tokens. +// This must be called after refresh because Cursor rotates refresh tokens. +func WriteCursorCredentials(accessToken, refreshToken string) error { + return writeCursorCredentials(accessToken, refreshToken) +} diff --git a/internal/api/cursor_token_test.go b/internal/api/cursor_token_test.go new file mode 100644 index 0000000..553bc01 --- /dev/null +++ b/internal/api/cursor_token_test.go @@ -0,0 +1,116 @@ +package api + +import ( + "log/slog" + "testing" +) + +func TestDetectCursorToken_Empty(t *testing.T) { + SetCursorTestMode(true) + defer SetCursorTestMode(false) + + token := DetectCursorToken(slog.Default()) + if token != "" { + t.Errorf("Expected empty token in test mode, got %q", token) + } +} + +func TestDetectCursorCredentials_Empty(t *testing.T) { + SetCursorTestMode(true) + defer SetCursorTestMode(false) + + creds := DetectCursorCredentials(slog.Default()) + if creds != nil { + t.Errorf("Expected nil credentials in test mode, got %+v", creds) + } +} + +func TestParseCursorStateRows(t *testing.T) { + tests := []struct { + name string + data string + expected map[string]string + }{ + { + name: "empty json", + data: `[]`, + expected: map[string]string{}, + }, + { + name: "single key", + data: `[{"key":"cursorAuth/accessToken","value":"test_token"}]`, + expected: map[string]string{ + "cursorAuth/accessToken": "test_token", + }, + }, + { + name: "multiple keys", + data: `[{"key":"cursorAuth/accessToken","value":"access_tok"},{"key":"cursorAuth/refreshToken","value":"refresh_tok"},{"key":"cursorAuth/cachedEmail","value":"test@example.com"}]`, + expected: map[string]string{ + "cursorAuth/accessToken": "access_tok", + "cursorAuth/refreshToken": "refresh_tok", + "cursorAuth/cachedEmail": "test@example.com", + }, + }, + { + name: "invalid json", + data: `{invalid`, + expected: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := parseCursorStateRows([]byte(tt.data)) + if tt.expected == nil { + if result != nil { + t.Errorf("Expected nil, got %v", result) + } + return + } + if len(result) != len(tt.expected) { + t.Errorf("Result len = %d, want %d", len(result), len(tt.expected)) + } + for k, v := range tt.expected { + if result[k] != v { + t.Errorf("result[%q] = %q, want %q", k, result[k], v) + } + } + }) + } +} + +func TestCursorStateDBPathForOS(t *testing.T) { + home := "/tmp/testhome" + + tests := []struct { + name string + goos string + want string + }{ + { + name: "darwin path", + goos: "darwin", + want: "/tmp/testhome/Library/Application Support/Cursor/User/globalStorage/state.vscdb", + }, + { + name: "linux path", + goos: "linux", + want: "/tmp/testhome/.config/Cursor/User/globalStorage/state.vscdb", + }, + { + name: "windows path", + goos: "windows", + want: "/tmp/testhome/AppData/Roaming/Cursor/User/globalStorage/state.vscdb", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := cursorStateDBPathForOS(home, tt.goos) + if got != tt.want { + t.Fatalf("cursorStateDBPathForOS() = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/internal/api/cursor_token_unix.go b/internal/api/cursor_token_unix.go new file mode 100644 index 0000000..39f04ba --- /dev/null +++ b/internal/api/cursor_token_unix.go @@ -0,0 +1,223 @@ +//go:build !windows + +package api + +import ( + "errors" + "fmt" + "log/slog" + "os" + "os/exec" + "os/user" + "runtime" + "strings" +) + +var ( + cursorWriteSQLiteToken = WriteCursorTokenToSQLite + cursorWriteKeychain = writeCursorTokenToKeychain + cursorWriteLinuxKeyring = writeCursorTokenToLinuxKeyring +) + +func getCursorStateDBPath() string { + home, err := os.UserHomeDir() + if err != nil { + if u, err := user.Current(); err == nil { + home = u.HomeDir + } + } + if home == "" { + return "" + } + return cursorStateDBPathForOS(home, runtime.GOOS) +} + +func detectCursorAuthPlatform(logger *slog.Logger) *cursorAuthState { + if logger == nil { + logger = slog.Default() + } + + sqliteAccessToken, sqliteRefreshToken, _, sqliteMembership := readCursorSQLiteAuth(logger) + keychainAccessToken, keychainRefreshToken := readCursorKeychainAuth(logger) + + sqliteSubject := "" + if sqliteAccessToken != "" { + sqliteSubject = ExtractJWTSubject(sqliteAccessToken) + } + keychainSubject := "" + if keychainAccessToken != "" { + keychainSubject = ExtractJWTSubject(keychainAccessToken) + } + + hasDifferentSubjects := sqliteSubject != "" && keychainSubject != "" && sqliteSubject != keychainSubject + sqliteLooksFree := sqliteMembership == "free" + + if sqliteAccessToken != "" || sqliteRefreshToken != "" { + if (keychainAccessToken != "" || keychainRefreshToken != "") && sqliteLooksFree && hasDifferentSubjects { + logger.Info("cursor: SQLite auth looks free and differs from keychain; preferring keychain token") + return buildCursorAuthState(keychainAccessToken, keychainRefreshToken, "keychain", logger, true) + } + return buildCursorAuthState(sqliteAccessToken, sqliteRefreshToken, "sqlite", logger, true) + } + + if keychainAccessToken != "" || keychainRefreshToken != "" { + return buildCursorAuthState(keychainAccessToken, keychainRefreshToken, "keychain", logger, true) + } + + return nil +} + +func readCursorKeychainAuth(logger *slog.Logger) (accessToken, refreshToken string) { + if cursorTestMode.Load() { + return + } + + if runtime.GOOS != "darwin" && runtime.GOOS != "linux" { + return + } + + if runtime.GOOS == "darwin" { + username := "" + if u, err := user.Current(); err == nil { + username = u.Username + } + if username == "" { + return + } + + out, err := exec.Command("security", "find-generic-password", + "-s", "cursor-access-token", + "-a", username, + "-w").Output() + if err == nil { + accessToken = strings.TrimSpace(string(out)) + } + + out, err = exec.Command("security", "find-generic-password", + "-s", "cursor-refresh-token", + "-a", username, + "-w").Output() + if err == nil { + refreshToken = strings.TrimSpace(string(out)) + } + + if accessToken != "" || refreshToken != "" { + logger.Info("cursor: auth detected from macOS Keychain") + } + return + } + + if runtime.GOOS == "linux" { + out, err := exec.Command("secret-tool", "lookup", + "service", "cursor-access-token").Output() + if err == nil { + accessToken = strings.TrimSpace(string(out)) + } + + out, err = exec.Command("secret-tool", "lookup", + "service", "cursor-refresh-token").Output() + if err == nil { + refreshToken = strings.TrimSpace(string(out)) + } + + if accessToken != "" || refreshToken != "" { + logger.Info("cursor: auth detected from Linux keyring") + } + return + } + + return +} + +func writeCursorCredentials(accessToken, refreshToken string) error { + if cursorTestMode.Load() { + return nil + } + + var errs []error + accessSaved := false + refreshSaved := refreshToken == "" + + if dbPath := getCursorStateDBPath(); dbPath != "" { + if _, err := os.Stat(dbPath); err == nil { + if err := cursorWriteSQLiteToken(dbPath, "cursorAuth/accessToken", accessToken); err != nil { + errs = append(errs, err) + } else { + accessSaved = true + } + if refreshToken != "" { + if err := cursorWriteSQLiteToken(dbPath, "cursorAuth/refreshToken", refreshToken); err != nil { + errs = append(errs, err) + } else { + refreshSaved = true + } + } + } + } + + if runtime.GOOS == "darwin" { + if err := cursorWriteKeychain("cursor-access-token", accessToken); err != nil { + errs = append(errs, err) + } else { + accessSaved = true + } + if refreshToken != "" { + if err := cursorWriteKeychain("cursor-refresh-token", refreshToken); err != nil { + errs = append(errs, err) + } else { + refreshSaved = true + } + } + } + + if runtime.GOOS == "linux" { + if err := cursorWriteLinuxKeyring("cursor-access-token", accessToken); err != nil { + errs = append(errs, err) + } else { + accessSaved = true + } + if refreshToken != "" { + if err := cursorWriteLinuxKeyring("cursor-refresh-token", refreshToken); err != nil { + errs = append(errs, err) + } else { + refreshSaved = true + } + } + } + + if accessSaved && refreshSaved { + return nil + } + if len(errs) > 0 { + return errs[0] + } + return os.ErrNotExist +} + +func writeCursorTokenToKeychain(service, value string) error { + username := "" + if u, err := user.Current(); err == nil { + username = u.Username + } + if username == "" { + return errors.New("cannot determine username for Keychain") + } + cmd := exec.Command("security", "add-generic-password", + "-U", + "-s", service, + "-a", username, + "-w", value, + ) + return cmd.Run() +} + +func writeCursorTokenToLinuxKeyring(service, value string) error { + if _, err := exec.LookPath("secret-tool"); err != nil { + return fmt.Errorf("secret-tool not found: %w", err) + } + cmd := exec.Command("secret-tool", "store", + "--label", service, + "service", service) + cmd.Stdin = strings.NewReader(value) + return cmd.Run() +} diff --git a/internal/api/cursor_token_unix_test.go b/internal/api/cursor_token_unix_test.go new file mode 100644 index 0000000..d880a3a --- /dev/null +++ b/internal/api/cursor_token_unix_test.go @@ -0,0 +1,44 @@ +//go:build !windows + +package api + +import ( + "errors" + "testing" +) + +func TestWriteCursorCredentials_FailsWhenRefreshTokenIsNotPersisted(t *testing.T) { + SetCursorTestMode(false) + + origSQLite := cursorWriteSQLiteToken + origKeychain := cursorWriteKeychain + origKeyring := cursorWriteLinuxKeyring + cursorWriteSQLiteToken = func(dbPath, key, value string) error { + if key == "cursorAuth/refreshToken" { + return errors.New("refresh write failed") + } + return nil + } + cursorWriteKeychain = func(service, value string) error { + if service == "cursor-refresh-token" { + return errors.New("refresh write failed") + } + return nil + } + cursorWriteLinuxKeyring = func(service, value string) error { + if service == "cursor-refresh-token" { + return errors.New("refresh write failed") + } + return nil + } + t.Cleanup(func() { + cursorWriteSQLiteToken = origSQLite + cursorWriteKeychain = origKeychain + cursorWriteLinuxKeyring = origKeyring + }) + + err := writeCursorCredentials("fresh_access", "fresh_refresh") + if err == nil { + t.Fatal("expected writeCursorCredentials to fail when refresh token persistence fails") + } +} diff --git a/internal/api/cursor_token_windows.go b/internal/api/cursor_token_windows.go new file mode 100644 index 0000000..f92b7ae --- /dev/null +++ b/internal/api/cursor_token_windows.go @@ -0,0 +1,56 @@ +//go:build windows + +package api + +import ( + "log/slog" + "os" + "os/user" + "runtime" +) + +func getCursorStateDBPath() string { + home, err := os.UserHomeDir() + if err != nil { + if u, err := user.Current(); err == nil { + home = u.HomeDir + } + } + if home == "" { + return "" + } + return cursorStateDBPathForOS(home, runtime.GOOS) +} + +func detectCursorAuthPlatform(logger *slog.Logger) *cursorAuthState { + if logger == nil { + logger = slog.Default() + } + + accessToken, refreshToken, _, _ := readCursorSQLiteAuth(logger) + if accessToken == "" && refreshToken == "" { + return nil + } + + return buildCursorAuthState(accessToken, refreshToken, "sqlite", logger, false) +} + +func writeCursorCredentials(accessToken, refreshToken string) error { + if cursorTestMode.Load() { + return nil + } + + dbPath := getCursorStateDBPath() + if dbPath == "" { + return os.ErrNotExist + } + if err := WriteCursorTokenToSQLite(dbPath, "cursorAuth/accessToken", accessToken); err != nil { + return err + } + if refreshToken != "" { + if err := WriteCursorTokenToSQLite(dbPath, "cursorAuth/refreshToken", refreshToken); err != nil { + return err + } + } + return nil +} diff --git a/internal/api/cursor_types.go b/internal/api/cursor_types.go new file mode 100644 index 0000000..0ce6e59 --- /dev/null +++ b/internal/api/cursor_types.go @@ -0,0 +1,510 @@ +package api + +import ( + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" + "time" +) + +type CursorAccountType string + +const ( + CursorAccountIndividual CursorAccountType = "individual" + CursorAccountTeam CursorAccountType = "team" + CursorAccountEnterprise CursorAccountType = "enterprise" +) + +type CursorQuotaFormat string + +const ( + CursorFormatPercent CursorQuotaFormat = "percent" + CursorFormatDollars CursorQuotaFormat = "dollars" + CursorFormatCount CursorQuotaFormat = "count" +) + +type CursorQuota struct { + Name string + Used float64 + Limit float64 + Utilization float64 + Format CursorQuotaFormat + ResetsAt *time.Time +} + +type CursorSnapshot struct { + ID int64 + CapturedAt time.Time + AccountType CursorAccountType + PlanName string + Quotas []CursorQuota + RawJSON string +} + +type CursorUsageResponse struct { + BillingCycleStart string `json:"billingCycleStart"` + BillingCycleEnd string `json:"billingCycleEnd"` + PlanUsage *CursorPlanUsage `json:"planUsage"` + SpendLimitUsage *CursorSpendLimitUsage `json:"spendLimitUsage"` + Enabled bool `json:"enabled"` + DisplayThreshold int `json:"displayThreshold"` + DisplayMessage string `json:"displayMessage"` +} + +type CursorPlanUsage struct { + TotalSpend int `json:"totalSpend"` + IncludedSpend int `json:"includedSpend"` + BonusSpend int `json:"bonusSpend"` + Remaining int `json:"remaining"` + Limit int `json:"limit"` + RemainingBonus bool `json:"remainingBonus"` + AutoPercentUsed float64 `json:"autoPercentUsed"` + ApiPercentUsed float64 `json:"apiPercentUsed"` + TotalPercentUsed float64 `json:"totalPercentUsed"` +} + +type CursorSpendLimitUsage struct { + TotalSpend int `json:"totalSpend"` + PooledLimit int `json:"pooledLimit"` + PooledUsed int `json:"pooledUsed"` + PooledRemaining int `json:"pooledRemaining"` + IndividualLimit int `json:"individualLimit"` + IndividualUsed int `json:"individualUsed"` + IndividualRemaining int `json:"individualRemaining"` + LimitType string `json:"limitType"` +} + +type CursorPlanInfoResponse struct { + PlanInfo CursorPlanInfo `json:"planInfo"` +} + +type CursorPlanInfo struct { + PlanName string `json:"planName"` + IncludedAmountCents int `json:"includedAmountCents"` + Price string `json:"price"` + BillingCycleEnd string `json:"billingCycleEnd"` +} + +type CursorCreditGrantsResponse struct { + HasCreditGrants bool `json:"hasCreditGrants"` + TotalCents string `json:"totalCents"` + UsedCents string `json:"usedCents"` +} + +type CursorStripeResponse struct { + MembershipType string `json:"membershipType"` + SubscriptionStatus string `json:"subscriptionStatus"` + CustomerBalance int `json:"customerBalance"` +} + +type CursorOAuthResponse struct { + AccessToken string `json:"access_token"` + IDToken string `json:"id_token"` + RefreshToken string `json:"refresh_token"` + ShouldLogout bool `json:"shouldLogout"` +} + +type CursorRequestUsageResponse struct { + StartOfMonth string `json:"startOfMonth"` + Models map[string]CursorModelUsage `json:"-"` +} + +type CursorModelUsage struct { + NumRequests int `json:"numRequests"` + MaxRequestUsage int `json:"maxRequestUsage"` +} + +type CursorCredentials struct { + AccessToken string + RefreshToken string + ExpiresAt time.Time + ExpiresIn time.Duration + Source string // "sqlite" or "keychain" +} + +func (c *CursorCredentials) IsExpiringSoon(threshold time.Duration) bool { + if c.ExpiresAt.IsZero() { + return false + } + return c.ExpiresIn < threshold +} + +func ParseCursorUsageResponse(data []byte) (*CursorUsageResponse, error) { + var resp CursorUsageResponse + if err := json.Unmarshal(data, &resp); err != nil { + return nil, err + } + return &resp, nil +} + +func ParseCursorPlanInfoResponse(data []byte) (*CursorPlanInfoResponse, error) { + var resp CursorPlanInfoResponse + if err := json.Unmarshal(data, &resp); err != nil { + return nil, err + } + return &resp, nil +} + +func ParseCursorCreditGrantsResponse(data []byte) (*CursorCreditGrantsResponse, error) { + var resp CursorCreditGrantsResponse + if err := json.Unmarshal(data, &resp); err != nil { + return nil, err + } + return &resp, nil +} + +func ParseCursorStripeResponse(data []byte) (*CursorStripeResponse, error) { + var resp CursorStripeResponse + if err := json.Unmarshal(data, &resp); err != nil { + return nil, err + } + return &resp, nil +} + +func ParseCursorOAuthResponse(data []byte) (*CursorOAuthResponse, error) { + var resp CursorOAuthResponse + if err := json.Unmarshal(data, &resp); err != nil { + return nil, err + } + return &resp, nil +} + +func ParseCursorRequestUsageResponse(data []byte) (*CursorRequestUsageResponse, error) { + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return nil, err + } + + resp := &CursorRequestUsageResponse{} + if v, ok := raw["startOfMonth"]; ok { + _ = json.Unmarshal(v, &resp.StartOfMonth) + } + + resp.Models = make(map[string]CursorModelUsage) + for key, val := range raw { + if key == "startOfMonth" { + continue + } + var usage CursorModelUsage + if err := json.Unmarshal(val, &usage); err == nil { + resp.Models[key] = usage + } + } + + return resp, nil +} + +func ExtractJWTSubject(token string) string { + parts := strings.Split(token, ".") + if len(parts) < 2 { + return "" + } + payload, err := base64URLDecode(parts[1]) + if err != nil { + return "" + } + + var claims map[string]interface{} + if err := json.Unmarshal(payload, &claims); err != nil { + return "" + } + + sub, ok := claims["sub"].(string) + if !ok || sub == "" { + return "" + } + + if idx := strings.LastIndex(sub, "|"); idx >= 0 { + return sub[idx+1:] + } + return sub +} + +func ExtractJWTExpiry(token string) int64 { + parts := strings.Split(token, ".") + if len(parts) < 2 { + return 0 + } + payload, err := base64URLDecode(parts[1]) + if err != nil { + return 0 + } + + var claims map[string]interface{} + if err := json.Unmarshal(payload, &claims); err != nil { + return 0 + } + + exp, ok := claims["exp"].(float64) + if !ok { + return 0 + } + return int64(exp) +} + +func base64URLDecode(s string) ([]byte, error) { + for len(s)%4 != 0 { + s += "=" + } + return base64.URLEncoding.DecodeString(s) +} + +func NormalizeCursorPlanName(planName string) string { + switch strings.ToLower(strings.TrimSpace(planName)) { + case "team", "business": + return "team" + case "enterprise": + return "enterprise" + case "pro": + return "pro" + case "ultra": + return "ultra" + case "free", "free trial": + return "free" + default: + return strings.ToLower(strings.TrimSpace(planName)) + } +} + +func ParseUnixMsString(s string) (time.Time, error) { + if s == "" { + return time.Time{}, fmt.Errorf("empty timestamp string") + } + ms, err := ParseIntString(s) + if err != nil { + return time.Time{}, err + } + return time.UnixMilli(ms), nil +} + +func ParseIntString(s string) (int64, error) { + if s == "" { + return 0, nil + } + result, err := strconv.ParseInt(s, 10, 64) + if err != nil { + return 0, errInvalidIntString + } + return result, nil +} + +var errInvalidIntString = errors.New("invalid integer string") + +func ToCursorSnapshot( + usage *CursorUsageResponse, + planInfo *CursorPlanInfoResponse, + creditGrants *CursorCreditGrantsResponse, + stripeResp *CursorStripeResponse, + requestUsage *CursorRequestUsageResponse, + useRequestBased bool, +) *CursorSnapshot { + snapshot := &CursorSnapshot{ + CapturedAt: time.Now().UTC(), + } + + planName := "" + if planInfo != nil { + planName = planInfo.PlanInfo.PlanName + } + accountType := DetermineCursorAccountType(planName, usage, useRequestBased) + snapshot.AccountType = accountType + snapshot.PlanName = planName + + if accountType == CursorAccountEnterprise && requestUsage != nil { + snapshot.Quotas = buildEnterpriseQuotas(usage, requestUsage) + } else { + snapshot.Quotas = buildStandardQuotas(usage, creditGrants, stripeResp, accountType) + } + + if raw, err := json.Marshal(usage); err == nil { + snapshot.RawJSON = string(raw) + } + + return snapshot +} + +func buildStandardQuotas( + usage *CursorUsageResponse, + creditGrants *CursorCreditGrantsResponse, + stripeResp *CursorStripeResponse, + accountType CursorAccountType, +) []CursorQuota { + var quotas []CursorQuota + + if usage == nil || usage.PlanUsage == nil { + return quotas + } + + pu := usage.PlanUsage + var billingCycleEnd *time.Time + if usage.BillingCycleEnd != "" { + if t, err := ParseUnixMsString(usage.BillingCycleEnd); err == nil { + billingCycleEnd = &t + } + } + + switch accountType { + case CursorAccountIndividual: + if pu.TotalPercentUsed != 0 || pu.Limit > 0 { + percentUsed := pu.TotalPercentUsed + if percentUsed == 0 && pu.Limit > 0 { + planUsed := pu.TotalSpend + if planUsed == 0 { + planUsed = pu.Limit - pu.Remaining + } + percentUsed = float64(planUsed) / float64(pu.Limit) * 100 + } + quotas = append(quotas, CursorQuota{ + Name: "total_usage", + Used: float64(pu.TotalSpend) / 100, + Limit: float64(pu.Limit) / 100, + Utilization: percentUsed, + Format: CursorFormatPercent, + ResetsAt: billingCycleEnd, + }) + } + case CursorAccountTeam: + planUsed := pu.TotalSpend + if planUsed == 0 && pu.Limit > 0 { + planUsed = pu.Limit - pu.Remaining + } + utilization := 0.0 + if pu.Limit > 0 { + utilization = float64(planUsed) / float64(pu.Limit) * 100 + } + quotas = append(quotas, CursorQuota{ + Name: "total_usage", + Used: float64(planUsed) / 100, + Limit: float64(pu.Limit) / 100, + Utilization: utilization, + Format: CursorFormatDollars, + ResetsAt: billingCycleEnd, + }) + default: + if pu.TotalPercentUsed != 0 || pu.Limit > 0 { + percentUsed := pu.TotalPercentUsed + if percentUsed == 0 && pu.Limit > 0 { + planUsed := pu.TotalSpend + if planUsed == 0 { + planUsed = pu.Limit - pu.Remaining + } + percentUsed = float64(planUsed) / float64(pu.Limit) * 100 + } + quotas = append(quotas, CursorQuota{ + Name: "total_usage", + Used: float64(pu.TotalSpend) / 100, + Limit: float64(pu.Limit) / 100, + Utilization: percentUsed, + Format: CursorFormatPercent, + ResetsAt: billingCycleEnd, + }) + } + } + + quotas = append(quotas, CursorQuota{ + Name: "auto_usage", + Utilization: pu.AutoPercentUsed, + Format: CursorFormatPercent, + ResetsAt: billingCycleEnd, + }) + + quotas = append(quotas, CursorQuota{ + Name: "api_usage", + Utilization: pu.ApiPercentUsed, + Format: CursorFormatPercent, + ResetsAt: billingCycleEnd, + }) + + if creditGrants != nil && stripeResp != nil { + grantTotalCents := 0 + if creditGrants.HasCreditGrants { + if tc, err := ParseIntString(creditGrants.TotalCents); err == nil { + grantTotalCents = int(tc) + } + } + stripeBalanceCents := 0 + if stripeResp.CustomerBalance < 0 { + stripeBalanceCents = -stripeResp.CustomerBalance + } + combinedTotal := grantTotalCents + stripeBalanceCents + if combinedTotal > 0 { + usedCents := 0 + if creditGrants.HasCreditGrants { + if uc, err := ParseIntString(creditGrants.UsedCents); err == nil { + usedCents = int(uc) + } + } + quotas = append(quotas, CursorQuota{ + Name: "credits", + Used: float64(usedCents) / 100, + Limit: float64(combinedTotal) / 100, + Utilization: float64(usedCents) / float64(combinedTotal) * 100, + Format: CursorFormatDollars, + }) + } + } + + if usage.SpendLimitUsage != nil { + su := usage.SpendLimitUsage + limit := su.IndividualLimit + remaining := su.IndividualRemaining + if limit == 0 && su.PooledLimit > 0 { + limit = su.PooledLimit + remaining = su.PooledRemaining + } + if limit > 0 { + used := limit - remaining + quotas = append(quotas, CursorQuota{ + Name: "on_demand", + Used: float64(used) / 100, + Limit: float64(limit) / 100, + Utilization: float64(used) / float64(limit) * 100, + Format: CursorFormatDollars, + }) + } + } + + return quotas +} + +func DetermineCursorAccountType(planName string, usage *CursorUsageResponse, useRequestBased bool) CursorAccountType { + normalizedPlan := NormalizeCursorPlanName(planName) + hasPlanUsageLimit := usage != nil && usage.PlanUsage != nil && usage.PlanUsage.Limit > 0 + + if normalizedPlan == "team" || + (usage != nil && usage.SpendLimitUsage != nil && usage.SpendLimitUsage.LimitType == "team") || + (usage != nil && usage.SpendLimitUsage != nil && usage.SpendLimitUsage.PooledLimit > 0) { + return CursorAccountTeam + } + + if normalizedPlan == "enterprise" || (useRequestBased && !hasPlanUsageLimit) { + return CursorAccountEnterprise + } + + return CursorAccountIndividual +} + +func buildEnterpriseQuotas(usage *CursorUsageResponse, requestUsage *CursorRequestUsageResponse) []CursorQuota { + var quotas []CursorQuota + + for model, mu := range requestUsage.Models { + used := mu.NumRequests + limit := mu.MaxRequestUsage + utilization := float64(0) + if limit > 0 { + utilization = float64(used) / float64(limit) * 100 + } + quotas = append(quotas, CursorQuota{ + Name: "requests_" + model, + Used: float64(used), + Limit: float64(limit), + Utilization: utilization, + Format: CursorFormatCount, + }) + } + + return quotas +} diff --git a/internal/api/cursor_types_test.go b/internal/api/cursor_types_test.go new file mode 100644 index 0000000..7ec7559 --- /dev/null +++ b/internal/api/cursor_types_test.go @@ -0,0 +1,849 @@ +package api + +import ( + "encoding/base64" + "fmt" + "math" + "strings" + "testing" + "time" +) + +func TestParseCursorUsageResponse(t *testing.T) { + raw := `{ + "billingCycleStart": "1768399334000", + "billingCycleEnd": "1771077734000", + "planUsage": { + "totalSpend": 23222, + "includedSpend": 23222, + "bonusSpend": 0, + "remaining": 16778, + "limit": 40000, + "remainingBonus": false, + "autoPercentUsed": 0, + "apiPercentUsed": 46.444, + "totalPercentUsed": 15.48 + }, + "spendLimitUsage": { + "totalSpend": 0, + "pooledLimit": 50000, + "pooledUsed": 0, + "pooledRemaining": 50000, + "individualLimit": 10000, + "individualUsed": 0, + "individualRemaining": 10000, + "limitType": "team" + }, + "enabled": true, + "displayThreshold": 200, + "displayMessage": "You've used 46% of your usage limit" + }` + + resp, err := ParseCursorUsageResponse([]byte(raw)) + if err != nil { + t.Fatalf("ParseCursorUsageResponse: %v", err) + } + + if resp.BillingCycleStart != "1768399334000" { + t.Errorf("BillingCycleStart = %q, want %q", resp.BillingCycleStart, "1768399334000") + } + if resp.PlanUsage == nil { + t.Fatal("PlanUsage should not be nil") + } + if resp.PlanUsage.TotalSpend != 23222 { + t.Errorf("TotalSpend = %d, want 23222", resp.PlanUsage.TotalSpend) + } + if resp.PlanUsage.TotalPercentUsed != 15.48 { + t.Errorf("TotalPercentUsed = %f, want 15.48", resp.PlanUsage.TotalPercentUsed) + } + if resp.SpendLimitUsage == nil { + t.Fatal("SpendLimitUsage should not be nil") + } + if resp.SpendLimitUsage.LimitType != "team" { + t.Errorf("LimitType = %q, want %q", resp.SpendLimitUsage.LimitType, "team") + } + if !resp.Enabled { + t.Error("Enabled should be true") + } +} + +func TestParseCursorUsageResponse_InvalidJSON(t *testing.T) { + _, err := ParseCursorUsageResponse([]byte(`{invalid`)) + if err == nil { + t.Error("Expected error for invalid JSON") + } +} + +func TestParseCursorPlanInfoResponse(t *testing.T) { + raw := `{ + "planInfo": { + "planName": "Ultra", + "includedAmountCents": 40000, + "price": "$200/mo", + "billingCycleEnd": "1771077734000" + } + }` + + resp, err := ParseCursorPlanInfoResponse([]byte(raw)) + if err != nil { + t.Fatalf("ParseCursorPlanInfoResponse: %v", err) + } + if resp.PlanInfo.PlanName != "Ultra" { + t.Errorf("PlanName = %q, want %q", resp.PlanInfo.PlanName, "Ultra") + } + if resp.PlanInfo.IncludedAmountCents != 40000 { + t.Errorf("IncludedAmountCents = %d, want 40000", resp.PlanInfo.IncludedAmountCents) + } +} + +func TestParseCursorCreditGrantsResponse(t *testing.T) { + raw := `{ + "hasCreditGrants": true, + "totalCents": "5000", + "usedCents": "2000" + }` + + resp, err := ParseCursorCreditGrantsResponse([]byte(raw)) + if err != nil { + t.Fatalf("ParseCursorCreditGrantsResponse: %v", err) + } + if !resp.HasCreditGrants { + t.Error("HasCreditGrants should be true") + } + if resp.TotalCents != "5000" { + t.Errorf("TotalCents = %q, want %q", resp.TotalCents, "5000") + } + if resp.UsedCents != "2000" { + t.Errorf("UsedCents = %q, want %q", resp.UsedCents, "2000") + } +} + +func TestParseCursorStripeResponse(t *testing.T) { + raw := `{ + "membershipType": "ultra", + "subscriptionStatus": "active", + "customerBalance": -123456 + }` + + resp, err := ParseCursorStripeResponse([]byte(raw)) + if err != nil { + t.Fatalf("ParseCursorStripeResponse: %v", err) + } + if resp.MembershipType != "ultra" { + t.Errorf("MembershipType = %q, want %q", resp.MembershipType, "ultra") + } + if resp.CustomerBalance != -123456 { + t.Errorf("CustomerBalance = %d, want -123456", resp.CustomerBalance) + } +} + +func TestParseCursorOAuthResponse(t *testing.T) { + raw := `{ + "access_token": "new_access_token", + "id_token": "new_id_token", + "refresh_token": "new_refresh_token", + "shouldLogout": false + }` + + resp, err := ParseCursorOAuthResponse([]byte(raw)) + if err != nil { + t.Fatalf("ParseCursorOAuthResponse: %v", err) + } + if resp.AccessToken != "new_access_token" { + t.Errorf("AccessToken = %q, want %q", resp.AccessToken, "new_access_token") + } + if resp.ShouldLogout { + t.Error("ShouldLogout should be false") + } +} + +func TestParseCursorOAuthResponse_SessionExpired(t *testing.T) { + raw := `{ + "access_token": "", + "id_token": "", + "shouldLogout": true + }` + + resp, err := ParseCursorOAuthResponse([]byte(raw)) + if err != nil { + t.Fatalf("ParseCursorOAuthResponse: %v", err) + } + if !resp.ShouldLogout { + t.Error("ShouldLogout should be true") + } +} + +func TestParseCursorRequestUsageResponse(t *testing.T) { + raw := `{ + "startOfMonth": "2026-03-01", + "gpt-4": { + "numRequests": 150, + "maxRequestUsage": 500 + }, + "claude-3.5-sonnet": { + "numRequests": 50, + "maxRequestUsage": 200 + } + }` + + resp, err := ParseCursorRequestUsageResponse([]byte(raw)) + if err != nil { + t.Fatalf("ParseCursorRequestUsageResponse: %v", err) + } + if resp.StartOfMonth != "2026-03-01" { + t.Errorf("StartOfMonth = %q, want %q", resp.StartOfMonth, "2026-03-01") + } + if len(resp.Models) != 2 { + t.Fatalf("Models len = %d, want 2", len(resp.Models)) + } + if resp.Models["gpt-4"].NumRequests != 150 { + t.Errorf("gpt-4 NumRequests = %d, want 150", resp.Models["gpt-4"].NumRequests) + } + if resp.Models["claude-3.5-sonnet"].MaxRequestUsage != 200 { + t.Errorf("claude-3.5-sonnet MaxRequestUsage = %d, want 200", resp.Models["claude-3.5-sonnet"].MaxRequestUsage) + } +} + +func TestExtractJWTSubject(t *testing.T) { + tests := []struct { + name string + token string + expected string + }{ + { + name: "valid token with pipe", + token: createTestJWT("google-oauth2|user_abc", 1735689600), + expected: "user_abc", + }, + { + name: "valid token without pipe", + token: createTestJWT("user123", 1735689600), + expected: "user123", + }, + { + name: "empty token", + token: "", + expected: "", + }, + { + name: "invalid token", + token: "not.a.valid.token", + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ExtractJWTSubject(tt.token) + if got != tt.expected { + t.Errorf("ExtractJWTSubject() = %q, want %q", got, tt.expected) + } + }) + } +} + +func TestExtractJWTExpiry(t *testing.T) { + token := createTestJWT("user123", 1735689600) + exp := ExtractJWTExpiry(token) + if exp != 1735689600 { + t.Errorf("ExtractJWTExpiry() = %d, want 1735689600", exp) + } + + emptyExp := ExtractJWTExpiry("") + if emptyExp != 0 { + t.Errorf("ExtractJWTExpiry(empty) = %d, want 0", emptyExp) + } +} + +func TestNormalizeCursorPlanName(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"Pro", "pro"}, + {"Ultra", "ultra"}, + {"Team", "team"}, + {"Business", "team"}, + {"Enterprise", "enterprise"}, + {"Free", "free"}, + {"free trial", "free"}, + {" PRO ", "pro"}, + {"Unknown", "unknown"}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := NormalizeCursorPlanName(tt.input) + if got != tt.expected { + t.Errorf("NormalizeCursorPlanName(%q) = %q, want %q", tt.input, got, tt.expected) + } + }) + } +} + +func TestParseUnixMsString(t *testing.T) { + result, err := ParseUnixMsString("1768399334000") + if err != nil { + t.Fatalf("ParseUnixMsString: %v", err) + } + expected := time.UnixMilli(1768399334000) + if !result.Equal(expected) { + t.Errorf("ParseUnixMsString = %v, want %v", result, expected) + } +} + +func TestParseUnixMsString_Empty(t *testing.T) { + _, err := ParseUnixMsString("") + if err == nil { + t.Error("Expected error for empty string") + } +} + +func TestParseIntString(t *testing.T) { + tests := []struct { + input string + expected int64 + }{ + {"12345", 12345}, + {"0", 0}, + {"", 0}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got, err := ParseIntString(tt.input) + if err != nil { + t.Fatalf("ParseIntString(%q): %v", tt.input, err) + } + if got != tt.expected { + t.Errorf("ParseIntString(%q) = %d, want %d", tt.input, got, tt.expected) + } + }) + } +} + +func TestParseIntString_Invalid(t *testing.T) { + _, err := ParseIntString("abc") + if err == nil { + t.Error("Expected error for non-numeric string") + } +} + +func TestParseIntString_LargeValue(t *testing.T) { + got, err := ParseIntString("1768399334000") + if err != nil { + t.Fatalf("ParseIntString large value: %v", err) + } + if got != 1768399334000 { + t.Fatalf("ParseIntString large value = %d, want 1768399334000", got) + } +} + +func TestToCursorSnapshot_Individual(t *testing.T) { + usage := &CursorUsageResponse{ + BillingCycleStart: "1768399334000", + BillingCycleEnd: "1771077734000", + PlanUsage: &CursorPlanUsage{ + TotalSpend: 23222, + IncludedSpend: 23222, + BonusSpend: 0, + Remaining: 16778, + Limit: 40000, + TotalPercentUsed: 15.48, + AutoPercentUsed: 5.2, + ApiPercentUsed: 46.444, + }, + Enabled: true, + } + + planInfo := &CursorPlanInfoResponse{ + PlanInfo: CursorPlanInfo{ + PlanName: "Pro", + }, + } + + snapshot := ToCursorSnapshot(usage, planInfo, nil, nil, nil, false) + + if snapshot.AccountType != CursorAccountIndividual { + t.Errorf("AccountType = %q, want %q", snapshot.AccountType, CursorAccountIndividual) + } + if snapshot.PlanName != "Pro" { + t.Errorf("PlanName = %q, want %q", snapshot.PlanName, "Pro") + } + + totalUsageFound := false + autoFound := false + apiFound := false + for _, q := range snapshot.Quotas { + switch q.Name { + case "total_usage": + totalUsageFound = true + if q.Format != CursorFormatPercent { + t.Errorf("total_usage Format = %q, want %q", q.Format, CursorFormatPercent) + } + if q.Utilization != 15.48 { + t.Errorf("total_usage Utilization = %f, want 15.48", q.Utilization) + } + case "auto_usage": + autoFound = true + if q.Utilization != 5.2 { + t.Errorf("auto_usage Utilization = %f, want 5.2", q.Utilization) + } + case "api_usage": + apiFound = true + if q.Utilization != 46.444 { + t.Errorf("api_usage Utilization = %f, want 46.444", q.Utilization) + } + } + } + if !totalUsageFound { + t.Error("total_usage quota not found") + } + if !autoFound { + t.Error("auto_usage quota not found") + } + if !apiFound { + t.Error("api_usage quota not found") + } +} + +func TestToCursorSnapshot_IndividualIncludesZeroBreakdownQuotas(t *testing.T) { + usage := &CursorUsageResponse{ + BillingCycleStart: "1768399334000", + BillingCycleEnd: "1771077734000", + PlanUsage: &CursorPlanUsage{ + TotalSpend: 100, + Remaining: 39900, + Limit: 40000, + TotalPercentUsed: 0.25, + AutoPercentUsed: 0, + ApiPercentUsed: 0, + }, + Enabled: true, + } + + planInfo := &CursorPlanInfoResponse{ + PlanInfo: CursorPlanInfo{ + PlanName: "Pro+", + }, + } + + snapshot := ToCursorSnapshot(usage, planInfo, nil, nil, nil, false) + + autoFound := false + apiFound := false + for _, q := range snapshot.Quotas { + switch q.Name { + case "auto_usage": + autoFound = true + if q.Utilization != 0 { + t.Errorf("auto_usage Utilization = %f, want 0", q.Utilization) + } + case "api_usage": + apiFound = true + if q.Utilization != 0 { + t.Errorf("api_usage Utilization = %f, want 0", q.Utilization) + } + } + } + + if !autoFound { + t.Error("auto_usage quota should still be present when utilization is zero") + } + if !apiFound { + t.Error("api_usage quota should still be present when utilization is zero") + } +} + +func TestToCursorSnapshot_Team(t *testing.T) { + usage := &CursorUsageResponse{ + BillingCycleStart: "1768399334000", + BillingCycleEnd: "1771077734000", + PlanUsage: &CursorPlanUsage{ + TotalSpend: 5000, + Remaining: 35000, + Limit: 40000, + }, + SpendLimitUsage: &CursorSpendLimitUsage{ + PooledLimit: 50000, + PooledUsed: 0, + PooledRemaining: 50000, + LimitType: "team", + }, + Enabled: true, + } + + planInfo := &CursorPlanInfoResponse{ + PlanInfo: CursorPlanInfo{ + PlanName: "Team", + }, + } + + snapshot := ToCursorSnapshot(usage, planInfo, nil, nil, nil, false) + + if snapshot.AccountType != CursorAccountTeam { + t.Errorf("AccountType = %q, want %q", snapshot.AccountType, CursorAccountTeam) + } + + totalUsageFound := false + for _, q := range snapshot.Quotas { + if q.Name == "total_usage" { + totalUsageFound = true + if q.Format != CursorFormatDollars { + t.Errorf("total_usage Format = %q, want %q", q.Format, CursorFormatDollars) + } + if q.Used != 50.0 { + t.Errorf("total_usage Used = %f, want 50.0", q.Used) + } + if q.Limit != 400.0 { + t.Errorf("total_usage Limit = %f, want 400.0", q.Limit) + } + } + } + if !totalUsageFound { + t.Error("total_usage quota not found for team") + } +} + +func TestToCursorSnapshot_Credits(t *testing.T) { + usage := &CursorUsageResponse{ + PlanUsage: &CursorPlanUsage{ + TotalSpend: 1000, + Remaining: 9000, + Limit: 10000, + TotalPercentUsed: 10.0, + }, + Enabled: true, + } + + planInfo := &CursorPlanInfoResponse{ + PlanInfo: CursorPlanInfo{ + PlanName: "Pro", + }, + } + + creditGrants := &CursorCreditGrantsResponse{ + HasCreditGrants: true, + TotalCents: "3000", + UsedCents: "1500", + } + + stripeResp := &CursorStripeResponse{ + MembershipType: "pro", + CustomerBalance: -2000, + } + + snapshot := ToCursorSnapshot(usage, planInfo, creditGrants, stripeResp, nil, false) + + creditsFound := false + for _, q := range snapshot.Quotas { + if q.Name == "credits" { + creditsFound = true + if q.Format != CursorFormatDollars { + t.Errorf("credits Format = %q, want %q", q.Format, CursorFormatDollars) + } + if q.Used != 15.0 { + t.Errorf("credits Used = %f, want 15.0", q.Used) + } + if q.Limit != 50.0 { + t.Errorf("credits Limit = %f, want 50.0 (3000 + 2000 = 5000 cents = $50)", q.Limit) + } + } + } + if !creditsFound { + t.Error("credits quota not found") + } +} + +func TestToCursorSnapshot_Enterprise(t *testing.T) { + usage := &CursorUsageResponse{ + Enabled: true, + } + + planInfo := &CursorPlanInfoResponse{ + PlanInfo: CursorPlanInfo{ + PlanName: "Enterprise", + }, + } + + requestUsage := &CursorRequestUsageResponse{ + StartOfMonth: "2026-03-01", + Models: map[string]CursorModelUsage{ + "gpt-4": {NumRequests: 150, MaxRequestUsage: 500}, + }, + } + + snapshot := ToCursorSnapshot(usage, planInfo, nil, nil, requestUsage, true) + + if snapshot.AccountType != CursorAccountEnterprise { + t.Errorf("AccountType = %q, want %q", snapshot.AccountType, CursorAccountEnterprise) + } + + requestsFound := false + for _, q := range snapshot.Quotas { + if q.Name == "requests_gpt-4" { + requestsFound = true + if q.Format != CursorFormatCount { + t.Errorf("requests Format = %q, want %q", q.Format, CursorFormatCount) + } + if q.Used != 150 { + t.Errorf("requests Used = %f, want 150", q.Used) + } + if q.Limit != 500 { + t.Errorf("requests Limit = %f, want 500", q.Limit) + } + } + } + if !requestsFound { + t.Error("requests quota not found for enterprise") + } +} + +func TestToCursorSnapshot_OnDemand(t *testing.T) { + usage := &CursorUsageResponse{ + PlanUsage: &CursorPlanUsage{ + TotalSpend: 1000, + Remaining: 9000, + Limit: 10000, + TotalPercentUsed: 10.0, + }, + SpendLimitUsage: &CursorSpendLimitUsage{ + IndividualLimit: 10000, + IndividualUsed: 2500, + IndividualRemaining: 7500, + }, + Enabled: true, + } + + planInfo := &CursorPlanInfoResponse{ + PlanInfo: CursorPlanInfo{ + PlanName: "Pro", + }, + } + + snapshot := ToCursorSnapshot(usage, planInfo, nil, nil, nil, false) + + ondemandFound := false + for _, q := range snapshot.Quotas { + if q.Name == "on_demand" { + ondemandFound = true + if q.Format != CursorFormatDollars { + t.Errorf("on_demand Format = %q, want %q", q.Format, CursorFormatDollars) + } + if q.Used != 25.0 { + t.Errorf("on_demand Used = %f, want 25.0", q.Used) + } + if q.Limit != 100.0 { + t.Errorf("on_demand Limit = %f, want 100.0", q.Limit) + } + } + } + if !ondemandFound { + t.Error("on_demand quota not found") + } +} + +func TestToCursorSnapshot_NilUsage(t *testing.T) { + snapshot := ToCursorSnapshot(nil, nil, nil, nil, nil, false) + + if snapshot.AccountType != CursorAccountIndividual { + t.Errorf("AccountType = %q, want %q", snapshot.AccountType, CursorAccountIndividual) + } + if len(snapshot.Quotas) != 0 { + t.Errorf("Quotas len = %d, want 0", len(snapshot.Quotas)) + } +} + +func TestDetermineCursorAccountType_RequestBasedPromotesToEnterprise(t *testing.T) { + usage := &CursorUsageResponse{ + Enabled: true, + PlanUsage: &CursorPlanUsage{Limit: 0}, + } + accountType := DetermineCursorAccountType("pro", usage, true) + if accountType != CursorAccountEnterprise { + t.Fatalf("DetermineCursorAccountType() = %q, want %q", accountType, CursorAccountEnterprise) + } +} + +func TestToCursorSnapshot_TeamZeroLimitDoesNotProduceInfiniteUtilization(t *testing.T) { + usage := &CursorUsageResponse{ + Enabled: true, + PlanUsage: &CursorPlanUsage{ + TotalSpend: 1234, + Limit: 0, + }, + } + planInfo := &CursorPlanInfoResponse{ + PlanInfo: CursorPlanInfo{ + PlanName: "Team", + }, + } + + snapshot := ToCursorSnapshot(usage, planInfo, nil, nil, nil, false) + if len(snapshot.Quotas) == 0 { + t.Fatal("expected at least one quota") + } + + var totalUsage *CursorQuota + for i := range snapshot.Quotas { + if snapshot.Quotas[i].Name == "total_usage" { + totalUsage = &snapshot.Quotas[i] + break + } + } + if totalUsage == nil { + t.Fatal("expected total_usage quota") + } + if math.IsInf(totalUsage.Utilization, 0) || math.IsNaN(totalUsage.Utilization) { + t.Fatalf("utilization = %v, want finite value", totalUsage.Utilization) + } + if totalUsage.Utilization != 0 { + t.Fatalf("utilization = %v, want 0 when plan limit is unavailable", totalUsage.Utilization) + } +} + +func TestCursorCredentials_IsExpiringSoon(t *testing.T) { + tests := []struct { + name string + creds *CursorCredentials + threshold time.Duration + expected bool + }{ + { + name: "expiring soon", + creds: &CursorCredentials{ + AccessToken: "test", + ExpiresAt: time.Now().Add(2 * time.Minute), + ExpiresIn: 2 * time.Minute, + }, + threshold: 5 * time.Minute, + expected: true, + }, + { + name: "not expiring soon", + creds: &CursorCredentials{ + AccessToken: "test", + ExpiresAt: time.Now().Add(30 * time.Minute), + ExpiresIn: 30 * time.Minute, + }, + threshold: 5 * time.Minute, + expected: false, + }, + { + name: "zero expires at", + creds: &CursorCredentials{ + AccessToken: "test", + }, + threshold: 5 * time.Minute, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.creds.IsExpiringSoon(tt.threshold) + if got != tt.expected { + t.Errorf("IsExpiringSoon() = %v, want %v", got, tt.expected) + } + }) + } +} + +func TestNeedsCursorRefresh(t *testing.T) { + tests := []struct { + name string + creds *CursorCredentials + expected bool + }{ + { + name: "nil credentials", + creds: nil, + expected: true, + }, + { + name: "empty access token", + creds: &CursorCredentials{ + AccessToken: "", + }, + expected: true, + }, + { + name: "not expiring soon", + creds: &CursorCredentials{ + AccessToken: "test", + ExpiresAt: time.Now().Add(30 * time.Minute), + ExpiresIn: 30 * time.Minute, + }, + expected: false, + }, + { + name: "expiring soon", + creds: &CursorCredentials{ + AccessToken: "test", + ExpiresAt: time.Now().Add(2 * time.Minute), + ExpiresIn: 2 * time.Minute, + }, + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := NeedsCursorRefresh(tt.creds) + if got != tt.expected { + t.Errorf("NeedsCursorRefresh() = %v, want %v", got, tt.expected) + } + }) + } +} + +func TestIsCursorAuthError(t *testing.T) { + if !IsCursorAuthError(ErrCursorUnauthorized) { + t.Error("IsCursorAuthError(ErrCursorUnauthorized) should be true") + } + if !IsCursorAuthError(ErrCursorForbidden) { + t.Error("IsCursorAuthError(ErrCursorForbidden) should be true") + } + if IsCursorAuthError(ErrCursorServerError) { + t.Error("IsCursorAuthError(ErrCursorServerError) should be false") + } +} + +func TestIsCursorSessionExpired(t *testing.T) { + if !IsCursorSessionExpired(ErrCursorSessionExpired) { + t.Error("IsCursorSessionExpired(ErrCursorSessionExpired) should be true") + } + if IsCursorSessionExpired(ErrCursorUnauthorized) { + t.Error("IsCursorSessionExpired(ErrCursorUnauthorized) should be false") + } +} + +func TestCursorQuotaFormat_String(t *testing.T) { + tests := []struct { + format CursorQuotaFormat + expected string + }{ + {CursorFormatPercent, "percent"}, + {CursorFormatDollars, "dollars"}, + {CursorFormatCount, "count"}, + } + + for _, tt := range tests { + t.Run(tt.expected, func(t *testing.T) { + if string(tt.format) != tt.expected { + t.Errorf("CursorQuotaFormat(%q).String() = %q, want %q", tt.format, string(tt.format), tt.expected) + } + }) + } +} + +func createTestJWT(sub string, exp int64) string { + header := `{"alg":"RS256","typ":"JWT"}` + payload := `{"sub":"` + sub + `","exp":` + fmt.Sprintf("%d", exp) + `}` + return base64URLTestEncode([]byte(header)) + "." + base64URLTestEncode([]byte(payload)) + ".signature" +} + +func base64URLTestEncode(data []byte) string { + s := base64.URLEncoding.EncodeToString(data) + return strings.TrimRight(s, "=") +} diff --git a/internal/config/config.go b/internal/config/config.go index e686d61..2540799 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -38,9 +38,9 @@ type Config struct { CopilotToken string // COPILOT_TOKEN (GitHub PAT with copilot scope) // Codex provider configuration - CodexToken string // CODEX_TOKEN or auto-detected - CodexAutoToken bool // true if token was auto-detected - CodexHasProfiles bool // true if saved profiles exist (enables bootstrap without token) + CodexToken string // CODEX_TOKEN or auto-detected + CodexAutoToken bool // true if token was auto-detected + CodexHasProfiles bool // true if saved profiles exist (enables bootstrap without token) CodexShowAvailable string // CODEX_SHOW_AVAILABLE: "usage" | "available", default "usage" // Antigravity provider configuration (auto-detected from local process) @@ -61,6 +61,10 @@ type Config struct { GeminiRefreshToken string // GEMINI_REFRESH_TOKEN (for Docker/headless) GeminiAccessToken string // GEMINI_ACCESS_TOKEN (for Docker/headless) + // Cursor provider configuration (auto-detected from Cursor Desktop SQLite or keychain) + CursorToken string // CURSOR_TOKEN or auto-detected + CursorAutoToken bool // true if token was auto-detected + // Custom API Integrations telemetry ingestion APIIntegrationsEnabled bool // ONWATCH_API_INTEGRATIONS_ENABLED (default: true) APIIntegrationsDir string // ONWATCH_API_INTEGRATIONS_DIR (default: ~/.onwatch/api-integrations or /data/api-integrations) @@ -109,9 +113,9 @@ func expandTilde(path string) string { // flagValues holds parsed CLI flags. type flagValues struct { - interval int - port int - db string + interval int + port int + db string debug bool debugStdout bool test bool @@ -185,6 +189,7 @@ var onwatchEnvKeys = []string{ "ANTIGRAVITY_ENABLED", "MINIMAX_API_KEY", "OPENROUTER_API_KEY", + "CURSOR_TOKEN", "GEMINI_ENABLED", "GEMINI_REFRESH_TOKEN", "GEMINI_ACCESS_TOKEN", @@ -303,6 +308,9 @@ func loadFromEnvAndFlags(flags *flagValues) (*Config, error) { } // File-based auto-detection is done later in main.go + // Cursor provider (auto-detected from Cursor Desktop SQLite or keychain) + cfg.CursorToken = strings.TrimSpace(os.Getenv("CURSOR_TOKEN")) + // Custom API Integrations telemetry ingestion cfg.APIIntegrationsDir = strings.TrimSpace(os.Getenv("ONWATCH_API_INTEGRATIONS_DIR")) cfg.APIIntegrationsEnabled = true @@ -508,6 +516,9 @@ func (c *Config) AvailableProviders() []string { if c.GeminiEnabled { providers = append(providers, "gemini") } + if c.CursorToken != "" { + providers = append(providers, "cursor") + } return providers } @@ -532,6 +543,8 @@ func (c *Config) HasProvider(name string) bool { return c.OpenRouterAPIKey != "" case "gemini": return c.GeminiEnabled + case "cursor": + return c.CursorToken != "" } return false } @@ -566,6 +579,9 @@ func (c *Config) HasMultipleProviders() bool { if c.GeminiEnabled { count++ } + if c.CursorToken != "" { + count++ + } return count > 1 } @@ -609,6 +625,13 @@ func (c *Config) String() string { fmt.Fprintf(&sb, " APIIntegrationsDir: %s,\n", c.APIIntegrationsDir) fmt.Fprintf(&sb, " APIIntegrationsRetention: %v,\n", c.APIIntegrationsRetention) + // Redact Cursor token + cursorDisplay := redactAPIKey(c.CursorToken, "") + fmt.Fprintf(&sb, " CursorToken: %s,\n", cursorDisplay) + if c.CursorAutoToken { + fmt.Fprintf(&sb, " CursorAutoToken: true,\n") + } + fmt.Fprintf(&sb, " PollInterval: %v,\n", c.PollInterval) fmt.Fprintf(&sb, " SessionIdleTimeout: %v,\n", c.SessionIdleTimeout) fmt.Fprintf(&sb, " Port: %d,\n", c.Port) diff --git a/internal/store/cursor_store.go b/internal/store/cursor_store.go new file mode 100644 index 0000000..ffc100c --- /dev/null +++ b/internal/store/cursor_store.go @@ -0,0 +1,523 @@ +package store + +import ( + "database/sql" + "fmt" + "time" + + "github.com/onllm-dev/onwatch/v2/internal/api" +) + +type CursorResetCycle struct { + ID int64 + QuotaName string + CycleStart time.Time + CycleEnd *time.Time + ResetsAt *time.Time + PeakUtilization float64 + TotalDelta float64 +} + +type CursorLatestQuota struct { + Name string + Used float64 + Limit float64 + Utilization float64 + Format string + ResetsAt *time.Time + CapturedAt time.Time + AccountType string + PlanName string +} + +func (s *Store) InsertCursorSnapshot(snapshot *api.CursorSnapshot) (int64, error) { + tx, err := s.db.Begin() + if err != nil { + return 0, fmt.Errorf("failed to begin transaction: %w", err) + } + defer tx.Rollback() + + result, err := tx.Exec( + `INSERT INTO cursor_snapshots (captured_at, raw_json, account_type, plan_name, quota_count) VALUES (?, ?, ?, ?, ?)`, + snapshot.CapturedAt.Format(time.RFC3339Nano), + snapshot.RawJSON, + string(snapshot.AccountType), + snapshot.PlanName, + len(snapshot.Quotas), + ) + if err != nil { + return 0, fmt.Errorf("failed to insert cursor snapshot: %w", err) + } + + snapshotID, err := result.LastInsertId() + if err != nil { + return 0, fmt.Errorf("failed to get snapshot ID: %w", err) + } + + for _, q := range snapshot.Quotas { + var resetsAt interface{} + if q.ResetsAt != nil { + resetsAt = q.ResetsAt.Format(time.RFC3339Nano) + } + _, err := tx.Exec( + `INSERT INTO cursor_quota_values (snapshot_id, quota_name, used, limit_value, utilization, format, resets_at) VALUES (?, ?, ?, ?, ?, ?, ?)`, + snapshotID, q.Name, q.Used, q.Limit, q.Utilization, string(q.Format), resetsAt, + ) + if err != nil { + return 0, fmt.Errorf("failed to insert quota value %s: %w", q.Name, err) + } + } + + if err := tx.Commit(); err != nil { + return 0, fmt.Errorf("failed to commit: %w", err) + } + + return snapshotID, nil +} + +func (s *Store) QueryLatestCursor() (*api.CursorSnapshot, error) { + var snapshot api.CursorSnapshot + var capturedAt, accountType, planName string + + err := s.db.QueryRow( + `SELECT id, captured_at, account_type, plan_name, quota_count FROM cursor_snapshots ORDER BY captured_at DESC LIMIT 1`, + ).Scan(&snapshot.ID, &capturedAt, &accountType, &planName, new(int)) + + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("failed to query latest cursor: %w", err) + } + + snapshot.CapturedAt, _ = time.Parse(time.RFC3339Nano, capturedAt) + snapshot.AccountType = api.CursorAccountType(accountType) + snapshot.PlanName = planName + + rows, err := s.db.Query( + `SELECT quota_name, used, limit_value, utilization, format, resets_at FROM cursor_quota_values WHERE snapshot_id = ? ORDER BY quota_name`, + snapshot.ID, + ) + if err != nil { + return nil, fmt.Errorf("failed to query quota values: %w", err) + } + defer rows.Close() + + for rows.Next() { + var q api.CursorQuota + var format string + var resetsAt sql.NullString + if err := rows.Scan(&q.Name, &q.Used, &q.Limit, &q.Utilization, &format, &resetsAt); err != nil { + return nil, fmt.Errorf("failed to scan quota value: %w", err) + } + q.Format = api.CursorQuotaFormat(format) + if resetsAt.Valid && resetsAt.String != "" { + t, _ := time.Parse(time.RFC3339Nano, resetsAt.String) + q.ResetsAt = &t + } + snapshot.Quotas = append(snapshot.Quotas, q) + } + + return &snapshot, rows.Err() +} + +func (s *Store) QueryCursorRange(start, end time.Time, limit ...int) ([]*api.CursorSnapshot, error) { + query := `SELECT id, captured_at, account_type, plan_name, quota_count FROM cursor_snapshots + WHERE captured_at BETWEEN ? AND ? ORDER BY captured_at ASC` + args := []interface{}{start.UTC().Format(time.RFC3339Nano), end.UTC().Format(time.RFC3339Nano)} + if len(limit) > 0 && limit[0] > 0 { + query = `SELECT id, captured_at, account_type, plan_name, quota_count + FROM ( + SELECT id, captured_at, account_type, plan_name, quota_count + FROM cursor_snapshots + WHERE captured_at BETWEEN ? AND ? + ORDER BY captured_at DESC + LIMIT ? + ) recent + ORDER BY captured_at ASC` + args = append(args, limit[0]) + } + + rows, err := s.db.Query(query, args...) + if err != nil { + return nil, fmt.Errorf("failed to query cursor range: %w", err) + } + defer rows.Close() + + var snapshots []*api.CursorSnapshot + for rows.Next() { + var snap api.CursorSnapshot + var capturedAt, accountType, planName string + if err := rows.Scan(&snap.ID, &capturedAt, &accountType, &planName, new(int)); err != nil { + return nil, fmt.Errorf("failed to scan cursor snapshot: %w", err) + } + snap.CapturedAt, _ = time.Parse(time.RFC3339Nano, capturedAt) + snap.AccountType = api.CursorAccountType(accountType) + snap.PlanName = planName + snapshots = append(snapshots, &snap) + } + + if err := rows.Err(); err != nil { + return nil, err + } + + for _, snap := range snapshots { + qRows, err := s.db.Query( + `SELECT quota_name, used, limit_value, utilization, format, resets_at FROM cursor_quota_values WHERE snapshot_id = ? ORDER BY quota_name`, + snap.ID, + ) + if err != nil { + return nil, fmt.Errorf("failed to query quota values for snapshot %d: %w", snap.ID, err) + } + for qRows.Next() { + var q api.CursorQuota + var format string + var resetsAt sql.NullString + if err := qRows.Scan(&q.Name, &q.Used, &q.Limit, &q.Utilization, &format, &resetsAt); err != nil { + qRows.Close() + return nil, fmt.Errorf("failed to scan quota value: %w", err) + } + q.Format = api.CursorQuotaFormat(format) + if resetsAt.Valid && resetsAt.String != "" { + t, _ := time.Parse(time.RFC3339Nano, resetsAt.String) + q.ResetsAt = &t + } + snap.Quotas = append(snap.Quotas, q) + } + qRows.Close() + } + + return snapshots, nil +} + +func (s *Store) CreateCursorCycle(quotaName string, cycleStart time.Time, resetsAt *time.Time) (int64, error) { + var resetsAtVal interface{} + if resetsAt != nil { + resetsAtVal = resetsAt.Format(time.RFC3339Nano) + } + + result, err := s.db.Exec( + `INSERT INTO cursor_reset_cycles (quota_name, cycle_start, resets_at) VALUES (?, ?, ?)`, + quotaName, cycleStart.Format(time.RFC3339Nano), resetsAtVal, + ) + if err != nil { + return 0, fmt.Errorf("failed to create cursor cycle: %w", err) + } + + id, err := result.LastInsertId() + if err != nil { + return 0, fmt.Errorf("failed to get cycle ID: %w", err) + } + return id, nil +} + +func (s *Store) CloseCursorCycle(quotaName string, cycleEnd time.Time, peak, delta float64) error { + _, err := s.db.Exec( + `UPDATE cursor_reset_cycles SET cycle_end = ?, peak_utilization = ?, total_delta = ? + WHERE quota_name = ? AND cycle_end IS NULL`, + cycleEnd.Format(time.RFC3339Nano), peak, delta, quotaName, + ) + if err != nil { + return fmt.Errorf("failed to close cursor cycle: %w", err) + } + return nil +} + +func (s *Store) UpdateCursorCycle(quotaName string, peak, delta float64) error { + _, err := s.db.Exec( + `UPDATE cursor_reset_cycles SET peak_utilization = ?, total_delta = ? + WHERE quota_name = ? AND cycle_end IS NULL`, + peak, delta, quotaName, + ) + if err != nil { + return fmt.Errorf("failed to update cursor cycle: %w", err) + } + return nil +} + +func (s *Store) QueryActiveCursorCycle(quotaName string) (*CursorResetCycle, error) { + var cycle CursorResetCycle + var cycleStart string + var cycleEnd, resetsAt sql.NullString + + err := s.db.QueryRow( + `SELECT id, quota_name, cycle_start, cycle_end, resets_at, peak_utilization, total_delta + FROM cursor_reset_cycles WHERE quota_name = ? AND cycle_end IS NULL`, + quotaName, + ).Scan( + &cycle.ID, &cycle.QuotaName, &cycleStart, &cycleEnd, &resetsAt, + &cycle.PeakUtilization, &cycle.TotalDelta, + ) + + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("failed to query active cursor cycle: %w", err) + } + + cycle.CycleStart, _ = time.Parse(time.RFC3339Nano, cycleStart) + if cycleEnd.Valid { + t, _ := time.Parse(time.RFC3339Nano, cycleEnd.String) + cycle.CycleEnd = &t + } + if resetsAt.Valid { + t, _ := time.Parse(time.RFC3339Nano, resetsAt.String) + cycle.ResetsAt = &t + } + + return &cycle, nil +} + +func (s *Store) QueryCursorCycleHistory(quotaName string, limit ...int) ([]*CursorResetCycle, error) { + query := `SELECT id, quota_name, cycle_start, cycle_end, resets_at, peak_utilization, total_delta + FROM cursor_reset_cycles WHERE quota_name = ? AND cycle_end IS NOT NULL ORDER BY cycle_start DESC` + args := []interface{}{quotaName} + if len(limit) > 0 && limit[0] > 0 { + query += ` LIMIT ?` + args = append(args, limit[0]) + } + + rows, err := s.db.Query(query, args...) + if err != nil { + return nil, fmt.Errorf("failed to query cursor cycles: %w", err) + } + defer rows.Close() + + var cycles []*CursorResetCycle + for rows.Next() { + var cycle CursorResetCycle + var cycleStart, cycleEnd string + var resetsAt sql.NullString + + if err := rows.Scan(&cycle.ID, &cycle.QuotaName, &cycleStart, &cycleEnd, &resetsAt, + &cycle.PeakUtilization, &cycle.TotalDelta); err != nil { + return nil, fmt.Errorf("failed to scan cursor cycle: %w", err) + } + + cycle.CycleStart, _ = time.Parse(time.RFC3339Nano, cycleStart) + t, _ := time.Parse(time.RFC3339Nano, cycleEnd) + cycle.CycleEnd = &t + if resetsAt.Valid { + rt, _ := time.Parse(time.RFC3339Nano, resetsAt.String) + cycle.ResetsAt = &rt + } + + cycles = append(cycles, &cycle) + } + + return cycles, rows.Err() +} + +func (s *Store) QueryCursorCyclesSince(quotaName string, since time.Time) ([]*CursorResetCycle, error) { + rows, err := s.db.Query( + `SELECT id, quota_name, cycle_start, cycle_end, resets_at, peak_utilization, total_delta + FROM cursor_reset_cycles WHERE quota_name = ? AND cycle_end IS NOT NULL AND cycle_start >= ? + ORDER BY cycle_start DESC`, + quotaName, since.UTC().Format(time.RFC3339Nano), + ) + if err != nil { + return nil, fmt.Errorf("failed to query cursor cycles since: %w", err) + } + defer rows.Close() + + var cycles []*CursorResetCycle + for rows.Next() { + var cycle CursorResetCycle + var cycleStart, cycleEnd string + var resetsAt sql.NullString + + if err := rows.Scan(&cycle.ID, &cycle.QuotaName, &cycleStart, &cycleEnd, &resetsAt, + &cycle.PeakUtilization, &cycle.TotalDelta); err != nil { + return nil, fmt.Errorf("failed to scan cursor cycle: %w", err) + } + + cycle.CycleStart, _ = time.Parse(time.RFC3339Nano, cycleStart) + t, _ := time.Parse(time.RFC3339Nano, cycleEnd) + cycle.CycleEnd = &t + if resetsAt.Valid { + rt, _ := time.Parse(time.RFC3339Nano, resetsAt.String) + cycle.ResetsAt = &rt + } + + cycles = append(cycles, &cycle) + } + + return cycles, rows.Err() +} + +func (s *Store) QueryCursorUtilizationSeries(quotaName string, since time.Time) ([]UtilizationPoint, error) { + rows, err := s.db.Query( + `SELECT s.captured_at, qv.utilization + FROM cursor_quota_values qv + JOIN cursor_snapshots s ON s.id = qv.snapshot_id + WHERE qv.quota_name = ? AND s.captured_at >= ? + ORDER BY s.captured_at ASC`, + quotaName, since.UTC().Format(time.RFC3339Nano), + ) + if err != nil { + return nil, fmt.Errorf("failed to query utilization series: %w", err) + } + defer rows.Close() + + var points []UtilizationPoint + for rows.Next() { + var capturedAt string + var util float64 + if err := rows.Scan(&capturedAt, &util); err != nil { + return nil, fmt.Errorf("failed to scan utilization point: %w", err) + } + t, _ := time.Parse(time.RFC3339Nano, capturedAt) + points = append(points, UtilizationPoint{CapturedAt: t, Utilization: util}) + } + + return points, rows.Err() +} + +func (s *Store) QueryCursorLatestPerQuota() ([]CursorLatestQuota, error) { + rows, err := s.db.Query(` + SELECT qv.quota_name, qv.used, qv.limit_value, qv.utilization, qv.format, qv.resets_at, + s.captured_at, s.account_type, s.plan_name + FROM cursor_quota_values qv + JOIN cursor_snapshots s ON s.id = qv.snapshot_id + WHERE s.id = (SELECT MAX(id) FROM cursor_snapshots) + ORDER BY qv.quota_name ASC`) + if err != nil { + return nil, fmt.Errorf("failed to query latest per-quota: %w", err) + } + defer rows.Close() + + var results []CursorLatestQuota + for rows.Next() { + var name, format, accountType, planName string + var used, limitValue, utilization float64 + var resetsAt sql.NullString + var capturedAt string + + if err := rows.Scan(&name, &used, &limitValue, &utilization, &format, &resetsAt, &capturedAt, &accountType, &planName); err != nil { + return nil, fmt.Errorf("failed to scan latest quota: %w", err) + } + + q := CursorLatestQuota{ + Name: name, + Used: used, + Limit: limitValue, + Utilization: utilization, + Format: format, + AccountType: accountType, + PlanName: planName, + } + q.CapturedAt, _ = time.Parse(time.RFC3339Nano, capturedAt) + if resetsAt.Valid && resetsAt.String != "" { + t, _ := time.Parse(time.RFC3339Nano, resetsAt.String) + q.ResetsAt = &t + } + results = append(results, q) + } + return results, rows.Err() +} + +func (s *Store) QueryAllCursorQuotaNames() ([]string, error) { + rows, err := s.db.Query( + `SELECT DISTINCT quota_name FROM cursor_reset_cycles ORDER BY quota_name`, + ) + if err != nil { + return nil, fmt.Errorf("failed to query cursor quota names: %w", err) + } + defer rows.Close() + + var names []string + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + return nil, fmt.Errorf("failed to scan quota name: %w", err) + } + names = append(names, name) + } + + return names, rows.Err() +} + +func (s *Store) QueryCursorCycleOverview(groupBy string, limit int) ([]CycleOverviewRow, error) { + if limit <= 0 { + limit = 50 + } + + var cycles []*CursorResetCycle + activeCycle, err := s.QueryActiveCursorCycle(groupBy) + if err != nil { + return nil, fmt.Errorf("store.QueryCursorCycleOverview: active: %w", err) + } + if activeCycle != nil { + cycles = append(cycles, activeCycle) + limit-- + } + + completedCycles, err := s.QueryCursorCycleHistory(groupBy, limit) + if err != nil { + return nil, fmt.Errorf("store.QueryCursorCycleOverview: %w", err) + } + cycles = append(cycles, completedCycles...) + + var overviewRows []CycleOverviewRow + for _, c := range cycles { + row := CycleOverviewRow{ + CycleID: c.ID, + QuotaType: c.QuotaName, + CycleStart: c.CycleStart, + CycleEnd: c.CycleEnd, + PeakValue: c.PeakUtilization, + TotalDelta: c.TotalDelta, + } + + var endBoundary time.Time + if c.CycleEnd != nil { + endBoundary = *c.CycleEnd + } else { + endBoundary = time.Now().Add(time.Minute) + } + + var snapshotID int64 + var capturedAt string + err := s.db.QueryRow( + `SELECT s.id, s.captured_at FROM cursor_snapshots s + JOIN cursor_quota_values qv ON qv.snapshot_id = s.id + WHERE qv.quota_name = ? AND s.captured_at >= ? AND s.captured_at < ? + ORDER BY qv.utilization DESC LIMIT 1`, + groupBy, + c.CycleStart.Format(time.RFC3339Nano), + endBoundary.Format(time.RFC3339Nano), + ).Scan(&snapshotID, &capturedAt) + + if err == sql.ErrNoRows { + overviewRows = append(overviewRows, row) + continue + } + if err != nil { + return nil, fmt.Errorf("store.QueryCursorCycleOverview: peak snapshot: %w", err) + } + + row.PeakTime, _ = time.Parse(time.RFC3339Nano, capturedAt) + + qRows, err := s.db.Query( + `SELECT quota_name, utilization, used, limit_value FROM cursor_quota_values WHERE snapshot_id = ? ORDER BY quota_name`, + snapshotID, + ) + if err != nil { + return nil, fmt.Errorf("store.QueryCursorCycleOverview: quota values: %w", err) + } + for qRows.Next() { + var entry CrossQuotaEntry + if err := qRows.Scan(&entry.Name, &entry.Percent, &entry.Value, new(float64)); err != nil { + qRows.Close() + return nil, fmt.Errorf("store.QueryCursorCycleOverview: scan quota: %w", err) + } + row.CrossQuotas = append(row.CrossQuotas, entry) + } + qRows.Close() + + overviewRows = append(overviewRows, row) + } + + return overviewRows, nil +} diff --git a/internal/store/cursor_store_test.go b/internal/store/cursor_store_test.go new file mode 100644 index 0000000..0a1d141 --- /dev/null +++ b/internal/store/cursor_store_test.go @@ -0,0 +1,306 @@ +package store + +import ( + "testing" + "time" + + "github.com/onllm-dev/onwatch/v2/internal/api" +) + +func newTestCursorSnapshot(capturedAt time.Time, accountType api.CursorAccountType, quotas []api.CursorQuota) *api.CursorSnapshot { + return &api.CursorSnapshot{ + CapturedAt: capturedAt, + AccountType: accountType, + PlanName: "Pro", + RawJSON: `{"test": true}`, + Quotas: quotas, + } +} + +func TestCursorStore_InsertAndQueryLatest(t *testing.T) { + s, err := New(":memory:") + if err != nil { + t.Fatalf("New: %v", err) + } + defer s.Close() + + now := time.Now().UTC() + resetsAt := now.Add(30 * 24 * time.Hour) + snap := newTestCursorSnapshot(now, api.CursorAccountIndividual, []api.CursorQuota{ + {Name: "total_usage", Used: 50.0, Limit: 400.0, Utilization: 12.5, Format: api.CursorFormatPercent, ResetsAt: &resetsAt}, + {Name: "auto_usage", Utilization: 3.0, Format: api.CursorFormatPercent, ResetsAt: &resetsAt}, + }) + + id, err := s.InsertCursorSnapshot(snap) + if err != nil { + t.Fatalf("InsertCursorSnapshot: %v", err) + } + if id <= 0 { + t.Errorf("Expected positive ID, got %d", id) + } + + latest, err := s.QueryLatestCursor() + if err != nil { + t.Fatalf("QueryLatestCursor: %v", err) + } + if latest == nil { + t.Fatal("QueryLatestCursor returned nil") + } + if latest.AccountType != api.CursorAccountIndividual { + t.Errorf("AccountType = %q, want %q", latest.AccountType, api.CursorAccountIndividual) + } + if latest.PlanName != "Pro" { + t.Errorf("PlanName = %q, want %q", latest.PlanName, "Pro") + } + if len(latest.Quotas) != 2 { + t.Fatalf("Quotas len = %d, want 2", len(latest.Quotas)) + } + if latest.Quotas[0].Name != "auto_usage" { + t.Errorf("Quotas[0].Name = %q, want auto_usage (sorted)", latest.Quotas[0].Name) + } + if latest.Quotas[1].Name != "total_usage" { + t.Errorf("Quotas[1].Name = %q, want total_usage (sorted)", latest.Quotas[1].Name) + } + if latest.Quotas[1].Used != 50.0 { + t.Errorf("total_usage Used = %f, want 50.0", latest.Quotas[1].Used) + } + if latest.Quotas[1].Format != api.CursorFormatPercent { + t.Errorf("total_usage Format = %q, want percent", latest.Quotas[1].Format) + } +} + +func TestCursorStore_QueryLatest_Empty(t *testing.T) { + s, err := New(":memory:") + if err != nil { + t.Fatalf("New: %v", err) + } + defer s.Close() + + latest, err := s.QueryLatestCursor() + if err != nil { + t.Fatalf("QueryLatestCursor: %v", err) + } + if latest != nil { + t.Error("Expected nil for empty store") + } +} + +func TestCursorStore_CycleLifecycle(t *testing.T) { + s, err := New(":memory:") + if err != nil { + t.Fatalf("New: %v", err) + } + defer s.Close() + + now := time.Now().UTC() + resetsAt := now.Add(30 * 24 * time.Hour) + + // Create cycle + cycleID, err := s.CreateCursorCycle("total_usage", now, &resetsAt) + if err != nil { + t.Fatalf("CreateCursorCycle: %v", err) + } + if cycleID <= 0 { + t.Errorf("Expected positive cycle ID, got %d", cycleID) + } + + // Update cycle + if err := s.UpdateCursorCycle("total_usage", 45.0, 10.0); err != nil { + t.Fatalf("UpdateCursorCycle: %v", err) + } + + // Query active cycle + cycle, err := s.QueryActiveCursorCycle("total_usage") + if err != nil { + t.Fatalf("QueryActiveCursorCycle: %v", err) + } + if cycle == nil { + t.Fatal("Expected active cycle") + } + if cycle.PeakUtilization != 45.0 { + t.Errorf("PeakUtilization = %f, want 45.0", cycle.PeakUtilization) + } + if cycle.TotalDelta != 10.0 { + t.Errorf("TotalDelta = %f, want 10.0", cycle.TotalDelta) + } + + // Close cycle + cycleEnd := now.Add(24 * time.Hour) + if err := s.CloseCursorCycle("total_usage", cycleEnd, 45.0, 10.0); err != nil { + t.Fatalf("CloseCursorCycle: %v", err) + } + + // Verify no active cycle + active, err := s.QueryActiveCursorCycle("total_usage") + if err != nil { + t.Fatalf("QueryActiveCursorCycle after close: %v", err) + } + if active != nil { + t.Error("Expected nil after closing cycle") + } + + // Query history + history, err := s.QueryCursorCycleHistory("total_usage", 10) + if err != nil { + t.Fatalf("QueryCursorCycleHistory: %v", err) + } + if len(history) != 1 { + t.Fatalf("History len = %d, want 1", len(history)) + } + if history[0].PeakUtilization != 45.0 { + t.Errorf("History PeakUtilization = %f, want 45.0", history[0].PeakUtilization) + } +} + +func TestCursorStore_QueryRange(t *testing.T) { + s, err := New(":memory:") + if err != nil { + t.Fatalf("New: %v", err) + } + defer s.Close() + + now := time.Now().UTC() + resetsAt := now.Add(30 * 24 * time.Hour) + + for i := 0; i < 3; i++ { + snap := newTestCursorSnapshot(now.Add(time.Duration(i)*time.Hour), api.CursorAccountIndividual, []api.CursorQuota{ + {Name: "total_usage", Used: float64(i) * 10, Limit: 400.0, Utilization: float64(i) * 2.5, Format: api.CursorFormatPercent, ResetsAt: &resetsAt}, + }) + if _, err := s.InsertCursorSnapshot(snap); err != nil { + t.Fatalf("InsertCursorSnapshot %d: %v", i, err) + } + } + + snapshots, err := s.QueryCursorRange(now.Add(-time.Hour), now.Add(4*time.Hour), 200) + if err != nil { + t.Fatalf("QueryCursorRange: %v", err) + } + if len(snapshots) != 3 { + t.Errorf("Range len = %d, want 3", len(snapshots)) + } +} + +func TestCursorStore_QueryAllQuotaNames(t *testing.T) { + s, err := New(":memory:") + if err != nil { + t.Fatalf("New: %v", err) + } + defer s.Close() + + now := time.Now().UTC() + + _, err = s.CreateCursorCycle("total_usage", now, nil) + if err != nil { + t.Fatalf("CreateCursorCycle: %v", err) + } + _, err = s.CreateCursorCycle("auto_usage", now, nil) + if err != nil { + t.Fatalf("CreateCursorCycle: %v", err) + } + + names, err := s.QueryAllCursorQuotaNames() + if err != nil { + t.Fatalf("QueryAllCursorQuotaNames: %v", err) + } + if len(names) != 2 { + t.Fatalf("Names len = %d, want 2", len(names)) + } + if names[0] != "auto_usage" { + t.Errorf("Names[0] = %q, want auto_usage", names[0]) + } + if names[1] != "total_usage" { + t.Errorf("Names[1] = %q, want total_usage", names[1]) + } +} + +func TestCursorStore_LatestPerQuota(t *testing.T) { + s, err := New(":memory:") + if err != nil { + t.Fatalf("New: %v", err) + } + defer s.Close() + + now := time.Now().UTC() + resetsAt := now.Add(30 * 24 * time.Hour) + + snap1 := newTestCursorSnapshot(now.Add(-2*time.Hour), api.CursorAccountIndividual, []api.CursorQuota{ + {Name: "total_usage", Used: 30.0, Limit: 400.0, Utilization: 7.5, Format: api.CursorFormatPercent, ResetsAt: &resetsAt}, + }) + snap2 := newTestCursorSnapshot(now.Add(-1*time.Hour), api.CursorAccountIndividual, []api.CursorQuota{ + {Name: "total_usage", Used: 50.0, Limit: 400.0, Utilization: 12.5, Format: api.CursorFormatPercent, ResetsAt: &resetsAt}, + {Name: "auto_usage", Utilization: 3.0, Format: api.CursorFormatPercent, ResetsAt: &resetsAt}, + }) + + if _, err := s.InsertCursorSnapshot(snap1); err != nil { + t.Fatalf("Insert snap1: %v", err) + } + if _, err := s.InsertCursorSnapshot(snap2); err != nil { + t.Fatalf("Insert snap2: %v", err) + } + + latest, err := s.QueryCursorLatestPerQuota() + if err != nil { + t.Fatalf("QueryCursorLatestPerQuota: %v", err) + } + if len(latest) != 2 { + t.Fatalf("Latest len = %d, want 2", len(latest)) + } + + for _, q := range latest { + if q.Name == "total_usage" { + if q.Utilization != 12.5 { + t.Errorf("total_usage Utilization = %f, want 12.5 (from newer snapshot)", q.Utilization) + } + } + } +} + +func TestCursorStore_LatestPerQuota_UsesOnlyLatestSnapshot(t *testing.T) { + s, err := New(":memory:") + if err != nil { + t.Fatalf("New: %v", err) + } + defer s.Close() + + now := time.Now().UTC() + oldReset := now.Add(30 * 24 * time.Hour) + + individual := &api.CursorSnapshot{ + CapturedAt: now.Add(-time.Hour), + AccountType: api.CursorAccountIndividual, + PlanName: "Pro", + Quotas: []api.CursorQuota{ + {Name: "total_usage", Used: 50, Limit: 400, Utilization: 12.5, Format: api.CursorFormatPercent, ResetsAt: &oldReset}, + {Name: "auto_usage", Utilization: 3, Format: api.CursorFormatPercent, ResetsAt: &oldReset}, + }, + } + enterprise := &api.CursorSnapshot{ + CapturedAt: now, + AccountType: api.CursorAccountEnterprise, + PlanName: "Enterprise", + Quotas: []api.CursorQuota{ + {Name: "requests_gpt-4.1", Used: 15, Limit: 100, Utilization: 15, Format: api.CursorFormatCount}, + }, + } + + if _, err := s.InsertCursorSnapshot(individual); err != nil { + t.Fatalf("Insert individual snapshot: %v", err) + } + if _, err := s.InsertCursorSnapshot(enterprise); err != nil { + t.Fatalf("Insert enterprise snapshot: %v", err) + } + + latest, err := s.QueryCursorLatestPerQuota() + if err != nil { + t.Fatalf("QueryCursorLatestPerQuota: %v", err) + } + if len(latest) != 1 { + t.Fatalf("Latest len = %d, want 1", len(latest)) + } + if latest[0].Name != "requests_gpt-4.1" { + t.Fatalf("Latest[0].Name = %q, want requests_gpt-4.1", latest[0].Name) + } + if latest[0].AccountType != string(api.CursorAccountEnterprise) { + t.Fatalf("Latest[0].AccountType = %q, want %q", latest[0].AccountType, api.CursorAccountEnterprise) + } +} diff --git a/internal/store/store.go b/internal/store/store.go index afa7751..fabe1df 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -627,6 +627,44 @@ func (s *Store) createTables() error { CREATE INDEX IF NOT EXISTS idx_openrouter_cycles_type_start ON openrouter_reset_cycles(quota_type, cycle_start); CREATE INDEX IF NOT EXISTS idx_openrouter_cycles_type_active ON openrouter_reset_cycles(quota_type, cycle_end) WHERE cycle_end IS NULL; + -- Cursor-specific tables + CREATE TABLE IF NOT EXISTS cursor_snapshots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + captured_at TEXT NOT NULL, + raw_json TEXT NOT NULL DEFAULT '', + account_type TEXT NOT NULL DEFAULT '', + plan_name TEXT NOT NULL DEFAULT '', + quota_count INTEGER NOT NULL DEFAULT 0 + ); + + CREATE TABLE IF NOT EXISTS cursor_quota_values ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + snapshot_id INTEGER NOT NULL, + quota_name TEXT NOT NULL, + used REAL NOT NULL DEFAULT 0, + limit_value REAL NOT NULL DEFAULT 0, + utilization REAL NOT NULL DEFAULT 0, + format TEXT NOT NULL DEFAULT 'percent', + resets_at TEXT, + FOREIGN KEY (snapshot_id) REFERENCES cursor_snapshots(id) + ); + + CREATE TABLE IF NOT EXISTS cursor_reset_cycles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + quota_name TEXT NOT NULL, + cycle_start TEXT NOT NULL, + cycle_end TEXT, + resets_at TEXT, + peak_utilization REAL NOT NULL DEFAULT 0, + total_delta REAL NOT NULL DEFAULT 0 + ); + + -- Cursor indexes + CREATE INDEX IF NOT EXISTS idx_cursor_snapshots_captured ON cursor_snapshots(captured_at); + CREATE INDEX IF NOT EXISTS idx_cursor_quota_values_snapshot ON cursor_quota_values(snapshot_id); + CREATE INDEX IF NOT EXISTS idx_cursor_cycles_name_start ON cursor_reset_cycles(quota_name, cycle_start); + CREATE INDEX IF NOT EXISTS idx_cursor_cycles_name_active ON cursor_reset_cycles(quota_name, cycle_end) WHERE cycle_end IS NULL; + -- API integrations telemetry ingestion tables CREATE TABLE IF NOT EXISTS api_integration_usage_events ( id INTEGER PRIMARY KEY AUTOINCREMENT, diff --git a/internal/tracker/cursor_tracker.go b/internal/tracker/cursor_tracker.go new file mode 100644 index 0000000..a102a0a --- /dev/null +++ b/internal/tracker/cursor_tracker.go @@ -0,0 +1,272 @@ +package tracker + +import ( + "fmt" + "log/slog" + "time" + + "github.com/onllm-dev/onwatch/v2/internal/api" + "github.com/onllm-dev/onwatch/v2/internal/store" +) + +type CursorTracker struct { + store *store.Store + logger *slog.Logger + lastValues map[string]float64 + lastResets map[string]string + hasLast bool + onReset func(quotaName string) +} + +func (t *CursorTracker) SetOnReset(fn func(string)) { + t.onReset = fn +} + +type CursorSummary struct { + QuotaName string + CurrentUtil float64 + ResetsAt *time.Time + TimeUntilReset time.Duration + CurrentRate float64 + ProjectedUtil float64 + CompletedCycles int + AvgPerCycle float64 + PeakCycle float64 + TotalTracked float64 + TrackingSince time.Time +} + +func NewCursorTracker(store *store.Store, logger *slog.Logger) *CursorTracker { + if logger == nil { + logger = slog.Default() + } + return &CursorTracker{ + store: store, + logger: logger, + lastValues: make(map[string]float64), + lastResets: make(map[string]string), + } +} + +func (t *CursorTracker) Process(snapshot *api.CursorSnapshot) error { + for _, quota := range snapshot.Quotas { + if err := t.processQuota(quota, snapshot.CapturedAt); err != nil { + return fmt.Errorf("cursor tracker: %s: %w", quota.Name, err) + } + } + + t.hasLast = true + return nil +} + +func (t *CursorTracker) processQuota(quota api.CursorQuota, capturedAt time.Time) error { + quotaName := quota.Name + currentUtil := quota.Utilization + + cycle, err := t.store.QueryActiveCursorCycle(quotaName) + if err != nil { + return fmt.Errorf("failed to query active cycle: %w", err) + } + + if cycle == nil { + _, err := t.store.CreateCursorCycle(quotaName, capturedAt, quota.ResetsAt) + if err != nil { + return fmt.Errorf("failed to create cycle: %w", err) + } + if err := t.store.UpdateCursorCycle(quotaName, currentUtil, 0); err != nil { + return fmt.Errorf("failed to set initial peak: %w", err) + } + t.lastValues[quotaName] = currentUtil + if quota.ResetsAt != nil { + t.lastResets[quotaName] = quota.ResetsAt.Format(time.RFC3339Nano) + } + t.logger.Info("Created new Cursor cycle", + "quota", quotaName, + "resetsAt", quota.ResetsAt, + "initialUtil", currentUtil, + ) + return nil + } + + resetDetected := false + resetReason := "" + if cycle.ResetsAt != nil && capturedAt.After(cycle.ResetsAt.Add(2*time.Minute)) { + resetDetected = true + resetReason = "time-based (stored ResetsAt passed)" + } + + if !resetDetected { + if quota.ResetsAt != nil && cycle.ResetsAt != nil { + diff := quota.ResetsAt.Sub(*cycle.ResetsAt) + if diff < 0 { + diff = -diff + } + if diff > 10*time.Minute { + resetDetected = true + resetReason = "api-based (ResetsAt changed)" + } + } else if quota.ResetsAt != nil && cycle.ResetsAt == nil { + resetDetected = true + resetReason = "api-based (new ResetsAt appeared)" + } + } + + if resetDetected { + cycleEndTime := capturedAt + if cycle.ResetsAt != nil && capturedAt.After(*cycle.ResetsAt) { + cycleEndTime = *cycle.ResetsAt + } + + if t.hasLast { + if lastUtil, ok := t.lastValues[quotaName]; ok { + delta := currentUtil - lastUtil + if delta > 0 { + cycle.TotalDelta += delta + } + if currentUtil > cycle.PeakUtilization { + cycle.PeakUtilization = currentUtil + } + } + } + + if err := t.store.CloseCursorCycle(quotaName, cycleEndTime, cycle.PeakUtilization, cycle.TotalDelta); err != nil { + return fmt.Errorf("failed to close cycle: %w", err) + } + + if _, err := t.store.CreateCursorCycle(quotaName, capturedAt, quota.ResetsAt); err != nil { + return fmt.Errorf("failed to create new cycle: %w", err) + } + if err := t.store.UpdateCursorCycle(quotaName, currentUtil, 0); err != nil { + return fmt.Errorf("failed to set initial peak: %w", err) + } + + t.lastValues[quotaName] = currentUtil + if quota.ResetsAt != nil { + t.lastResets[quotaName] = quota.ResetsAt.Format(time.RFC3339Nano) + } + t.logger.Info("Detected Cursor quota reset", + "quota", quotaName, + "reason", resetReason, + "oldResetsAt", cycle.ResetsAt, + "newResetsAt", quota.ResetsAt, + "cycleEndTime", cycleEndTime, + "totalDelta", cycle.TotalDelta, + ) + if t.onReset != nil { + t.onReset(quotaName) + } + return nil + } + + if t.hasLast { + if lastUtil, ok := t.lastValues[quotaName]; ok { + delta := currentUtil - lastUtil + if delta > 0 { + cycle.TotalDelta += delta + } + if currentUtil > cycle.PeakUtilization { + cycle.PeakUtilization = currentUtil + } + if err := t.store.UpdateCursorCycle(quotaName, cycle.PeakUtilization, cycle.TotalDelta); err != nil { + return fmt.Errorf("failed to update cycle: %w", err) + } + } else { + if currentUtil > cycle.PeakUtilization { + cycle.PeakUtilization = currentUtil + if err := t.store.UpdateCursorCycle(quotaName, cycle.PeakUtilization, cycle.TotalDelta); err != nil { + return fmt.Errorf("failed to update cycle: %w", err) + } + } + } + } else { + if currentUtil > cycle.PeakUtilization { + cycle.PeakUtilization = currentUtil + if err := t.store.UpdateCursorCycle(quotaName, cycle.PeakUtilization, cycle.TotalDelta); err != nil { + return fmt.Errorf("failed to update cycle: %w", err) + } + } + } + + t.lastValues[quotaName] = currentUtil + if quota.ResetsAt != nil { + t.lastResets[quotaName] = quota.ResetsAt.Format(time.RFC3339Nano) + } + return nil +} + +func (t *CursorTracker) UsageSummary(quotaName string) (*CursorSummary, error) { + activeCycle, err := t.store.QueryActiveCursorCycle(quotaName) + if err != nil { + return nil, fmt.Errorf("failed to query active cycle: %w", err) + } + + history, err := t.store.QueryCursorCycleHistory(quotaName) + if err != nil { + return nil, fmt.Errorf("failed to query cycle history: %w", err) + } + + summary := &CursorSummary{ + QuotaName: quotaName, + CompletedCycles: len(history), + } + + if len(history) > 0 { + var totalDelta float64 + summary.TrackingSince = history[len(history)-1].CycleStart + + for _, cycle := range history { + totalDelta += cycle.TotalDelta + if cycle.PeakUtilization > summary.PeakCycle { + summary.PeakCycle = cycle.PeakUtilization + } + } + summary.AvgPerCycle = totalDelta / float64(len(history)) + summary.TotalTracked = totalDelta + } + + if activeCycle != nil { + summary.TotalTracked += activeCycle.TotalDelta + if activeCycle.PeakUtilization > summary.PeakCycle { + summary.PeakCycle = activeCycle.PeakUtilization + } + if activeCycle.ResetsAt != nil { + summary.ResetsAt = activeCycle.ResetsAt + summary.TimeUntilReset = time.Until(*activeCycle.ResetsAt) + } + + latest, err := t.store.QueryLatestCursor() + if err != nil { + return nil, fmt.Errorf("failed to query latest: %w", err) + } + + if latest != nil { + for _, q := range latest.Quotas { + if q.Name == quotaName { + summary.CurrentUtil = q.Utilization + if summary.ResetsAt == nil && q.ResetsAt != nil { + summary.ResetsAt = q.ResetsAt + summary.TimeUntilReset = time.Until(*q.ResetsAt) + } + break + } + } + + elapsed := time.Since(activeCycle.CycleStart) + if elapsed.Minutes() >= 30 && activeCycle.TotalDelta > 0 { + summary.CurrentRate = activeCycle.TotalDelta / elapsed.Hours() + if summary.ResetsAt != nil { + hoursLeft := time.Until(*summary.ResetsAt).Hours() + if hoursLeft > 0 { + projected := summary.CurrentUtil + (summary.CurrentRate * hoursLeft) + if projected > 100 { + projected = 100 + } + summary.ProjectedUtil = projected + } + } + } + } + } + + return summary, nil +} diff --git a/internal/tracker/cursor_tracker_test.go b/internal/tracker/cursor_tracker_test.go new file mode 100644 index 0000000..6661e23 --- /dev/null +++ b/internal/tracker/cursor_tracker_test.go @@ -0,0 +1,278 @@ +package tracker + +import ( + "log/slog" + "testing" + "time" + + "github.com/onllm-dev/onwatch/v2/internal/api" + "github.com/onllm-dev/onwatch/v2/internal/store" +) + +func newTestCursorStore(t *testing.T) *store.Store { + t.Helper() + s, err := store.New(":memory:") + if err != nil { + t.Fatalf("store.New: %v", err) + } + t.Cleanup(func() { s.Close() }) + return s +} + +func TestCursorTracker_Process_FirstSnapshot(t *testing.T) { + s := newTestCursorStore(t) + tr := NewCursorTracker(s, slog.Default()) + + now := time.Now().UTC() + resetsAt := now.Add(30 * 24 * time.Hour) + + snapshot := &api.CursorSnapshot{ + CapturedAt: now, + AccountType: api.CursorAccountIndividual, + PlanName: "Pro", + Quotas: []api.CursorQuota{ + {Name: "total_usage", Used: 50.0, Limit: 400.0, Utilization: 12.5, Format: api.CursorFormatPercent, ResetsAt: &resetsAt}, + }, + } + + if err := tr.Process(snapshot); err != nil { + t.Fatalf("Process: %v", err) + } + + cycle, err := s.QueryActiveCursorCycle("total_usage") + if err != nil { + t.Fatalf("QueryActiveCursorCycle: %v", err) + } + if cycle == nil { + t.Fatal("Expected active cycle after first snapshot") + } + if cycle.PeakUtilization != 12.5 { + t.Errorf("PeakUtilization = %f, want 12.5", cycle.PeakUtilization) + } +} + +func TestCursorTracker_Process_UsageIncrease(t *testing.T) { + s := newTestCursorStore(t) + tr := NewCursorTracker(s, slog.Default()) + + now := time.Now().UTC() + resetsAt := now.Add(30 * 24 * time.Hour) + + snap1 := &api.CursorSnapshot{ + CapturedAt: now, + Quotas: []api.CursorQuota{ + {Name: "total_usage", Utilization: 12.5, Format: api.CursorFormatPercent, ResetsAt: &resetsAt}, + }, + } + if err := tr.Process(snap1); err != nil { + t.Fatalf("Process snap1: %v", err) + } + + snap2 := &api.CursorSnapshot{ + CapturedAt: now.Add(time.Minute), + Quotas: []api.CursorQuota{ + {Name: "total_usage", Utilization: 25.0, Format: api.CursorFormatPercent, ResetsAt: &resetsAt}, + }, + } + if err := tr.Process(snap2); err != nil { + t.Fatalf("Process snap2: %v", err) + } + + cycle, err := s.QueryActiveCursorCycle("total_usage") + if err != nil { + t.Fatalf("QueryActiveCursorCycle: %v", err) + } + if cycle == nil { + t.Fatal("Expected active cycle") + } + if cycle.PeakUtilization != 25.0 { + t.Errorf("PeakUtilization = %f, want 25.0", cycle.PeakUtilization) + } + if cycle.TotalDelta != 12.5 { + t.Errorf("TotalDelta = %f, want 12.5", cycle.TotalDelta) + } +} + +func TestCursorTracker_Process_ResetDetection(t *testing.T) { + s := newTestCursorStore(t) + tr := NewCursorTracker(s, slog.Default()) + + now := time.Now().UTC() + + // First cycle with resetsAt + resetsAt1 := now.Add(24 * time.Hour) + snap1 := &api.CursorSnapshot{ + CapturedAt: now, + Quotas: []api.CursorQuota{ + {Name: "total_usage", Utilization: 80.0, Format: api.CursorFormatPercent, ResetsAt: &resetsAt1}, + }, + } + if err := tr.Process(snap1); err != nil { + t.Fatalf("Process snap1: %v", err) + } + + // Reset: resetsAt jumps forward significantly (>10min diff) + resetsAt2 := now.Add(7 * 24 * time.Hour) + snap2 := &api.CursorSnapshot{ + CapturedAt: now.Add(25 * time.Hour), + Quotas: []api.CursorQuota{ + {Name: "total_usage", Utilization: 5.0, Format: api.CursorFormatPercent, ResetsAt: &resetsAt2}, + }, + } + if err := tr.Process(snap2); err != nil { + t.Fatalf("Process snap2: %v", err) + } + + // Old cycle should be closed, new cycle started + history, err := s.QueryCursorCycleHistory("total_usage", 10) + if err != nil { + t.Fatalf("QueryCursorCycleHistory: %v", err) + } + if len(history) != 1 { + t.Fatalf("History len = %d, want 1 (old cycle closed)", len(history)) + } + if history[0].PeakUtilization != 80.0 { + t.Errorf("Closed cycle PeakUtilization = %f, want 80.0", history[0].PeakUtilization) + } + + // New cycle should be active + active, err := s.QueryActiveCursorCycle("total_usage") + if err != nil { + t.Fatalf("QueryActiveCursorCycle: %v", err) + } + if active == nil { + t.Fatal("Expected new active cycle after reset") + } + if active.PeakUtilization != 5.0 { + t.Errorf("New cycle PeakUtilization = %f, want 5.0", active.PeakUtilization) + } +} + +func TestCursorTracker_Process_MultipleQuotas(t *testing.T) { + s := newTestCursorStore(t) + tr := NewCursorTracker(s, slog.Default()) + + now := time.Now().UTC() + resetsAt := now.Add(30 * 24 * time.Hour) + + snapshot := &api.CursorSnapshot{ + CapturedAt: now, + Quotas: []api.CursorQuota{ + {Name: "total_usage", Utilization: 15.0, Format: api.CursorFormatPercent, ResetsAt: &resetsAt}, + {Name: "auto_usage", Utilization: 3.0, Format: api.CursorFormatPercent, ResetsAt: &resetsAt}, + {Name: "api_usage", Utilization: 12.0, Format: api.CursorFormatPercent, ResetsAt: &resetsAt}, + }, + } + + if err := tr.Process(snapshot); err != nil { + t.Fatalf("Process: %v", err) + } + + cycleTotal, err := s.QueryActiveCursorCycle("total_usage") + if err != nil { + t.Fatalf("QueryActiveCursorCycle total_usage: %v", err) + } + if cycleTotal == nil { + t.Fatal("Expected active cycle for total_usage") + } + + cycleAuto, err := s.QueryActiveCursorCycle("auto_usage") + if err != nil { + t.Fatalf("QueryActiveCursorCycle auto_usage: %v", err) + } + if cycleAuto == nil { + t.Fatal("Expected active cycle for auto_usage") + } + + cycleApi, err := s.QueryActiveCursorCycle("api_usage") + if err != nil { + t.Fatalf("QueryActiveCursorCycle api_usage: %v", err) + } + if cycleApi == nil { + t.Fatal("Expected active cycle for api_usage") + } +} + +func TestCursorTracker_UsageSummary(t *testing.T) { + s := newTestCursorStore(t) + tr := NewCursorTracker(s, slog.Default()) + + now := time.Now().UTC() + resetsAt := now.Add(30 * 24 * time.Hour) + + snapshot := &api.CursorSnapshot{ + CapturedAt: now, + Quotas: []api.CursorQuota{ + {Name: "total_usage", Used: 50.0, Limit: 400.0, Utilization: 12.5, Format: api.CursorFormatPercent, ResetsAt: &resetsAt}, + }, + } + + if _, err := s.InsertCursorSnapshot(snapshot); err != nil { + t.Fatalf("InsertCursorSnapshot: %v", err) + } + if err := tr.Process(snapshot); err != nil { + t.Fatalf("Process: %v", err) + } + + summary, err := tr.UsageSummary("total_usage") + if err != nil { + t.Fatalf("UsageSummary: %v", err) + } + if summary == nil { + t.Fatal("UsageSummary returned nil") + } + if summary.QuotaName != "total_usage" { + t.Errorf("QuotaName = %q, want total_usage", summary.QuotaName) + } + if summary.CurrentUtil != 12.5 { + t.Errorf("CurrentUtil = %f, want 12.5", summary.CurrentUtil) + } + if summary.ResetsAt == nil { + t.Error("ResetsAt should not be nil") + } +} + +func TestCursorTracker_SetOnReset(t *testing.T) { + s := newTestCursorStore(t) + tr := NewCursorTracker(s, slog.Default()) + + resetCalled := false + resetQuota := "" + tr.SetOnReset(func(quotaName string) { + resetCalled = true + resetQuota = quotaName + }) + + now := time.Now().UTC() + + // First cycle + resetsAt1 := now.Add(24 * time.Hour) + snap1 := &api.CursorSnapshot{ + CapturedAt: now, + Quotas: []api.CursorQuota{ + {Name: "total_usage", Utilization: 80.0, Format: api.CursorFormatPercent, ResetsAt: &resetsAt1}, + }, + } + if err := tr.Process(snap1); err != nil { + t.Fatalf("Process snap1: %v", err) + } + + // Trigger reset + resetsAt2 := now.Add(7 * 24 * time.Hour) + snap2 := &api.CursorSnapshot{ + CapturedAt: now.Add(25 * time.Hour), + Quotas: []api.CursorQuota{ + {Name: "total_usage", Utilization: 5.0, Format: api.CursorFormatPercent, ResetsAt: &resetsAt2}, + }, + } + if err := tr.Process(snap2); err != nil { + t.Fatalf("Process snap2: %v", err) + } + + if !resetCalled { + t.Error("OnReset callback was not called") + } + if resetQuota != "total_usage" { + t.Errorf("OnReset quotaName = %q, want total_usage", resetQuota) + } +} diff --git a/internal/web/cursor_handlers.go b/internal/web/cursor_handlers.go new file mode 100644 index 0000000..f886b92 --- /dev/null +++ b/internal/web/cursor_handlers.go @@ -0,0 +1,568 @@ +package web + +import ( + "fmt" + "net/http" + "sort" + "time" + + "github.com/onllm-dev/onwatch/v2/internal/api" + "github.com/onllm-dev/onwatch/v2/internal/tracker" +) + +// cursorInsightsResponse is the JSON payload for Cursor deep insights. +type cursorInsightsResponse struct { + Stats []cursorInsightStat `json:"stats"` + Insights []insightItem `json:"insights"` +} + +// cursorInsightStat is a stats-row shape that carries linked forecast metadata for the Cursor dashboard. +type cursorInsightStat struct { + Value string `json:"value"` + Label string `json:"label"` + Sublabel string `json:"sublabel,omitempty"` + Key string `json:"key,omitempty"` + Metric string `json:"metric,omitempty"` + Severity string `json:"severity,omitempty"` + Desc string `json:"desc,omitempty"` +} + +var cursorQuotaDisplayOrder = map[string]int{ + "total_usage": 1, + "auto_usage": 2, + "api_usage": 3, + "credits": 4, + "on_demand": 5, +} + +var cursorDisplayNames = map[string]string{ + "total_usage": "Total Usage", + "auto_usage": "Auto + Composer", + "api_usage": "API Usage", + "credits": "Credits", + "on_demand": "On-Demand", +} + +func cursorDisplayName(name string) string { + if dn, ok := cursorDisplayNames[name]; ok { + return dn + } + return name +} + +func cursorQuotaOrder(name string) int { + if order, ok := cursorQuotaDisplayOrder[name]; ok { + return order + } + return 99 +} + +func utilStatus(util float64) string { + switch { + case util >= 95: + return "exhausted" + case util >= 80: + return "critical" + case util >= 60: + return "warning" + default: + return "healthy" + } +} + +type cursorQuotaRate struct { + Rate float64 + HasRate bool + TimeToReset time.Duration + TimeToExhaust time.Duration + ExhaustsFirst bool + ProjectedPct float64 +} + +func (h *Handler) computeCursorRate(quotaName string, currentUtil float64, summary *tracker.CursorSummary) cursorQuotaRate { + var result cursorQuotaRate + + if summary != nil && summary.ResetsAt != nil { + result.TimeToReset = time.Until(*summary.ResetsAt) + } + + if h.store != nil { + points, err := h.store.QueryCursorUtilizationSeries(quotaName, time.Now().Add(-30*time.Minute)) + if err == nil && len(points) >= 2 { + first := points[0] + last := points[len(points)-1] + elapsed := last.CapturedAt.Sub(first.CapturedAt) + if elapsed >= 5*time.Minute { + delta := last.Utilization - first.Utilization + if delta > 0 { + result.Rate = delta / elapsed.Hours() + result.HasRate = true + } else { + result.HasRate = true + } + } + } + } + + if !result.HasRate && summary != nil && summary.CurrentRate > 0 { + result.Rate = summary.CurrentRate + result.HasRate = true + } + + if result.HasRate && result.Rate > 0 { + remaining := 100 - currentUtil + if remaining > 0 { + result.TimeToExhaust = time.Duration(remaining / result.Rate * float64(time.Hour)) + } + if result.TimeToReset > 0 { + result.ProjectedPct = currentUtil + (result.Rate * result.TimeToReset.Hours()) + if result.ProjectedPct > 100 { + result.ProjectedPct = 100 + } + result.ExhaustsFirst = result.TimeToExhaust > 0 && result.TimeToExhaust < result.TimeToReset + } + } + + return result +} + +func buildCursorBurnRateInsight(quota api.CursorQuota, rate cursorQuotaRate) insightItem { + item := insightItem{ + Key: fmt.Sprintf("forecast_%s", quota.Name), + Title: fmt.Sprintf("%s Burn Rate", cursorDisplayName(quota.Name)), + } + + resetStr := "" + if rate.TimeToReset > 0 { + resetStr = formatDuration(rate.TimeToReset) + } + projected := quota.Utilization + if rate.ProjectedPct > projected { + projected = rate.ProjectedPct + } + sublabel := fmt.Sprintf("~%.0f%% by reset", projected) + if resetStr != "" { + sublabel = fmt.Sprintf("~%.0f%% by reset in %s", projected, resetStr) + } + + if !rate.HasRate { + item.Type = "forecast" + item.Severity = "info" + item.Metric = "Analyzing..." + item.Sublabel = sublabel + item.Desc = fmt.Sprintf("Currently at %.0f%%. Collecting more snapshots to estimate burn rate and refine reset projection.", quota.Utilization) + return item + } + + if rate.Rate < 0.01 { + item.Type = "forecast" + item.Severity = "positive" + item.Metric = "Idle" + item.Sublabel = sublabel + item.Desc = fmt.Sprintf("Currently at %.0f%%. No meaningful burn detected recently, so this quota looks stable through the rest of the cycle.", quota.Utilization) + return item + } + + item.Type = "forecast" + item.Metric = fmt.Sprintf("%.1f%%/hr", rate.Rate) + if rate.ExhaustsFirst { + exhaustStr := formatDuration(rate.TimeToExhaust) + item.Severity = "negative" + item.Sublabel = sublabel + item.Desc = fmt.Sprintf("Currently at %.0f%%. At this rate, projected %.0f%% by reset and likely to exhaust in %s before reset.", quota.Utilization, projected, exhaustStr) + return item + } + + if rate.ProjectedPct >= 80 { + item.Severity = "warning" + item.Sublabel = sublabel + item.Desc = fmt.Sprintf("Currently at %.0f%%. At this rate, projected %.0f%% by reset.", quota.Utilization, projected) + return item + } + + item.Severity = "positive" + item.Sublabel = sublabel + item.Desc = fmt.Sprintf("Currently at %.0f%%. At this rate, projected %.0f%% by reset.", quota.Utilization, projected) + return item +} + +func (h *Handler) buildCursorCurrent() map[string]interface{} { + now := time.Now().UTC() + response := map[string]interface{}{ + "capturedAt": now.Format(time.RFC3339), + "quotas": []interface{}{}, + } + + if h.store == nil { + return response + } + + latest, err := h.store.QueryLatestCursor() + if err != nil || latest == nil { + return response + } + + response["capturedAt"] = latest.CapturedAt.Format(time.RFC3339) + response["accountType"] = string(latest.AccountType) + response["planName"] = latest.PlanName + + latestPerQuota, err := h.store.QueryCursorLatestPerQuota() + if err != nil || len(latestPerQuota) == 0 { + for _, q := range latest.Quotas { + quotaMap := map[string]interface{}{ + "name": q.Name, + "displayName": cursorDisplayName(q.Name), + "utilization": q.Utilization, + "used": q.Used, + "limit": q.Limit, + "format": string(q.Format), + "status": utilStatus(q.Utilization), + "lastUpdatedAt": latest.CapturedAt.Format(time.RFC3339), + "ageSeconds": int64(now.Sub(latest.CapturedAt).Seconds()), + } + if q.ResetsAt != nil { + timeUntilReset := time.Until(*q.ResetsAt) + quotaMap["resetsAt"] = q.ResetsAt.Format(time.RFC3339) + quotaMap["timeUntilReset"] = formatDuration(timeUntilReset) + quotaMap["timeUntilResetSeconds"] = int64(timeUntilReset.Seconds()) + } + if h.cursorTracker != nil { + if summary, sErr := h.cursorTracker.UsageSummary(q.Name); sErr == nil && summary != nil { + quotaMap["currentRate"] = summary.CurrentRate + quotaMap["projectedUtil"] = summary.ProjectedUtil + } + } + response["quotas"] = append(response["quotas"].([]interface{}), quotaMap) + } + return response + } + + sort.SliceStable(latestPerQuota, func(i, j int) bool { + left := cursorQuotaOrder(latestPerQuota[i].Name) + right := cursorQuotaOrder(latestPerQuota[j].Name) + if left != right { + return left < right + } + return latestPerQuota[i].Name < latestPerQuota[j].Name + }) + + var quotas []interface{} + for _, q := range latestPerQuota { + age := now.Sub(q.CapturedAt) + qMap := map[string]interface{}{ + "name": q.Name, + "displayName": cursorDisplayName(q.Name), + "utilization": q.Utilization, + "used": q.Used, + "limit": q.Limit, + "format": q.Format, + "status": utilStatus(q.Utilization), + "lastUpdatedAt": q.CapturedAt.Format(time.RFC3339), + "ageSeconds": int64(age.Seconds()), + "isStale": age > 30*time.Minute, + } + if q.ResetsAt != nil { + timeUntilReset := time.Until(*q.ResetsAt) + qMap["resetsAt"] = q.ResetsAt.Format(time.RFC3339) + qMap["timeUntilReset"] = formatDuration(timeUntilReset) + qMap["timeUntilResetSeconds"] = int64(timeUntilReset.Seconds()) + } + if h.cursorTracker != nil { + if summary, sErr := h.cursorTracker.UsageSummary(q.Name); sErr == nil && summary != nil { + qMap["currentRate"] = summary.CurrentRate + qMap["projectedUtil"] = summary.ProjectedUtil + } + } + quotas = append(quotas, qMap) + } + response["quotas"] = quotas + return response +} + +func (h *Handler) historyCursor(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if h.store == nil { + respondJSON(w, http.StatusOK, []interface{}{}) + return + } + + rangeParam := r.URL.Query().Get("range") + if rangeParam == "" { + rangeParam = "7d" + } + + now := time.Now().UTC() + var start time.Time + switch rangeParam { + case "1h": + start = now.Add(-1 * time.Hour) + case "6h": + start = now.Add(-6 * time.Hour) + case "24h", "1d": + start = now.Add(-24 * time.Hour) + case "3d": + start = now.Add(-3 * 24 * time.Hour) + case "30d": + start = now.Add(-30 * 24 * time.Hour) + case "7d": + start = now.Add(-7 * 24 * time.Hour) + default: + start = now.Add(-7 * 24 * time.Hour) + } + + snapshots, err := h.store.QueryCursorRange(start, now, 200) + if err != nil { + h.logger.Error("failed to query Cursor history", "error", err) + respondError(w, http.StatusInternalServerError, "failed to query history") + return + } + + type historyEntry struct { + CapturedAt string `json:"capturedAt"` + Quotas []map[string]interface{} `json:"quotas"` + } + + result := make([]historyEntry, 0, len(snapshots)) + for _, snap := range snapshots { + entry := historyEntry{ + CapturedAt: snap.CapturedAt.Format(time.RFC3339), + } + for _, q := range snap.Quotas { + qMap := map[string]interface{}{ + "name": q.Name, + "utilization": q.Utilization, + "used": q.Used, + "limit": q.Limit, + "format": string(q.Format), + } + if q.ResetsAt != nil { + qMap["resetsAt"] = q.ResetsAt.Format(time.RFC3339) + } + entry.Quotas = append(entry.Quotas, qMap) + } + result = append(result, entry) + } + + respondJSON(w, http.StatusOK, result) +} + +func (h *Handler) cyclesCursor(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if h.store == nil { + respondJSON(w, http.StatusOK, []interface{}{}) + return + } + + quotaName := r.URL.Query().Get("type") + if quotaName == "" { + quotaName = "total_usage" + } + + active, err := h.store.QueryActiveCursorCycle(quotaName) + if err != nil { + h.logger.Error("failed to query active Cursor cycle", "error", err) + respondError(w, http.StatusInternalServerError, "failed to query cycles") + return + } + + history, err := h.store.QueryCursorCycleHistory(quotaName, 50) + if err != nil { + h.logger.Error("failed to query Cursor cycle history", "error", err) + respondError(w, http.StatusInternalServerError, "failed to query cycles") + return + } + + var cycles []map[string]interface{} + if active != nil { + cycleMap := map[string]interface{}{ + "id": active.ID, + "quotaName": active.QuotaName, + "cycleStart": active.CycleStart.Format(time.RFC3339), + "cycleEnd": nil, + "peakUtilization": active.PeakUtilization, + "totalDelta": active.TotalDelta, + "isActive": true, + } + if active.ResetsAt != nil { + cycleMap["resetsAt"] = active.ResetsAt.Format(time.RFC3339) + cycleMap["timeUntilReset"] = formatDuration(time.Until(*active.ResetsAt)) + } + cycles = append(cycles, cycleMap) + } + + for _, c := range history { + cycleMap := map[string]interface{}{ + "id": c.ID, + "quotaName": c.QuotaName, + "cycleStart": c.CycleStart.Format(time.RFC3339), + "cycleEnd": c.CycleEnd.Format(time.RFC3339), + "peakUtilization": c.PeakUtilization, + "totalDelta": c.TotalDelta, + "isActive": false, + } + if c.ResetsAt != nil { + cycleMap["resetsAt"] = c.ResetsAt.Format(time.RFC3339) + } + cycles = append(cycles, cycleMap) + } + + respondJSON(w, http.StatusOK, cycles) +} + +func (h *Handler) cycleOverviewCursor(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if h.store == nil { + respondJSON(w, http.StatusOK, []interface{}{}) + return + } + + groupBy := r.URL.Query().Get("group_by") + if groupBy == "" { + groupBy = "total_usage" + } + + overview, err := h.store.QueryCursorCycleOverview(groupBy, 50) + if err != nil { + h.logger.Error("failed to query Cursor cycle overview", "error", err) + respondError(w, http.StatusInternalServerError, "failed to query cycle overview") + return + } + + respondJSON(w, http.StatusOK, overview) +} + +func (h *Handler) summaryCursor(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + respondJSON(w, http.StatusOK, h.buildCursorSummaryMap()) +} + +func (h *Handler) insightsCursor(w http.ResponseWriter, _ *http.Request, rangeDur time.Duration) { + hidden := h.getHiddenInsightKeys() + respondJSON(w, http.StatusOK, h.buildCursorInsights(hidden, rangeDur)) +} + +func (h *Handler) cursorQuotaNames() []string { + if h.store == nil { + return nil + } + names, err := h.store.QueryAllCursorQuotaNames() + if err != nil { + return nil + } + return names +} + +func (h *Handler) buildCursorSummaryMap() map[string]interface{} { + if h.store == nil || h.cursorTracker == nil { + return map[string]interface{}{} + } + + quotaNames, err := h.store.QueryAllCursorQuotaNames() + if err != nil { + h.logger.Error("failed to query Cursor quota names", "error", err) + return map[string]interface{}{} + } + + result := make(map[string]interface{}) + for _, name := range quotaNames { + summary, err := h.cursorTracker.UsageSummary(name) + if err != nil || summary == nil { + continue + } + entry := map[string]interface{}{ + "currentUtil": summary.CurrentUtil, + "completedCycles": summary.CompletedCycles, + "peakCycle": summary.PeakCycle, + "avgPerCycle": summary.AvgPerCycle, + "totalTracked": summary.TotalTracked, + } + if summary.ResetsAt != nil { + entry["resetsAt"] = summary.ResetsAt.Format(time.RFC3339) + entry["timeUntilReset"] = formatDuration(summary.TimeUntilReset) + } + result[name] = entry + } + return result +} + +func (h *Handler) buildCursorInsights(hidden map[string]bool, _ time.Duration) cursorInsightsResponse { + resp := cursorInsightsResponse{Stats: []cursorInsightStat{}, Insights: []insightItem{}} + + if h.store == nil { + return resp + } + + latest, err := h.store.QueryLatestCursor() + if err != nil || latest == nil || len(latest.Quotas) == 0 { + return resp + } + + planLabel := latest.PlanName + if planLabel == "" { + planLabel = string(latest.AccountType) + } + if planLabel != "" { + resp.Stats = append(resp.Stats, cursorInsightStat{ + Label: "Plan", + Value: planLabel, + }) + } + + quotas := append([]api.CursorQuota(nil), latest.Quotas...) + sort.SliceStable(quotas, func(i, j int) bool { + left := cursorQuotaOrder(quotas[i].Name) + right := cursorQuotaOrder(quotas[j].Name) + if left != right { + return left < right + } + return quotas[i].Name < quotas[j].Name + }) + + summaries := map[string]*tracker.CursorSummary{} + if h.cursorTracker != nil { + for _, quota := range quotas { + summary, err := h.cursorTracker.UsageSummary(quota.Name) + if err == nil && summary != nil { + summaries[quota.Name] = summary + } + } + } + + preferredQuotas := []string{"total_usage", "auto_usage", "api_usage"} + selected := make([]api.CursorQuota, 0, len(preferredQuotas)) + for _, name := range preferredQuotas { + for _, quota := range quotas { + if quota.Name == name { + selected = append(selected, quota) + break + } + } + } + if len(selected) == 0 { + selected = quotas + } + + for _, quota := range selected { + rate := h.computeCursorRate(quota.Name, quota.Utilization, summaries[quota.Name]) + insightKey := fmt.Sprintf("forecast_%s", quota.Name) + if hidden[insightKey] { + continue + } + value := "Analyzing..." + if rate.HasRate { + value = fmt.Sprintf("%.1f%%/hr", rate.Rate) + } + insight := buildCursorBurnRateInsight(quota, rate) + resp.Stats = append(resp.Stats, cursorInsightStat{ + Key: insightKey, + Label: fmt.Sprintf("%s Burn Rate", cursorDisplayName(quota.Name)), + Value: value, + Sublabel: insight.Sublabel, + Metric: insight.Metric, + Severity: insight.Severity, + Desc: insight.Desc, + }) + } + + return resp +} diff --git a/internal/web/cursor_handlers_test.go b/internal/web/cursor_handlers_test.go new file mode 100644 index 0000000..c709b25 --- /dev/null +++ b/internal/web/cursor_handlers_test.go @@ -0,0 +1,509 @@ +package web + +import ( + "encoding/json" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/onllm-dev/onwatch/v2/internal/api" + "github.com/onllm-dev/onwatch/v2/internal/config" + "github.com/onllm-dev/onwatch/v2/internal/store" + "github.com/onllm-dev/onwatch/v2/internal/tracker" +) + +func createTestConfigWithCursor() *config.Config { + return &config.Config{ + CursorToken: "cursor_test_token", + PollInterval: 60 * time.Second, + Port: 9211, + AdminUser: "admin", + AdminPass: "test", + DBPath: "./test.db", + } +} + +func createTestConfigWithCursorAndAll() *config.Config { + return &config.Config{ + SyntheticAPIKey: "syn_test_key", + ZaiAPIKey: "zai_test_key", + ZaiBaseURL: "https://api.z.ai/api", + AnthropicToken: "test_anthropic_token", + CopilotToken: "ghp_test_copilot_token", + CodexToken: "codex_test_token", + AntigravityEnabled: true, + MiniMaxAPIKey: "minimax_test_key", + GeminiEnabled: true, + CursorToken: "cursor_test_token", + PollInterval: 60 * time.Second, + Port: 9211, + AdminUser: "admin", + AdminPass: "test", + DBPath: "./test.db", + } +} + +func insertTestCursorSnapshot(t *testing.T, s *store.Store, capturedAt time.Time, accountType api.CursorAccountType, planName string, quotas []api.CursorQuota) { + t.Helper() + snap := &api.CursorSnapshot{ + CapturedAt: capturedAt, + AccountType: accountType, + PlanName: planName, + Quotas: quotas, + } + if _, err := s.InsertCursorSnapshot(snap); err != nil { + t.Fatalf("failed to insert test Cursor snapshot: %v", err) + } +} + +func insertTrackedCursorSnapshot(t *testing.T, s *store.Store, tr *tracker.CursorTracker, capturedAt time.Time, quotas []api.CursorQuota) { + t.Helper() + snap := &api.CursorSnapshot{ + CapturedAt: capturedAt, + AccountType: api.CursorAccountIndividual, + PlanName: "Pro+", + Quotas: quotas, + } + if _, err := s.InsertCursorSnapshot(snap); err != nil { + t.Fatalf("failed to insert tracked Cursor snapshot: %v", err) + } + if err := tr.Process(snap); err != nil { + t.Fatalf("failed to process tracked Cursor snapshot: %v", err) + } +} + +func TestBuildCursorCurrent_UsesLatestSnapshotMetadata(t *testing.T) { + s, err := store.New(":memory:") + if err != nil { + t.Fatalf("store.New: %v", err) + } + defer s.Close() + + now := time.Now().UTC() + oldReset := now.Add(24 * time.Hour) + + oldSnap := &api.CursorSnapshot{ + CapturedAt: now.Add(-2 * time.Hour), + AccountType: api.CursorAccountIndividual, + PlanName: "Pro", + Quotas: []api.CursorQuota{ + {Name: "api_usage", Utilization: 12, Format: api.CursorFormatPercent, ResetsAt: &oldReset}, + }, + } + newSnap := &api.CursorSnapshot{ + CapturedAt: now.Add(-10 * time.Minute), + AccountType: api.CursorAccountEnterprise, + PlanName: "Enterprise", + Quotas: []api.CursorQuota{ + {Name: "requests_gpt-4.1", Used: 15, Limit: 100, Utilization: 15, Format: api.CursorFormatCount}, + }, + } + + if _, err := s.InsertCursorSnapshot(oldSnap); err != nil { + t.Fatalf("insert old snapshot: %v", err) + } + if _, err := s.InsertCursorSnapshot(newSnap); err != nil { + t.Fatalf("insert new snapshot: %v", err) + } + + h := NewHandler(s, nil, nil, nil, createTestConfigWithCursor()) + current := h.buildCursorCurrent() + + if got := current["accountType"]; got != string(api.CursorAccountEnterprise) { + t.Fatalf("accountType = %v, want %q", got, api.CursorAccountEnterprise) + } + if got := current["planName"]; got != "Enterprise" { + t.Fatalf("planName = %v, want Enterprise", got) + } +} + +func TestCursorInsights_ViaRouter(t *testing.T) { + s, err := store.New(":memory:") + if err != nil { + t.Fatalf("store.New: %v", err) + } + defer s.Close() + + now := time.Now().UTC() + resetTime := now.Add(3 * time.Hour) + insertTestCursorSnapshot(t, s, now.Add(-20*time.Minute), api.CursorAccountIndividual, "Pro+", []api.CursorQuota{ + {Name: "total_usage", Used: 45, Limit: 100, Utilization: 45, Format: api.CursorFormatPercent, ResetsAt: &resetTime}, + {Name: "auto_usage", Used: 20, Limit: 100, Utilization: 20, Format: api.CursorFormatPercent, ResetsAt: &resetTime}, + }) + + h := NewHandler(s, nil, nil, nil, createTestConfigWithCursor()) + + req := httptest.NewRequest(http.MethodGet, "/api/insights?provider=cursor&range=7d", nil) + rr := httptest.NewRecorder() + h.Insights(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) + } + + var resp map[string]interface{} + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to parse JSON: %v", err) + } + + if _, ok := resp["stats"].([]interface{}); !ok { + t.Fatalf("expected stats array, got: %v", resp) + } + if _, ok := resp["insights"].([]interface{}); !ok { + t.Fatalf("expected insights array, got: %v", resp) + } +} + +func TestBuildCursorInsights_ShowsQuotaBurnRates(t *testing.T) { + s, err := store.New(":memory:") + if err != nil { + t.Fatalf("store.New: %v", err) + } + defer s.Close() + + now := time.Now().UTC() + resetTime := now.Add(3 * time.Hour) + tr := tracker.NewCursorTracker(s, slog.Default()) + insertTrackedCursorSnapshot(t, s, tr, now.Add(-40*time.Minute), []api.CursorQuota{ + {Name: "total_usage", Used: 30, Limit: 100, Utilization: 30, Format: api.CursorFormatPercent, ResetsAt: &resetTime}, + {Name: "auto_usage", Used: 10, Limit: 100, Utilization: 10, Format: api.CursorFormatPercent, ResetsAt: &resetTime}, + {Name: "api_usage", Used: 5, Limit: 100, Utilization: 5, Format: api.CursorFormatPercent, ResetsAt: &resetTime}, + }) + insertTrackedCursorSnapshot(t, s, tr, now.Add(-10*time.Minute), []api.CursorQuota{ + {Name: "total_usage", Used: 45, Limit: 100, Utilization: 45, Format: api.CursorFormatPercent, ResetsAt: &resetTime}, + {Name: "auto_usage", Used: 25, Limit: 100, Utilization: 25, Format: api.CursorFormatPercent, ResetsAt: &resetTime}, + {Name: "api_usage", Used: 15, Limit: 100, Utilization: 15, Format: api.CursorFormatPercent, ResetsAt: &resetTime}, + }) + + h := NewHandler(s, nil, nil, nil, createTestConfigWithCursor()) + h.cursorTracker = tr + resp := h.buildCursorInsights(map[string]bool{}, 7*24*time.Hour) + + labels := map[string]bool{} + for _, stat := range resp.Stats { + labels[stat.Label] = true + } + + for _, want := range []string{"Total Usage Burn Rate", "Auto + Composer Burn Rate", "API Usage Burn Rate"} { + if !labels[want] { + t.Fatalf("missing stat %q in %+v", want, resp.Stats) + } + } +} + +func TestBuildCursorInsights_IncludesPlanStat(t *testing.T) { + s, err := store.New(":memory:") + if err != nil { + t.Fatalf("store.New: %v", err) + } + defer s.Close() + + now := time.Now().UTC() + resetTime := now.Add(2 * time.Hour) + insertTestCursorSnapshot(t, s, now.Add(-10*time.Minute), api.CursorAccountIndividual, "Pro+", []api.CursorQuota{ + {Name: "total_usage", Used: 12, Limit: 100, Utilization: 12, Format: api.CursorFormatPercent, ResetsAt: &resetTime}, + }) + + h := NewHandler(s, nil, nil, nil, createTestConfigWithCursor()) + resp := h.buildCursorInsights(map[string]bool{}, 7*24*time.Hour) + + var planStat *cursorInsightStat + for i := range resp.Stats { + if resp.Stats[i].Label == "Plan" { + planStat = &resp.Stats[i] + break + } + } + if planStat == nil { + t.Fatal("missing Plan stat") + } + if planStat.Value != "Pro+" { + t.Fatalf("Plan stat value = %q, want %q", planStat.Value, "Pro+") + } +} + +func TestBuildCursorInsights_ShowsProjectedBurnRateInsights(t *testing.T) { + s, err := store.New(":memory:") + if err != nil { + t.Fatalf("store.New: %v", err) + } + defer s.Close() + + now := time.Now().UTC() + resetTime := now.Add(3 * time.Hour) + tr := tracker.NewCursorTracker(s, slog.Default()) + insertTrackedCursorSnapshot(t, s, tr, now.Add(-40*time.Minute), []api.CursorQuota{ + {Name: "total_usage", Used: 25, Limit: 100, Utilization: 25, Format: api.CursorFormatPercent, ResetsAt: &resetTime}, + {Name: "auto_usage", Used: 10, Limit: 100, Utilization: 10, Format: api.CursorFormatPercent, ResetsAt: &resetTime}, + {Name: "api_usage", Used: 15, Limit: 100, Utilization: 15, Format: api.CursorFormatPercent, ResetsAt: &resetTime}, + }) + insertTrackedCursorSnapshot(t, s, tr, now.Add(-10*time.Minute), []api.CursorQuota{ + {Name: "total_usage", Used: 65, Limit: 100, Utilization: 65, Format: api.CursorFormatPercent, ResetsAt: &resetTime}, + {Name: "auto_usage", Used: 25, Limit: 100, Utilization: 25, Format: api.CursorFormatPercent, ResetsAt: &resetTime}, + {Name: "api_usage", Used: 18, Limit: 100, Utilization: 18, Format: api.CursorFormatPercent, ResetsAt: &resetTime}, + }) + + h := NewHandler(s, nil, nil, nil, createTestConfigWithCursor()) + h.cursorTracker = tr + resp := h.buildCursorInsights(map[string]bool{}, 7*24*time.Hour) + + statByLabel := map[string]cursorInsightStat{} + for _, s := range resp.Stats { + statByLabel[s.Label] = s + } + + totalBurn, ok := statByLabel["Total Usage Burn Rate"] + if !ok { + t.Fatalf("missing Total Usage Burn Rate stat in %+v", resp.Stats) + } + if totalBurn.Severity != "negative" { + t.Fatalf("Total Usage Burn Rate severity = %q, want %q", totalBurn.Severity, "negative") + } + if totalBurn.Metric == "" || totalBurn.Metric == "Analyzing..." { + t.Fatalf("expected burn rate metric, got %q", totalBurn.Metric) + } + if totalBurn.Sublabel == "" { + t.Fatal("expected projected sublabel for total burn stat") + } + + autoBurn, ok := statByLabel["Auto + Composer Burn Rate"] + if !ok { + t.Fatalf("missing Auto + Composer Burn Rate stat in %+v", resp.Stats) + } + if autoBurn.Severity != "warning" { + t.Fatalf("Auto + Composer Burn Rate severity = %q, want %q", autoBurn.Severity, "warning") + } + + apiBurn, ok := statByLabel["API Usage Burn Rate"] + if !ok { + t.Fatalf("missing API Usage Burn Rate stat in %+v", resp.Stats) + } + if apiBurn.Severity != "positive" { + t.Fatalf("API Usage Burn Rate severity = %q, want %q", apiBurn.Severity, "positive") + } + + // Burn rate analysis must no longer appear as separate insight cards + for _, item := range resp.Insights { + if item.Key == "forecast_total_usage" || item.Key == "forecast_auto_usage" || item.Key == "forecast_api_usage" { + t.Errorf("burn rate insight %q should be embedded in stats, not in insights", item.Key) + } + } +} + +func TestBuildCursorInsights_PlanStatFirst(t *testing.T) { + s, err := store.New(":memory:") + if err != nil { + t.Fatalf("store.New: %v", err) + } + defer s.Close() + + now := time.Now().UTC() + resetTime := now.Add(2 * time.Hour) + insertTestCursorSnapshot(t, s, now.Add(-10*time.Minute), api.CursorAccountIndividual, "Pro+", []api.CursorQuota{ + {Name: "total_usage", Used: 12, Limit: 100, Utilization: 12, Format: api.CursorFormatPercent, ResetsAt: &resetTime}, + {Name: "auto_usage", Used: 8, Limit: 100, Utilization: 8, Format: api.CursorFormatPercent, ResetsAt: &resetTime}, + }) + + h := NewHandler(s, nil, nil, nil, createTestConfigWithCursor()) + resp := h.buildCursorInsights(map[string]bool{}, 7*24*time.Hour) + + if len(resp.Stats) == 0 { + t.Fatal("expected stats") + } + if resp.Stats[0].Label != "Plan" { + t.Fatalf("first stat label = %q, want Plan", resp.Stats[0].Label) + } +} + +func TestBuildCursorCurrent_UsesHealthyStatusBelowWarning(t *testing.T) { + s, err := store.New(":memory:") + if err != nil { + t.Fatalf("store.New: %v", err) + } + defer s.Close() + + now := time.Now().UTC() + resetTime := now.Add(2 * time.Hour) + insertTestCursorSnapshot(t, s, now.Add(-10*time.Minute), api.CursorAccountIndividual, "Pro+", []api.CursorQuota{ + {Name: "total_usage", Used: 12, Limit: 100, Utilization: 12, Format: api.CursorFormatPercent, ResetsAt: &resetTime}, + }) + + h := NewHandler(s, nil, nil, nil, createTestConfigWithCursor()) + current := h.buildCursorCurrent() + + quotas, ok := current["quotas"].([]interface{}) + if !ok || len(quotas) == 0 { + t.Fatalf("expected quotas in current response, got %#v", current["quotas"]) + } + + firstQuota, ok := quotas[0].(map[string]interface{}) + if !ok { + t.Fatalf("expected quota map, got %#v", quotas[0]) + } + if got := firstQuota["status"]; got != "healthy" { + t.Fatalf("status = %v, want healthy", got) + } +} + +func TestLoggingHistoryCursor_ViaRouter(t *testing.T) { + s, err := store.New(":memory:") + if err != nil { + t.Fatalf("store.New: %v", err) + } + defer s.Close() + + now := time.Now().UTC() + resetTime := now.Add(7 * 24 * time.Hour) + insertTestCursorSnapshot(t, s, now.Add(-2*time.Hour), api.CursorAccountIndividual, "Pro+", []api.CursorQuota{ + {Name: "total_usage", Used: 20, Limit: 100, Utilization: 20, Format: api.CursorFormatPercent, ResetsAt: &resetTime}, + {Name: "auto_usage", Used: 8, Limit: 100, Utilization: 8, Format: api.CursorFormatPercent, ResetsAt: &resetTime}, + }) + insertTestCursorSnapshot(t, s, now.Add(-1*time.Hour), api.CursorAccountIndividual, "Pro+", []api.CursorQuota{ + {Name: "total_usage", Used: 35, Limit: 100, Utilization: 35, Format: api.CursorFormatPercent, ResetsAt: &resetTime}, + {Name: "auto_usage", Used: 12, Limit: 100, Utilization: 12, Format: api.CursorFormatPercent, ResetsAt: &resetTime}, + }) + + h := NewHandler(s, nil, nil, nil, createTestConfigWithCursor()) + req := httptest.NewRequest(http.MethodGet, "/api/logging-history?provider=cursor&range=1", nil) + rr := httptest.NewRecorder() + h.LoggingHistory(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) + } + + var resp struct { + Provider string `json:"provider"` + QuotaNames []string `json:"quotaNames"` + Logs []map[string]interface{} `json:"logs"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { + t.Fatalf("json.Unmarshal: %v", err) + } + if resp.Provider != "cursor" { + t.Fatalf("provider = %q, want cursor", resp.Provider) + } + if len(resp.QuotaNames) < 2 || resp.QuotaNames[0] != "total_usage" { + t.Fatalf("unexpected quotaNames: %#v", resp.QuotaNames) + } + if len(resp.Logs) != 2 { + t.Fatalf("logs len = %d, want 2", len(resp.Logs)) + } +} + +func TestBothHistory_IncludesCursor(t *testing.T) { + s, err := store.New(":memory:") + if err != nil { + t.Fatalf("store.New: %v", err) + } + defer s.Close() + + now := time.Now().UTC() + resetTime := now.Add(2 * time.Hour) + insertTestCursorSnapshot(t, s, now.Add(-20*time.Minute), api.CursorAccountIndividual, "Pro+", []api.CursorQuota{ + {Name: "total_usage", Used: 10, Limit: 100, Utilization: 10, Format: api.CursorFormatPercent, ResetsAt: &resetTime}, + }) + insertTestCursorSnapshot(t, s, now.Add(-10*time.Minute), api.CursorAccountIndividual, "Pro+", []api.CursorQuota{ + {Name: "total_usage", Used: 25, Limit: 100, Utilization: 25, Format: api.CursorFormatPercent, ResetsAt: &resetTime}, + }) + + h := NewHandler(s, nil, nil, nil, createTestConfigWithCursorAndAll()) + + req := httptest.NewRequest(http.MethodGet, "/api/history?provider=both&range=1h", nil) + rr := httptest.NewRecorder() + h.History(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) + } + + var resp map[string]interface{} + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to parse JSON: %v", err) + } + + cursor, ok := resp["cursor"].([]interface{}) + if !ok { + t.Fatalf("expected 'cursor' key in both history response, keys: %v", keysOf(resp)) + } + if len(cursor) == 0 { + t.Fatal("expected non-empty cursor history array") + } +} + +func TestBothInsights_IncludesCursor(t *testing.T) { + s, err := store.New(":memory:") + if err != nil { + t.Fatalf("store.New: %v", err) + } + defer s.Close() + + now := time.Now().UTC() + resetTime := now.Add(2 * time.Hour) + insertTestCursorSnapshot(t, s, now.Add(-15*time.Minute), api.CursorAccountIndividual, "Pro+", []api.CursorQuota{ + {Name: "total_usage", Used: 35, Limit: 100, Utilization: 35, Format: api.CursorFormatPercent, ResetsAt: &resetTime}, + {Name: "api_usage", Used: 10, Limit: 100, Utilization: 10, Format: api.CursorFormatPercent, ResetsAt: &resetTime}, + }) + + h := NewHandler(s, nil, nil, nil, createTestConfigWithCursorAndAll()) + + req := httptest.NewRequest(http.MethodGet, "/api/insights?provider=both&range=7d", nil) + rr := httptest.NewRecorder() + h.Insights(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) + } + + var resp map[string]interface{} + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to parse JSON: %v", err) + } + + cursor, ok := resp["cursor"].(map[string]interface{}) + if !ok { + t.Fatalf("expected 'cursor' key in both insights response, keys: %v", keysOf(resp)) + } + if _, ok := cursor["stats"]; !ok { + t.Error("cursor insights missing 'stats'") + } + if _, ok := cursor["insights"]; !ok { + t.Error("cursor insights missing 'insights'") + } +} + +func TestHistoryCursor_ThirtyDayRange(t *testing.T) { + s, err := store.New(":memory:") + if err != nil { + t.Fatalf("store.New: %v", err) + } + defer s.Close() + + now := time.Now().UTC() + resetTime := now.Add(31 * 24 * time.Hour) + insertTestCursorSnapshot(t, s, now.Add(-20*24*time.Hour), api.CursorAccountIndividual, "Pro+", []api.CursorQuota{ + {Name: "total_usage", Used: 20, Limit: 100, Utilization: 20, Format: api.CursorFormatPercent, ResetsAt: &resetTime}, + }) + insertTestCursorSnapshot(t, s, now.Add(-2*24*time.Hour), api.CursorAccountIndividual, "Pro+", []api.CursorQuota{ + {Name: "total_usage", Used: 35, Limit: 100, Utilization: 35, Format: api.CursorFormatPercent, ResetsAt: &resetTime}, + }) + + h := NewHandler(s, nil, nil, nil, createTestConfigWithCursor()) + req := httptest.NewRequest(http.MethodGet, "/api/history?provider=cursor&range=30d", nil) + rr := httptest.NewRecorder() + h.History(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) + } + + var resp []map[string]interface{} + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { + t.Fatalf("json.Unmarshal: %v", err) + } + if len(resp) != 2 { + t.Fatalf("history len = %d, want 2", len(resp)) + } +} diff --git a/internal/web/handlers.go b/internal/web/handlers.go index 547a5d9..0835126 100644 --- a/internal/web/handlers.go +++ b/internal/web/handlers.go @@ -91,6 +91,7 @@ type Handler struct { minimaxTracker *tracker.MiniMaxTracker geminiTracker *tracker.GeminiTracker openrouterTracker *tracker.OpenRouterTracker + cursorTracker *tracker.CursorTracker updater *update.Updater notifier Notifier agentManager ProviderAgentController @@ -811,6 +812,11 @@ func (h *Handler) SetOpenRouterTracker(t *tracker.OpenRouterTracker) { h.openrouterTracker = t } +// SetCursorTracker sets the Cursor tracker for usage summary enrichment. +func (h *Handler) SetCursorTracker(t *tracker.CursorTracker) { + h.cursorTracker = t +} + // SetAgentManager sets provider agent lifecycle controller. func (h *Handler) SetAgentManager(m ProviderAgentController) { h.agentManager = m @@ -1145,6 +1151,7 @@ func providerCatalog() []providerCatalogItem { {Key: "minimax", Name: "MiniMax", Description: "MiniMax Coding Plan usage tracking"}, {Key: "openrouter", Name: "OpenRouter", Description: "OpenRouter credits usage tracking"}, {Key: "gemini", Name: "Gemini", Description: "Google Gemini CLI quota tracking", AutoDetectable: true}, + {Key: "cursor", Name: "Cursor", Description: "Cursor usage and quota tracking", AutoDetectable: true}, } } @@ -1202,6 +1209,8 @@ func (h *Handler) isProviderConfigured(provider string) bool { return strings.TrimSpace(h.config.OpenRouterAPIKey) != "" case "gemini": return h.config.GeminiEnabled + case "cursor": + return strings.TrimSpace(h.config.CursorToken) != "" || strings.TrimSpace(api.DetectCursorToken(h.logger)) != "" default: return false } @@ -1798,11 +1807,19 @@ func (h *Handler) Current(w http.ResponseWriter, r *http.Request) { h.currentOpenRouter(w, r) case "gemini": h.currentGemini(w, r) + case "cursor": + h.currentCursor(w, r) default: respondError(w, http.StatusBadRequest, fmt.Sprintf("unknown provider: %s", provider)) } } +func (h *Handler) currentCursor(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + response := h.buildCursorCurrent() + json.NewEncoder(w).Encode(response) +} + // currentBoth returns combined quota status for all configured providers. func (h *Handler) currentBoth(w http.ResponseWriter, r *http.Request) { response := map[string]interface{}{} @@ -1867,6 +1884,9 @@ func (h *Handler) currentBoth(w http.ResponseWriter, r *http.Request) { if h.config.HasProvider("gemini") && providerTelemetryEnabled(visibility, "gemini") { response["gemini"] = h.buildGeminiCurrent() } + if h.config.HasProvider("cursor") && providerTelemetryEnabled(visibility, "cursor") { + response["cursor"] = h.buildCursorCurrent() + } respondJSON(w, http.StatusOK, response) } @@ -2205,6 +2225,8 @@ func (h *Handler) History(w http.ResponseWriter, r *http.Request) { h.historyOpenRouter(w, r) case "gemini": h.historyGemini(w, r) + case "cursor": + h.historyCursor(w, r) default: respondError(w, http.StatusBadRequest, fmt.Sprintf("unknown provider: %s", provider)) } @@ -2500,6 +2522,28 @@ func (h *Handler) historyBoth(w http.ResponseWriter, r *http.Request) { } } + if h.config.HasProvider("cursor") && providerTelemetryEnabled(visibility, "cursor") && h.store != nil { + snapshots, err := h.store.QueryCursorRange(start, now, 200) + if err == nil { + step := downsampleStep(len(snapshots), maxChartPoints) + last := len(snapshots) - 1 + cursorData := make([]map[string]interface{}, 0, min(len(snapshots), maxChartPoints)) + for i, snap := range snapshots { + if step > 1 && i != 0 && i != last && i%step != 0 { + continue + } + entry := map[string]interface{}{ + "capturedAt": snap.CapturedAt.Format(time.RFC3339), + } + for _, q := range snap.Quotas { + entry[q.Name] = q.Utilization + } + cursorData = append(cursorData, entry) + } + response["cursor"] = cursorData + } + } + respondJSON(w, http.StatusOK, response) } @@ -3096,6 +3140,8 @@ func (h *Handler) Cycles(w http.ResponseWriter, r *http.Request) { h.cyclesOpenRouter(w, r) case "gemini": h.cyclesGemini(w, r) + case "cursor": + h.cyclesCursor(w, r) default: respondError(w, http.StatusBadRequest, fmt.Sprintf("unknown provider: %s", provider)) } @@ -3413,6 +3459,8 @@ func (h *Handler) Summary(w http.ResponseWriter, r *http.Request) { h.summaryOpenRouter(w, r) case "gemini": h.summaryGemini(w, r) + case "cursor": + h.summaryCursor(w, r) default: respondError(w, http.StatusBadRequest, fmt.Sprintf("unknown provider: %s", provider)) } @@ -3480,6 +3528,9 @@ func (h *Handler) summaryBoth(w http.ResponseWriter, r *http.Request) { } response["gemini"] = geminiSummaries } + if h.config.HasProvider("cursor") && h.cursorTracker != nil { + response["cursor"] = h.buildCursorSummaryMap() + } respondJSON(w, http.StatusOK, response) } @@ -4204,6 +4255,8 @@ func (h *Handler) Insights(w http.ResponseWriter, r *http.Request) { h.insightsOpenRouter(w, r, rangeDur) case "gemini": h.insightsGemini(w, r, rangeDur) + case "cursor": + h.insightsCursor(w, r, rangeDur) default: respondError(w, http.StatusBadRequest, fmt.Sprintf("unknown provider: %s", provider)) } @@ -4282,6 +4335,9 @@ func (h *Handler) insightsBoth(w http.ResponseWriter, r *http.Request, rangeDur if h.config.HasProvider("gemini") && providerTelemetryEnabled(visibility, "gemini") { response["gemini"] = insightsResponse{Stats: []insightStat{}, Insights: []insightItem{}} } + if h.config.HasProvider("cursor") && providerTelemetryEnabled(visibility, "cursor") { + response["cursor"] = h.buildCursorInsights(hidden, rangeDur) + } respondJSON(w, http.StatusOK, response) } @@ -6614,6 +6670,8 @@ func (h *Handler) CycleOverview(w http.ResponseWriter, r *http.Request) { h.cycleOverviewOpenRouter(w, r) case "gemini": h.cycleOverviewGemini(w, r) + case "cursor": + h.cycleOverviewCursor(w, r) default: respondError(w, http.StatusBadRequest, fmt.Sprintf("unknown provider: %s", provider)) } @@ -9987,6 +10045,8 @@ func (h *Handler) LoggingHistory(w http.ResponseWriter, r *http.Request) { h.loggingHistoryOpenRouter(w, r) case "gemini": h.loggingHistoryGemini(w, r) + case "cursor": + h.loggingHistoryCursor(w, r) default: respondError(w, http.StatusBadRequest, fmt.Sprintf("unknown provider: %s", provider)) } @@ -10366,6 +10426,72 @@ func (h *Handler) loggingHistoryCodex(w http.ResponseWriter, r *http.Request) { }) } +func (h *Handler) loggingHistoryCursor(w http.ResponseWriter, r *http.Request) { + if h.store == nil { + respondJSON(w, http.StatusOK, map[string]interface{}{"logs": []interface{}{}}) + return + } + + start, end, limit := h.loggingHistoryRangeAndLimit(r) + snapshots, err := h.store.QueryCursorRange(start, end, limit) + if err != nil { + h.logger.Error("failed to query Cursor snapshots", "error", err) + respondError(w, http.StatusInternalServerError, "failed to query logging history") + return + } + + quotaSet := map[string]bool{} + for _, snap := range snapshots { + for _, q := range snap.Quotas { + quotaSet[q.Name] = true + } + } + + quotaNames := make([]string, 0, len(quotaSet)) + for qn := range quotaSet { + quotaNames = append(quotaNames, qn) + } + if len(quotaNames) == 0 { + quotaNames = []string{"total_usage", "auto_usage", "api_usage"} + } else { + sort.SliceStable(quotaNames, func(i, j int) bool { + left := cursorQuotaOrder(quotaNames[i]) + right := cursorQuotaOrder(quotaNames[j]) + if left != right { + return left < right + } + return quotaNames[i] < quotaNames[j] + }) + } + + capturedAt := make([]time.Time, 0, len(snapshots)) + ids := make([]int64, 0, len(snapshots)) + series := make([]map[string]loggingHistoryCrossQuota, 0, len(snapshots)) + + for _, snap := range snapshots { + capturedAt = append(capturedAt, snap.CapturedAt) + ids = append(ids, snap.ID) + row := make(map[string]loggingHistoryCrossQuota, len(snap.Quotas)) + for _, q := range snap.Quotas { + row[q.Name] = loggingHistoryCrossQuota{ + Name: q.Name, + Value: q.Used, + Limit: q.Limit, + Percent: q.Utilization, + HasValue: q.Used > 0 || q.Limit > 0, + HasLimit: q.Limit > 0, + } + } + series = append(series, row) + } + + respondJSON(w, http.StatusOK, map[string]interface{}{ + "provider": "cursor", + "quotaNames": quotaNames, + "logs": loggingHistoryRowsFromSnapshots(capturedAt, ids, quotaNames, series), + }) +} + // loggingHistoryAntigravity returns Antigravity polling snapshots with deltas. func (h *Handler) loggingHistoryAntigravity(w http.ResponseWriter, r *http.Request) { if h.store == nil { diff --git a/internal/web/menubar.go b/internal/web/menubar.go index 63b87f9..70d7ada 100644 --- a/internal/web/menubar.go +++ b/internal/web/menubar.go @@ -204,7 +204,7 @@ func (h *Handler) buildMenubarProviders(settings *menubar.Settings, includeHidde normalized := settings.Normalize() visibility := h.providerVisibilityMap() - providers := make([]menubar.ProviderCard, 0, 8) + providers := make([]menubar.ProviderCard, 0, 10) latest := time.Time{} if h.config != nil && h.config.HasProvider("synthetic") && h.providerDashboardVisible("synthetic", visibility) { @@ -327,6 +327,15 @@ func (h *Handler) buildMenubarProviders(settings *menubar.Settings, includeHidde } } } + if h.config != nil && h.config.HasProvider("cursor") && h.providerDashboardVisible("cursor", visibility) { + payload := h.buildCursorCurrent() + if card := normalizeProviderCard("cursor", "Cursor", "", payload, normalized.WarningPercent, normalized.CriticalPercent); card != nil { + providers = append(providers, *card) + if captured := parseCapturedAt(payload); captured.After(latest) { + latest = captured + } + } + } sortProviderCards(providers, normalized.ProvidersOrder) if !includeHidden { diff --git a/internal/web/menubar_test.go b/internal/web/menubar_test.go index cdc2f1a..03f2687 100644 --- a/internal/web/menubar_test.go +++ b/internal/web/menubar_test.go @@ -159,6 +159,48 @@ func TestMenubarTestEndpointPreservesMinimalView(t *testing.T) { } } +func TestMenubarSummaryIncludesCursorWhenEnabled(t *testing.T) { + t.Setenv("ONWATCH_TEST_MODE", "1") + + s, err := store.New(":memory:") + if err != nil { + t.Fatalf("store.New: %v", err) + } + defer s.Close() + + now := time.Now().UTC() + resetTime := now.Add(2 * time.Hour) + insertTestCursorSnapshot(t, s, now, api.CursorAccountIndividual, "Pro", []api.CursorQuota{ + {Name: "total_usage", Used: 50, Limit: 100, Utilization: 50, Format: api.CursorFormatPercent, ResetsAt: &resetTime}, + }) + + h := NewHandler(s, nil, nil, nil, createTestConfigWithCursor()) + + req := httptest.NewRequest(http.MethodGet, "/api/menubar/summary", nil) + rr := httptest.NewRecorder() + + h.MenubarSummary(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d body=%s", rr.Code, rr.Body.String()) + } + + var snapshot menubar.Snapshot + if err := json.Unmarshal(rr.Body.Bytes(), &snapshot); err != nil { + t.Fatalf("json.Unmarshal: %v", err) + } + var found bool + for _, p := range snapshot.Providers { + if p.ID == "cursor" && p.BaseProvider == "cursor" { + found = true + break + } + } + if !found { + t.Fatalf("expected cursor provider card, got: %#v", snapshot.Providers) + } +} + func TestMenubarSummaryUsesConfiguredThresholds(t *testing.T) { t.Setenv("ONWATCH_TEST_MODE", "1") diff --git a/internal/web/static/app.js b/internal/web/static/app.js index c3bfebe..0c719c8 100644 --- a/internal/web/static/app.js +++ b/internal/web/static/app.js @@ -66,6 +66,8 @@ function getCurrentProvider() { if (openrouterGrid) return 'openrouter'; const geminiGrid = document.getElementById('quota-grid-gemini'); if (geminiGrid) return 'gemini'; + const cursorGrid = document.getElementById('quota-grid-cursor'); + if (cursorGrid) return 'cursor'; const grid = document.getElementById('quota-grid'); return (grid && grid.dataset.provider) || 'synthetic'; } @@ -82,10 +84,22 @@ function providerParam() { return param; } -function shouldShowHistoryTables(provider = getCurrentProvider()) { +function shouldShowSessionsTable(provider = getCurrentProvider()) { + return provider !== 'both' && provider !== 'cursor' && provider !== 'api-integrations'; +} + +function shouldShowCyclesTable(provider = getCurrentProvider()) { return provider !== 'both' && provider !== 'api-integrations'; } +function shouldShowOverviewTable(provider = getCurrentProvider()) { + return provider !== 'both' && provider !== 'gemini' && provider !== 'api-integrations'; +} + +function shouldShowHistoryTables(provider = getCurrentProvider()) { + return shouldShowSessionsTable(provider) || shouldShowCyclesTable(provider) || shouldShowOverviewTable(provider); +} + function getBothViewProviders() { const tabs = document.querySelectorAll('#provider-tabs .provider-tab[data-provider]'); if (tabs.length > 0) { @@ -325,9 +339,9 @@ function refreshAll() { const refreshBtn = document.getElementById('refresh-btn'); if (refreshBtn) refreshBtn.classList.add('spinning'); const tasks = [fetchCurrent(), fetchDeepInsights(), fetchHistory()]; - if (shouldShowHistoryTables()) { - tasks.push(fetchCycles(), fetchSessions(), fetchCycleOverview()); - } + if (shouldShowCyclesTable()) tasks.push(fetchCycles()); + if (shouldShowSessionsTable()) tasks.push(fetchSessions()); + if (shouldShowOverviewTable()) tasks.push(fetchCycleOverview()); Promise.all(tasks).finally(() => { if (refreshBtn) setTimeout(() => refreshBtn.classList.remove('spinning'), 600); }); @@ -823,10 +837,12 @@ function codexVisibleQuotaNames(planType) { const anthropicQuotaOrder = ['five_hour', 'seven_day', 'seven_day_sonnet', 'monthly_limit', 'extra_usage']; const codexQuotaOrder = ['five_hour', 'seven_day', 'code_review']; +const cursorQuotaOrder = ['total_usage', 'auto_usage', 'api_usage', 'credits', 'on_demand']; function quotaOrderForProvider(provider) { if (provider === 'anthropic') return anthropicQuotaOrder; if (provider === 'codex') return codexQuotaOrder; + if (provider === 'cursor') return cursorQuotaOrder; return []; } @@ -949,12 +965,36 @@ const geminiDisplayNames = { 'flash_lite': 'Gemini Flash Lite', }; +const cursorDisplayNames = { + 'total_usage': 'Total Usage', + 'auto_usage': 'Auto + Composer', + 'api_usage': 'API Usage', + 'credits': 'Credits', + 'on_demand': 'On-Demand', +}; + const geminiChartColorMap = { 'pro': { border: '#4285F4', bg: 'rgba(66, 133, 244, 0.08)' }, 'flash': { border: '#34A853', bg: 'rgba(52, 168, 83, 0.08)' }, 'flash_lite': { border: '#FBBC04', bg: 'rgba(251, 188, 4, 0.08)' }, }; +const cursorChartColorMap = { + 'total_usage': { border: '#6366f1', bg: 'rgba(99, 102, 241, 0.08)' }, + 'auto_usage': { border: '#8b5cf6', bg: 'rgba(139, 92, 246, 0.08)' }, + 'api_usage': { border: '#a78bfa', bg: 'rgba(167, 139, 250, 0.08)' }, + 'credits': { border: '#10b981', bg: 'rgba(16, 185, 129, 0.08)' }, + 'on_demand': { border: '#f59e0b', bg: 'rgba(245, 158, 11, 0.08)' }, +}; +const cursorChartColorFallback = [ + { border: '#6366f1', bg: 'rgba(99, 102, 241, 0.08)' }, + { border: '#8b5cf6', bg: 'rgba(139, 92, 246, 0.08)' }, + { border: '#a78bfa', bg: 'rgba(167, 139, 250, 0.08)' }, + { border: '#10b981', bg: 'rgba(16, 185, 129, 0.08)' }, + { border: '#f59e0b', bg: 'rgba(245, 158, 11, 0.08)' }, + { border: '#ef4444', bg: 'rgba(239, 68, 68, 0.08)' }, +]; + const geminiChartColorFallback = [ { border: '#4285F4', bg: 'rgba(66, 133, 244, 0.08)' }, { border: '#34A853', bg: 'rgba(52, 168, 83, 0.08)' }, @@ -1003,7 +1043,14 @@ const renewalCategories = { openrouter: [ { label: 'Credits', groupBy: 'credits' } ], - gemini: [] + gemini: [], + cursor: [ + { label: 'Total Usage', groupBy: 'total_usage' }, + { label: 'Auto + Composer', groupBy: 'auto_usage' }, + { label: 'API Usage', groupBy: 'api_usage' }, + { label: 'Credits', groupBy: 'credits' }, + { label: 'On-Demand', groupBy: 'on_demand' } + ] }; const overviewQuotaDisplayNames = { @@ -1024,7 +1071,11 @@ const overviewQuotaDisplayNames = { antigravity_claude_gpt: 'Claude + GPT Quota', antigravity_gemini_pro: 'Gemini Pro Quota', antigravity_gemini_flash: 'Gemini Flash Quota', - credits: 'Credits' + credits: 'Credits', + total_usage: 'Total Usage', + auto_usage: 'Auto + Composer', + api_usage: 'API Usage', + on_demand: 'On-Demand' }; // Provider-specific display name overrides @@ -2046,6 +2097,18 @@ function renderGeminiQuotaCards(quotas, containerId) { }); } +function renderCursorQuotaCards(quotas, containerId) { + const container = document.getElementById(containerId); + if (!container) return; + + if (!Array.isArray(quotas) || quotas.length === 0) { + container.innerHTML = '

No Cursor data available

'; + return; + } + + container.innerHTML = renderProviderKPIHTML(normalizeBothQuotas('cursor', { quotas })); +} + function updateGeminiCard(q) { const key = `gemini-${q.modelId}`; const prev = State.currentQuotas[key]; @@ -3312,6 +3375,10 @@ async function fetchCurrent() { } data.quotas.forEach(q => updateGeminiCard(q)); } + } else if (provider === 'cursor') { + if (data.quotas) { + renderCursorQuotaCards(data.quotas || [], 'quota-grid-cursor'); + } } else if (provider === 'openrouter') { if (data.credits) { const container = document.getElementById('quota-grid-openrouter'); @@ -3537,12 +3604,23 @@ async function fetchDeepInsights() { // Render stats if (statsEl) { statsEl.innerHTML = allStats.length > 0 ? allStats.map(s => - `
-
${s.value}
-
${s.label}
- ${s.sublabel ? `
${s.sublabel}
` : ''} -
` + (s.metric || s.severity || s.desc) + ? buildEnrichedStatHTML(s) + : `
+
${escapeHTML(s.value)}
+
${escapeHTML(s.label)}
+ ${s.sublabel ? `
${escapeHTML(s.sublabel)}
` : ''} +
` ).join('') : ''; + statsEl.querySelectorAll('.insight-card').forEach(card => { + attachInsightCardEvents(card, statsEl); + }); + statsEl.querySelectorAll('.insight-eye-btn').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + toggleInsightVisibility(btn.dataset.key); + }); + }); } // Render insight cards @@ -3812,6 +3890,28 @@ function renderBothInsights(data, statsEl, cardsEl) { }); } +function buildEnrichedStatHTML(s) { + const icon = insightIcons[s.severity] || insightIcons.info; + const hideBtn = s.key ? `` : ''; + const chevron = s.desc ? `` : ''; + const displayMetric = s.metric || s.value || '--'; + return `
+
+ ${icon} + ${escapeHTML(s.label)} + + ${escapeHTML(displayMetric)} + ${s.sublabel ? `${escapeHTML(s.sublabel)}` : ''} + + ${hideBtn} + ${chevron} +
+ ${s.desc ? `
${escapeHTML(s.desc)}
` : ''} +
`; +} + function buildInsightCardsHTML(insights) { if (insights.length === 0) return '

Keep tracking to see deep analytics.

'; return insights.map((i, idx) => { @@ -3982,6 +4082,8 @@ function initChart() { defaultDatasets = []; // MiniMax datasets are dynamic - populated when history data arrives } else if (provider === 'gemini') { defaultDatasets = []; // Gemini datasets are dynamic - populated when history data arrives + } else if (provider === 'cursor') { + defaultDatasets = []; // Cursor datasets are dynamic - populated when history data arrives } else if (provider === 'openrouter') { defaultDatasets = []; // OpenRouter datasets are dynamic - populated when history data arrives } else if (provider === 'zai') { @@ -4003,6 +4105,8 @@ function initChart() { ? [] : provider === 'gemini' ? [] + : provider === 'cursor' + ? [] : provider === 'openrouter' ? [] : provider === 'api-integrations' @@ -4312,6 +4416,44 @@ async function fetchHistory(range) { return; } + if (provider === 'cursor') { + const quotaKeys = new Set(); + historyRows.forEach(d => { + if (Array.isArray(d.quotas)) d.quotas.forEach(q => quotaKeys.add(q.name)); + }); + const sortedKeys = sortQuotaKeysForProvider(quotaKeys, 'cursor'); + let fallbackIdx = 0; + const datasets = []; + sortedKeys.forEach((key) => { + const color = cursorChartColorMap[key] || cursorChartColorFallback[fallbackIdx++ % cursorChartColorFallback.length]; + const rawData = historyRows.map(d => { + const q = Array.isArray(d.quotas) ? d.quotas.find(quota => quota.name === key) : null; + return { x: new Date(d.capturedAt), y: q ? (q.utilization || 0) : 0 }; + }); + const { data, gapSegments, pointRadii } = processDataWithGaps(rawData, range); + datasets.push({ + label: cursorDisplayNames[key] || key, + data: data, + borderColor: color.border, + backgroundColor: color.bg, + fill: true, + tension: 0.4, + borderWidth: 2, + pointRadius: pointRadii, + pointHoverRadius: 4, + hidden: State.hiddenQuotas.has(key), + spanGaps: true, + segment: getSegmentStyle(gapSegments, color.border) + }); + }); + State.chart.data.datasets = datasets; + updateTimeScale(State.chart, range); + State.chartYMax = computeYMax(State.chart.data.datasets, State.chart); + State.chart.options.scales.y.max = State.chartYMax; + State.chart.update(); + return; + } + if (provider === 'minimax') { const quotaKeys = new Set(); historyRows.forEach(d => { @@ -4513,6 +4655,7 @@ const bothProviderNames = { antigravity: 'Antigravity', minimax: 'MiniMax', gemini: 'Gemini', + cursor: 'Cursor', 'api-integrations': 'API Integrations', }; @@ -4816,7 +4959,11 @@ function buildAllProviderEntries() { provider, cardKey: sanitizeProviderCardKey(provider), title: bothProviderNames[provider] || toTitleCase(provider), - badge: provider === 'copilot' ? 'Beta' : toTitleCase(payload.planType || ''), + badge: provider === 'copilot' + ? 'Beta' + : (provider === 'cursor' + ? (payload.planName || toTitleCase(payload.accountType || '')) + : toTitleCase(payload.planType || '')), promoHtml: provider === 'anthropic' && payload.promo ? promoTagHTML() : '', planType: payload.planType || '', quotas: normalizeBothQuotas(provider, payload), @@ -4929,28 +5076,39 @@ function renderAPIIntegrationsSummaryCard(entry, collapsed) { } function getSingleViewInsightStats(provider, stats) { - if (provider !== 'minimax' && provider !== 'gemini') return stats; + if (provider !== 'minimax' && provider !== 'gemini' && provider !== 'cursor') return stats; + const preferred = provider === 'cursor' + ? ['Plan', 'Total Usage Burn Rate', 'Auto + Composer Burn Rate', 'API Usage Burn Rate'] + : ['Current Usage', 'Burn Rate', 'Resets In']; return sortItemsByPreference( stats.filter((stat) => stat && stat.label !== 'Current Status'), - ['Current Usage', 'Burn Rate', 'Resets In'], + preferred, (stat) => stat.label ); } function getSingleViewInsightCards(provider, insights) { - if (provider !== 'minimax' && provider !== 'gemini' && provider !== 'openrouter') return insights; - const filtered = insights.filter((insight) => !['shared_status', 'burn_rate'].includes(insight.key)); + if (provider !== 'minimax' && provider !== 'gemini' && provider !== 'openrouter' && provider !== 'cursor') return insights; + const filtered = provider === 'cursor' + ? insights.filter((insight) => !insight.key || !insight.key.startsWith('forecast_')) + : insights.filter((insight) => !['shared_status', 'burn_rate'].includes(insight.key)); + const preferred = provider === 'cursor' + ? [] + : ['trend', 'efficiency', 'burn_rate', 'shared_status']; return sortItemsByPreference( - filtered.length > 0 ? filtered : insights, - ['trend', 'efficiency', 'burn_rate', 'shared_status'], + filtered.length > 0 ? filtered : (provider === 'cursor' ? [] : insights), + preferred, (insight) => insight.key ); } function getCompactProviderStats(provider, stats) { - const preferred = provider === 'minimax' || provider === 'gemini' || provider === 'openrouter' - ? ['Burn Rate', 'Current Usage', 'Resets In', 'Current Status'] - : []; + let preferred = []; + if (provider === 'cursor') { + preferred = ['Plan', 'Total Usage Burn Rate', 'Auto + Composer Burn Rate', 'API Usage Burn Rate']; + } else if (provider === 'minimax' || provider === 'gemini' || provider === 'openrouter') { + preferred = ['Burn Rate', 'Current Usage', 'Resets In', 'Current Status']; + } const ordered = preferred.length > 0 ? sortItemsByPreference(stats, preferred, (stat) => stat.label) : stats; @@ -4958,9 +5116,12 @@ function getCompactProviderStats(provider, stats) { } function getCompactProviderInsights(provider, insights) { - const ordered = provider === 'minimax' || provider === 'gemini' || provider === 'openrouter' - ? sortItemsByPreference(insights, ['efficiency', 'trend', 'burn_rate', 'shared_status'], (insight) => insight.key) - : insights; + let ordered = insights; + if (provider === 'cursor') { + ordered = sortItemsByPreference(insights, ['forecast_total_usage', 'forecast_auto_usage', 'forecast_api_usage'], (insight) => insight.key); + } else if (provider === 'minimax' || provider === 'gemini' || provider === 'openrouter') { + ordered = sortItemsByPreference(insights, ['efficiency', 'trend', 'burn_rate', 'shared_status'], (insight) => insight.key); + } const urgent = ordered.filter((insight) => ['warning', 'negative'].includes(insight.severity)); return urgent.slice(0, 1); } @@ -4971,10 +5132,12 @@ function renderProviderInsightsHTML(provider, payload) { const items = []; stats.forEach((stat) => { - items.push(`
+ const displayValue = stat.metric || stat.value || '--'; + const severity = (stat.metric && stat.severity) ? stat.severity : 'info'; + items.push(`
${escapeHTML(stat.label || 'Metric')} - ${escapeHTML(stat.value || '--')} + ${escapeHTML(displayValue)}
${stat.sublabel ? `
${escapeHTML(compactInsightText(stat.sublabel, 48))}
` : ''}
`); @@ -5333,6 +5496,17 @@ function buildProviderCardDatasets(provider, rows, range) { if (provider === 'gemini') { return buildDynamicDatasetsForRows(rows, range, geminiDisplayNames, geminiChartColorMap, geminiChartColorFallback, 'gemini'); } + if (provider === 'cursor') { + const normalizedRows = rows.map((row) => { + if (!Array.isArray(row.quotas)) return row; + const entry = { capturedAt: row.capturedAt }; + row.quotas.forEach((quota) => { + entry[quota.name] = quota.utilization || 0; + }); + return entry; + }); + return buildDynamicDatasetsForRows(normalizedRows, range, cursorDisplayNames, cursorChartColorMap, cursorChartColorFallback, 'cursor'); + } if (provider === 'openrouter') { const orDisplayNames = { usage: 'Total Usage', usageDaily: 'Daily Usage', percent: 'Usage %' }; const orColors = { @@ -5495,6 +5669,9 @@ function updateBothCharts(data, range = '6h') { if (activeProviders.has('gemini') && Array.isArray(data.gemini) && data.gemini.length > 0) { slots.push({ id: 'gemini', label: 'Gemini', provider: 'gemini', rows: data.gemini }); } + if (activeProviders.has('cursor') && Array.isArray(data.cursor) && data.cursor.length > 0) { + slots.push({ id: 'cursor', label: 'Cursor', provider: 'cursor', rows: data.cursor }); + } if (activeProviders.has('codex')) { if (Array.isArray(data.codexAccounts) && data.codexAccounts.length > 0) { data.codexAccounts.forEach((account, idx) => { @@ -5610,6 +5787,16 @@ function updateBothCharts(data, range = '6h') { datasets = createDynamicDatasets(slot.rows, minimaxDisplayNames, minimaxChartColorMap, minimaxChartColorFallback, 'minimax'); } else if (slot.provider === 'gemini') { datasets = createDynamicDatasets(slot.rows, geminiDisplayNames, geminiChartColorMap, geminiChartColorFallback, 'gemini'); + } else if (slot.provider === 'cursor') { + const normalizedRows = slot.rows.map((row) => { + if (!Array.isArray(row.quotas)) return row; + const entry = { capturedAt: row.capturedAt }; + row.quotas.forEach((quota) => { + entry[quota.name] = quota.utilization || 0; + }); + return entry; + }); + datasets = createDynamicDatasets(normalizedRows, cursorDisplayNames, cursorChartColorMap, cursorChartColorFallback, 'cursor'); } else if (slot.provider === 'openrouter') { const orDN = { usage: 'Total Usage', usageDaily: 'Daily Usage', percent: 'Usage %' }; const orCM = { usage: { border: '#0D9488', bg: 'rgba(13, 148, 136, 0.06)' }, usageDaily: { border: '#F59E0B', bg: 'rgba(245, 158, 11, 0.06)' }, percent: { border: '#3B82F6', bg: 'rgba(59, 130, 246, 0.06)' } }; @@ -5753,14 +5940,14 @@ function updateTimeScale(chart, range) { // ── Cycles Table (client-side search/sort/paginate) ── async function fetchCycles() { - if (!shouldShowHistoryTables()) return; + if (!shouldShowCyclesTable()) return; const requestProvider = getCurrentProvider(); const requestAccount = requestProvider === 'codex' ? State.codexAccount : null; const requestRange = State.cyclesRange; const requestSeq = (State.cyclesRequestSeq || 0) + 1; State.cyclesRequestSeq = requestSeq; const provider = requestProvider; - const loggingHistoryProviders = new Set(['synthetic', 'zai', 'anthropic', 'copilot', 'codex', 'antigravity', 'minimax', 'gemini']); + const loggingHistoryProviders = new Set(['synthetic', 'zai', 'anthropic', 'copilot', 'codex', 'antigravity', 'minimax', 'gemini', 'cursor']); if (loggingHistoryProviders.has(provider)) { // Convert range from ms to days (min 1, max 30) @@ -5908,7 +6095,7 @@ function renderCyclesTable() { const provider = getCurrentProvider(); const quotaNames = State.cyclesQuotaNames; - const usePercent = provider === 'anthropic' || provider === 'copilot' || provider === 'codex' || provider === 'antigravity' || provider === 'minimax' || provider === 'gemini' || provider === 'openrouter'; + const usePercent = provider === 'anthropic' || provider === 'copilot' || provider === 'codex' || provider === 'antigravity' || provider === 'minimax' || provider === 'gemini' || provider === 'openrouter' || provider === 'cursor'; const deltaUsesPercent = usePercent && provider !== 'minimax'; const isLoggingHistory = State.isLoggingHistory === true; @@ -6037,7 +6224,9 @@ function renderCyclesTable() { const colCount = isLoggingHistory ? (2 + quotaNames.length) : (5 + quotaNames.length); if (pageData.length === 0) { - const emptyMsg = isLoggingHistory ? 'No logging data in this range.' : 'No polling data in this range.'; + const emptyMsg = isLoggingHistory + ? (provider === 'cursor' ? 'No Cursor usage samples in this range.' : 'No logging data in this range.') + : (provider === 'cursor' ? 'No Cursor billing-cycle samples in this range.' : 'No polling data in this range.'); tbody.innerHTML = `${emptyMsg}`; } else { tbody.innerHTML = pageData.map(row => { @@ -6131,7 +6320,7 @@ function renderCyclesTable() { // ── Sessions Table (client-side search/sort/paginate + expandable rows) ── async function fetchSessions() { - if (!shouldShowHistoryTables()) return; + if (!shouldShowSessionsTable()) return; const requestProvider = getCurrentProvider(); // Hide sessions section for providers without session tracking. const sessionsEl = document.getElementById('sessions-section'); @@ -7096,7 +7285,8 @@ function getOverviewCategories() { ...(renewalCategories.antigravity || []), ...(renewalCategories.minimax || []), ...(renewalCategories.openrouter || []), - ...(renewalCategories.gemini || []) + ...(renewalCategories.gemini || []), + ...(renewalCategories.cursor || []) ]; } if (provider === 'codex') { @@ -7172,7 +7362,7 @@ function truncateLabel(str, maxLen) { } async function fetchCycleOverview() { - if (!shouldShowHistoryTables()) return; + if (!shouldShowOverviewTable()) return; const provider = getCurrentProvider(); const requestProvider = provider; const requestAccount = requestProvider === 'codex' ? State.codexAccount : null; @@ -7228,7 +7418,7 @@ function renderOverviewTable() { const quotaNames = State.overviewQuotaNames; const overviewProv = getOverviewProvider(); - const usePercent = overviewProv === 'anthropic' || overviewProv === 'codex' || overviewProv === 'antigravity' || overviewProv === 'minimax' || overviewProv === 'gemini' || overviewProv === 'openrouter'; + const usePercent = overviewProv === 'anthropic' || overviewProv === 'codex' || overviewProv === 'antigravity' || overviewProv === 'minimax' || overviewProv === 'gemini' || overviewProv === 'openrouter' || overviewProv === 'cursor'; const deltaUsesPercent = usePercent && overviewProv !== 'minimax'; // Build dynamic header @@ -7309,7 +7499,10 @@ function renderOverviewTable() { if (pageData.length === 0) { const colCount = 5 + quotaNames.length; - tbody.innerHTML = `No completed cycles found for this period.`; + const emptyMsg = overviewProv === 'cursor' + ? 'No completed monthly billing cycles found for this quota yet.' + : 'No completed cycles found for this period.'; + tbody.innerHTML = `${emptyMsg}`; } else { tbody.innerHTML = pageData.map(row => { const start = row.cycleStart || null; @@ -7507,9 +7700,9 @@ function setupHeaderActions() { refreshBtn.addEventListener('click', () => { refreshBtn.classList.add('spinning'); const tasks = [fetchCurrent(), fetchDeepInsights(), fetchHistory()]; - if (shouldShowHistoryTables()) { - tasks.push(fetchCycles(), fetchSessions(), fetchCycleOverview()); - } + if (shouldShowCyclesTable()) tasks.push(fetchCycles()); + if (shouldShowSessionsTable()) tasks.push(fetchSessions()); + if (shouldShowOverviewTable()) tasks.push(fetchCycleOverview()); Promise.all(tasks).finally(() => { setTimeout(() => refreshBtn.classList.remove('spinning'), 600); }); @@ -7554,11 +7747,9 @@ function startAutoRefresh() { // Always refresh above-fold data fetchCurrent(); fetchDeepInsights(); fetchHistory(); // Only refresh below-fold sections that have been loaded - if (shouldShowHistoryTables()) { - if (_lazyLoaded.has('.cycles-section')) fetchCycles(); - if (_lazyLoaded.has('.cycle-overview-section')) fetchCycleOverview(); - if (_lazyLoaded.has('.sessions-section')) fetchSessions(); - } + if (shouldShowCyclesTable() && _lazyLoaded.has('.cycles-section')) fetchCycles(); + if (shouldShowOverviewTable() && _lazyLoaded.has('.cycle-overview-section')) fetchCycleOverview(); + if (shouldShowSessionsTable() && _lazyLoaded.has('.sessions-section')) fetchSessions(); }, REFRESH_INTERVAL); } @@ -9650,6 +9841,7 @@ function addOverrideRow(quotaKey, provider, warning, critical, isAbsolute, disab + @@ -9946,13 +10138,17 @@ document.addEventListener('DOMContentLoaded', async () => { // Preload providers whose history tables should appear immediately. const activeProvider = getCurrentProvider(); - const eagerHistoryProviders = new Set(['antigravity', 'minimax', 'gemini']); - if (eagerHistoryProviders.has(activeProvider) && shouldShowHistoryTables(activeProvider)) { - _lazyLoaded.add('.cycles-section'); - _lazyLoaded.add('.sessions-section'); - fetchCycles(); - fetchSessions(); - if (activeProvider !== 'gemini') { + const eagerHistoryProviders = new Set(['antigravity', 'minimax', 'gemini', 'cursor']); + if (eagerHistoryProviders.has(activeProvider)) { + if (shouldShowCyclesTable(activeProvider)) { + _lazyLoaded.add('.cycles-section'); + fetchCycles(); + } + if (shouldShowSessionsTable(activeProvider)) { + _lazyLoaded.add('.sessions-section'); + fetchSessions(); + } + if (shouldShowOverviewTable(activeProvider)) { _lazyLoaded.add('.cycle-overview-section'); fetchCycleOverview(); } @@ -9965,11 +10161,13 @@ document.addEventListener('DOMContentLoaded', async () => { } // Below-fold: lazy-load when sections scroll into view - if (shouldShowHistoryTables(activeProvider)) { + if (shouldShowCyclesTable(activeProvider)) { lazyLoadOnVisible('.cycles-section', () => fetchCycles()); - if (activeProvider !== 'gemini') { - lazyLoadOnVisible('.cycle-overview-section', () => fetchCycleOverview()); - } + } + if (shouldShowOverviewTable(activeProvider)) { + lazyLoadOnVisible('.cycle-overview-section', () => fetchCycleOverview()); + } + if (shouldShowSessionsTable(activeProvider)) { lazyLoadOnVisible('.sessions-section', () => fetchSessions()); } diff --git a/internal/web/templates/dashboard.html b/internal/web/templates/dashboard.html index 94d2ec0..9d99db7 100644 --- a/internal/web/templates/dashboard.html +++ b/internal/web/templates/dashboard.html @@ -12,7 +12,7 @@ {{range .Providers}} {{end}} @@ -117,6 +117,9 @@

Dashboard

{{else if eq .CurrentProvider "gemini"}}
+ {{else if eq .CurrentProvider "cursor"}} + +
{{else if eq .CurrentProvider "api-integrations"}}
@@ -471,7 +474,7 @@

{{end}} - {{if and (ne .CurrentProvider "both") (ne .CurrentProvider "api-integrations")}} + {{if and (ne .CurrentProvider "both") (ne .CurrentProvider "api-integrations") (ne .CurrentProvider "cursor")}}

@@ -557,7 +560,9 @@

+ {{end}} + {{if and (ne .CurrentProvider "both") (ne .CurrentProvider "api-integrations")}}

@@ -565,7 +570,7 @@

- Logging History + {{if eq .CurrentProvider "cursor"}}Usage Samples{{else}}Logging History{{end}}

@@ -579,7 +584,7 @@

- Group + {{if eq .CurrentProvider "cursor"}}Bucket{{else}}Group{{end}}
@@ -605,14 +610,14 @@

- No polling data yet. Tracking begins on first poll. + {{if eq .CurrentProvider "cursor"}}No usage samples yet. Cursor tracking begins after the first successful sync.{{else}}No polling data yet. Tracking begins on first poll.{{end}}

@@ -623,11 +628,11 @@

- Cycle Overview + {{if eq .CurrentProvider "cursor"}}Billing Cycle Overview{{else}}Cycle Overview{{end}}

- Period + {{if eq .CurrentProvider "cursor"}}Quota{{else}}Period{{end}}