Skip to content

Add Ledger: a menu-bar app for Cursor spend#103

Open
kyleve wants to merge 34 commits into
mainfrom
cursor/ledger-spend-menubar
Open

Add Ledger: a menu-bar app for Cursor spend#103
kyleve wants to merge 34 commits into
mainfrom
cursor/ledger-spend-menubar

Conversation

@kyleve

@kyleve kyleve commented Jul 17, 2026

Copy link
Copy Markdown
Owner

What

A new native-macOS menu bar app, Ledger, that shows your current-cycle Cursor spend at a glance. Building it also restores the native-macOS build infrastructure removed with Foreman (#68) — the .macOS platform, a .mac app target + hostless macOS test bundle, the platform-scoped CI scheme, and the test-macos job.

The menu-bar item shows the current-cycle amount (cents dropped, digits roll over on change). Clicking opens a popover with:

  • This cycle spend + billing-cycle date range, and the plan tier as a badge.
  • Today and this week spend (differenced from locally recorded history — hidden until enough history exists).
  • Included usage as two bars — first-party/Auto vs third-party/API — since a single blended figure hides that one pool can be maxed while the other is barely used.
  • Top models this cycle as usage shares (each model ≥5% gets its own bar; smaller ones roll into one multi-colored "Other models" bar with a legend), with model ids parsed into friendly names + badges (e.g. Claude Opus 5 · high).
  • A Refresh button, Settings, and Quit.

Data source

Works for individual accounts by calling the same undocumented endpoints cursor.com/dashboard uses (the team Admin API only sees team accounts):

  • GET /api/usage-summary — cycle dates, plan, included-usage pool percentages, and live usage-based spend (individualUsage.onDemand.used).
  • POST /api/dashboard/get-filtered-usage-events — individual events (per-event model + chargedCents), paginated over the cycle and summed per model for the breakdown. Must be called without teamId for an individual account, or it 401s.

Auth is zero-config: it auto-detects your Cursor session read-only from the local app's state.vscdb (cursorAuth/accessToken) and builds the required WorkosCursorSessionToken=<userId>::<jwt> cookie, deriving userId from the JWT sub (a bare JWT 401s). Settings › Account has an optional paste-a-token override (Keychain-stored) for when auto-detect fails or the session expires. The resolved token is cached and re-read only when it can actually change — the pasted value is edited, the API returns 401, or Settings appears.

Today / this week

onDemand.used is a cycle-cumulative running total, so SpendHistory records timestamped samples (SpendHistoryStore, JSON in Application Support, pruned to ~14 days) and differences them — today = since local midnight, this week = since the start of the calendar week. These are real billed dollars, and the diff captures spend even while the app was closed, as long as a sample exists near the window start. Each figure is hidden until there's a baseline.

Cost & resilience

  • The headline refreshes every 5 minutes (configurable in Settings); opening the popover does not fetch. The per-model breakdown is throttled separately to at most every 15 minutes, since it walks every usage event in the cycle — an explicit Refresh or a cycle rollover bypasses the throttle.
  • A failed refresh keeps the last data on screen and turns the "Updated…" caption into an amber stale warning (reason on hover) rather than blanking. The full error screen shows only before anything has loaded. A per-model failure likewise keeps the last good breakdown.
  • Only the newest request may mutate state (including recorded history), so a superseded response can't skew day/week baselines.
  • The API gets a dedicated ephemeral session with no cookie storage and a 30s timeout: auth is the Cookie header we set explicitly, and URLSession must never substitute a stored one.

Structure

  • Ledger/LedgerCore (macOS-only SPM library) — LedgerServices (@MainActor @Observable root, single LoadState), SessionToken/SessionTokenSource/CursorLocalTokenSource, KeychainStore, DashboardProvider/CursorDashboardAPI, wire/view models (UsageSummary, UsageEvent/UsageEventsPage, SpendSnapshot, ModelShare, ModelName), SpendHistory/SpendHistoryStore, and LoginItemController (launch-at-login, restored from Foreman).
  • Ledger/Ledger (.mac, LSUIElement) — AppKit NSStatusItem + NSPopover shell hosting a SwiftUI MenuBarLabel (bound to the observable session; deliberately not MenuBarExtra), a System-Settings-style sidebar, and the thin LedgerSession facade.
  • Ledger/install — builds a Release, ad-hoc-signs it, and installs to /Applications so it runs standalone (no Xcode).
  • Build wiring: .macOS(.v26) + LedgerCore in Package.swift; Ledger/LedgerCoreTests targets and Ledger/Ledger-macOS-Tests schemes in Project.swift; the test-macos CI job; root AGENTS.md + per-module README.md/AGENTS.md.

Notes / caveats

  • The dashboard endpoints are undocumented and can change without notice (e.g. get-aggregated-usage-events went stale for some accounts — returning days-old data and omitting newly released models — which is why the breakdown uses the per-event endpoint).
  • Cursor's state.vscdb is read strictly read-only (SQLITE_OPEN_READONLY, one parameterized SELECT against ItemTable); we never write to it. If the key or path ever moves, auto-detect degrades to "no session found" and the paste fallback takes over.
  • It rides your Cursor web session (re-open Cursor when it expires).
  • The per-model figures are usage shares, not spend: their summed per-event cost is total usage value (included allowance + on-demand), which exceeds the billed on-demand headline.
  • There is no year-to-date total — the monthly-invoice endpoint is a billing ledger with cross-month credit/adjustment lines, so summing it isn't a meaningful yearly figure.

Verification

  • ./swiftformat --lint — clean
  • mise exec -- tuist generate --no-open — clean
  • mise exec -- tuist test Ledger-macOS-Tests -- -destination 'platform=macOS' — full LedgerCore suite passes: parsing/decoding, JWT→cookie derivation, a real SQLite round-trip for the token reader, history differencing, stale-vs-error state, per-model aggregation and pagination, the throttle, token caching/invalidation, and a URLProtocol-stubbed check of the outgoing request shape (no teamId, epoch-ms dates, the session cookie) — mutation-checked to confirm it fails when the rule is broken.
  • mise exec -- tuist build Stuff-iOS-Tests — iOS scheme unaffected
  • The auth-path changes (cookie-less session, token caching) were additionally verified against the live API after installing, since unit tests can't exercise them.

kyleve added 30 commits July 17, 2026 15:15
The model layer for a new Ledger menu-bar app: fetches current-cycle Cursor
spend from the Admin API (POST /teams/spend, Basic auth) and reduces it to the
signed-in team member via a single observable LoadState. The Admin API key
lives in the Keychain (KeychainStore); only the email + refresh interval
persist as JSON (LedgerConfigStore). Restores the .macOS(.v26) platform and a
LedgerCore product/target in Package.swift.

Groundwork for the Ledger app (next commit); builds via swift build.
The native-macOS shell over LedgerCore: an NSStatusItem whose title shows the
current-cycle spend (updated via an Observations loop) toggling an NSPopover
that renders the LoadState, plus a System-Settings-style sidebar (General +
Account panes) where the email and Keychain-stored Admin API key are set.

Wires the Ledger app target (.mac, LSUIElement), the hostless LedgerCoreTests
bundle, the Ledger / Ledger-macOS-Tests schemes, and restores the test-macos
CI job. Verified: swiftformat --lint, tuist generate, and tuist test
Ledger-macOS-Tests all pass (30 tests).
Adds README.md + AGENTS.md for Ledger/Ledger and Ledger/LedgerCore, and
restores the native-macOS references in the root AGENTS.md (deployment row,
the two-platform CI-scheme note, directory-layout mention, and the macOS test
command). CLAUDE.md files regenerated via ./sync-agents (gitignored).
The Admin API (/teams/spend) only sees team accounts, so an individual
account can't use it. Re-point the data layer at the same undocumented
endpoints the cursor.com dashboard itself calls:

- GET /api/usage-summary — current cycle dates, plan, and live usage-based
  spend (individualUsage.onDemand.used).
- POST /api/dashboard/get-monthly-invoice — per-month billed totals, summed
  across the year for a year-to-date figure (the original yearly ask, now
  feasible).

Auth is the WorkOS session cookie, value "<userId>::<jwt>". SessionToken
derives the userId from the JWT sub (a bare JWT 401s). CursorLocalTokenSource
auto-detects it read-only from the local Cursor app's state.vscdb; a pasted
token in the Keychain overrides it. Drops the email + Admin API key UI.

Verified against a live account (usage-summary + invoices) and via
Ledger-macOS-Tests (26 tests); swiftformat --lint clean.
The status item was icon-only until the first fetch, making it easy to lose
among other menu-bar items. Show the current-cycle amount as the title always,
with a "$—" placeholder while loading, so it's a wider, findable target.
Opening the menu-bar popover fired a network fetch every time, which is too
frequent. Rely on the periodic 15-minute refresh loop (which drives both the
title and the popover) plus the explicit Refresh button; opening now just
shows the latest fetched state.
Host a SwiftUI MenuBarLabel in the status item (via a click-through
NSHostingView, so the button still toggles the popover) instead of setting a
plain NSButton title, and give both it and the popover's headline amount
.contentTransition(.numericText()) so digit changes roll over. The hosted
label observes the session directly, replacing the manual Observations title
loop.
A variable-length NSStatusItem doesn't auto-size to a hosted SwiftUI view, so
only the leading icon was visible and the amount was clipped. MenuBarLabel now
reports its rendered width (fixedSize + onGeometryChange) and the app delegate
sets the item's length to match.
Adds an included-usage progress bar (usage-summary totalPercentUsed) with
Cursor's own status messages, and a top-models-this-cycle breakdown from
get-aggregated-usage-events. The per-model figure measures compute differently
from the billed on-demand headline (they don't reconcile), so models are shown
as usage *shares*, not dollars. The aggregated fetch is best-effort — a
failure logs and yields no models rather than failing the whole load.
A script to run Ledger standalone without Xcode: regenerates the project,
builds the Release configuration via xcodebuild (ad-hoc signed, so no Apple
Developer account needed), installs to /Applications (quitting any running
copy first), and launches it. --no-open skips the launch.
quit is asynchronous, so poll until the installed binary has actually exited
(force-killing as a fallback) before rm/cp — making repeated runs race-free.
Silences the 'No App Category is set' build warning by tagging Ledger as a
developer-tools app in its Info.plist.
Refreshing flipped loadState to .loading, which cleared the popover to a
spinner. Now a refresh keeps the last loaded snapshot on screen and drives only
a small header spinner via a new isRefreshing flag; the full loading state is
used only for the very first load. Adds a gated-provider regression test.
The monthly-invoice endpoint is a billing ledger with cross-month adjustments —
negative 'mid-month usage paid for <month>' credit lines that partly cancel the
live cycle figure — so summing months produced a meaningless (often negative)
year-to-date, e.g. -$574.65. Rather than show a wrong number, drop the YTD:
remove yearToDateCents and the now-unused get-monthly-invoice plumbing
(MonthlyInvoice, provider method, prior-month summation, calendar injection).
Current-cycle spend (billed on-demand) remains the reliable headline.
Lower the default cadence from 15 to 5 minutes, and add a Refresh picker to
Settings > General (1 min / 5 min / 15 min / 30 min / hour) bound to the
persisted refreshInterval. Changing it restarts the refresh loop so the new
cadence applies immediately rather than after the current sleep.
Simplify the models breakdown: each model with >=20% share keeps its own bar;
everything below rolls into a single 'Other models' bar with one color segment
per model plus a compact legend, so there are fewer bars on screen. Core now
returns all model shares (AggregatedUsage.modelShares, renamed from
topModels(limit:)); the 20% grouping and coloring are view concerns via a new
segmented ShareBar.
The included-usage percentage appeared twice: in the header above the bar and
again in Cursor's status message below it. Keep the concise header + bar and
remove the status-message lines, along with their now-unused plumbing
(usageMessages / the display-message fields).
Show the plan (e.g. Ultra) as a small badge in the top-right of the popover
header, on the same row as 'Cursor Spend', and drop the separate Plan row (and
the now-unused breakdownRow helper).
A single blended 'included usage' figure (totalPercentUsed) was misleading: it
averaged a barely-used first-party/Auto pool (~1%) with a maxed third-party/API
pool (100%). Show two side-by-side bars from autoPercentUsed/apiPercentUsed
instead (each hidden when the API omits it), which also explains the on-demand
spend. Drops the now-unused totalPercentUsed field.
Parse raw model ids (claude-opus-4-8-thinking-xhigh, github_bugbot,
non-max-composer-2.5-fast, …) into a friendly displayName plus badges
(reasoning effort / speed / mode) via a new LedgerCore ModelName tokenizer:
maps known vendor/family words, collapses hyphenated version numbers (4-8 ->
4.8), drops 'thinking'/hosting prefixes, and title-cases unknown models. The
popover renders the name with small badge chips in the model rows and legend.
- Model badges (xhigh, fast, high, non-max, …) render lowercase.
- Remove the redundant 'This cycle' caption above the headline amount (the
  cycle date range below it already says so).
- Move the 'Updated <time>' caption next to the Refresh button in the footer.
onDemand.used is a cycle-cumulative total, so recording it over time lets us
difference it into real billed per-window spend (unlike the aggregated compute
measure). Adds SpendSample/SpendHistoryStore (JSON in Application Support,
pruned to 14 days) and SpendHistory, which diffs samples with baselines scoped
to the current cycle — today = since local midnight, this week = since the
start of the calendar week. Each figure is hidden until enough history exists
and clamps at 0. Shown as an up-arrow line under the headline amount.
The menu-bar title now shows a glanceable amount — no cents, rounded to the
nearest dollar under $100 and the nearest $10 at $100 and above ($12,
$3,240). The popover keeps full precision.
A dollar-sign-in-a-circle next to the '$' amount text was redundant. Show just
the amount; the status item keeps its 'Cursor spend' accessibility title.
Rounding to the nearest $10 made the menu bar read less than the popover
($3,662.77 -> $3,660), which looked like a discrepancy. Just drop the cents
($3,662), so the menu bar shares the popover's visible digits and never reads
higher than the real amount.
A failed refresh (e.g. losing internet) dropped the popover to the error screen
and the menu bar to $—, even though we had good data. Now a failure while
already loaded keeps the last snapshot on screen and surfaces on a new
loadError, which the footer renders as an amber stale 'Updated…' warning (with
the reason on hover). The full error screen is reserved for a failure with
nothing loaded yet. Also refreshes stale docs (hosted MenuBarLabel, provider
names).
Drop the 'Refresh' label; keep the arrow.clockwise icon with a help tooltip,
matching the Settings and Quit buttons.
The per-model breakdown came from get-aggregated-usage-events, which goes stale
for some accounts (frozen for days, $0 for recent windows) and so omitted newly
released models like Opus 5. Switch to get-filtered-usage-events (fresh,
date-scoped, per-event chargedCents), paginated over the cycle and summed per
model into shares. Drops the now-dead AggregatedUsage/get-aggregated-usage-events
code. Still shown as shares — the summed per-event cost is total usage value
(included + on-demand), above the billed headline. Note: this endpoint must be
called WITHOUT teamId for an individual account (it 401s otherwise).
Two review findings:

1. Walking every usage event in the cycle costs several paginated requests, and
   it ran on every headline refresh — as often as once a minute, which is a
   rate-limit risk against an undocumented endpoint. The breakdown now refetches
   at most every 15 minutes (cache reused in between), bypassed by an explicit
   refresh(force: true) or a cycle rollover. A per-model failure also keeps the
   last good breakdown instead of blanking it.

2. recordHistory ran before the request-generation guard, so a superseded (older)
   response still appended a sample — a lower reading at a later timestamp, which
   can skew later day/week baselines. All state mutation now sits behind the
   guard.

Adds coverage for the throttle, force, cycle-rollover, failure-keeps-cache,
multi-page pagination, and the superseded-refresh case.
Auth is the Cookie header we set explicitly, but requests went out on
URLSession.shared with cookie handling enabled — so a Set-Cookie from a
response could be stored and later sent *instead of* the token we resolved,
surfacing as an intermittent 401 ('session expired') with no trace of the
substitution. Give the API its own ephemeral session with no cookie storage
(accept policy .never, httpShouldSetCookies false) and set
httpShouldHandleCookies = false per request so the guarantee holds for any
injected session. Also sets a 30s request timeout — well under the 60s default,
which matters now that a per-model fetch chains several requests.

Verified against the live API after installing: a fetch succeeded (new history
sample written) and the app's cookie store stays empty.
kyleve added 3 commits July 26, 2026 16:41
Resolving the credential touched the Keychain and opened Cursor's SQLite state
store twice per refresh (once for availability, once to resolve) — synchronous
I/O on the main actor against a database Cursor writes constantly. The token
doesn't change between refreshes, so resolve it once and cache it, dropping the
cache exactly when it can change: the pasted token is edited, the API rejects it
with a 401 (the user may have signed back in to Cursor since), or Settings asks
for a fresh read on appear.

Verified live after installing: a fetch succeeded 10s later.
SpendView's header still described the removed year-to-date total, and
togglePopover's doc claimed it refreshes on open — contradicted by the comment
directly below it, so a future reader could 'restore' the fetch-per-open we
deliberately removed. Also deletes SpendDeltas.none, which has no callers.
No behavior change.
The rules that cost real 401s during development were enforced only by
comments: get-filtered-usage-events must carry no teamId, dates are epoch
milliseconds, and the cookie needs the userId prefix. Stub the transport and
assert on the captured request so a plausible-looking 'fix' (the sibling
endpoint does want teamId) fails here instead of at runtime, where it reads as
an intermittent 'session expired'. Also covers the 401/HTTP/decode mapping and
the httpShouldHandleCookies opt-out, giving CursorDashboardAPI its first direct
coverage.

Mutation-checked: re-adding teamId fails usageEventsSendsNoTeamID.
onChange(window.occlusionState.contains(.visible))
}

deinit {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got to here

The 20% cutoff hid meaningful models in the rollup — Opus 5 is 11% of this
cycle's usage and was collapsed into 'Other models'. At 5% it gets its own row
while the long tail (Bugbot, Composer, one-off models at well under 1%) still
rolls up.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant