Add Ledger: a menu-bar app for Cursor spend#103
Open
kyleve wants to merge 27 commits into
Open
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
A new native-macOS menu bar app, Ledger, that shows your current-cycle Cursor spend at a glance (with a year-to-date total in the popover). Building it also restores the native-macOS build infrastructure that was removed with Foreman (#68) — the
.macOSplatform, a.macapp target + hostless macOS test bundle, the platform-scoped CI scheme, and thetest-macosjob.Menu-bar title shows the live cycle-to-date amount (a
$—placeholder until the first fetch); clicking opens a popover with this cycle's spend + date range, year-to-date, included-usage used/limit, and plan tier.Data source
Works for individual accounts by calling the same undocumented endpoints the
cursor.com/dashboard/usagepage uses (the team Admin API only sees team accounts):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 the YTD figure.Auth is zero-config: it auto-detects your Cursor session (read-only, from the local app's
state.vscdb→cursorAuth/accessToken) and builds the requiredWorkosCursorSessionToken=<userId>::<jwt>cookie, derivinguserIdfrom the JWTsub(a bare JWT 401s). Settings › Account has an optional paste-a-token override (stored in the Keychain) for when auto-detect fails or the session expires.Structure
Ledger/LedgerCore(macOS-only SPM library) —LedgerServices(@MainActor @Observableroot with a singleLoadState),SessionToken/CursorLocalTokenSource,DashboardProvider/CursorDashboardAPI,UsageSummary/MonthlyInvoice/SpendSnapshot,KeychainStore, andLoginItemController(launch-at-login, restored from Foreman).Ledger/Ledger(.mac,LSUIElement) — the AppKitNSStatusItem+NSPopovershell (title driven by anObservationsloop, deliberately notMenuBarExtra), a System-Settings-style sidebar, and the thinLedgerSessionfacade..macOS(.v26)+LedgerCoreinPackage.swift;Ledger/LedgerCoreTeststargets,Ledger/Ledger-macOS-Testsschemes inProject.swift; thetest-macosCI job; rootAGENTS.md+ per-moduleREADME.md/AGENTS.md.Notes / caveats
$figures reflect included-compute value, not money owed.Verification
./swiftformat --lint— cleantuist generate --no-open— cleantuist test Ledger-macOS-Tests— 26 tests pass (incl. a real SQLite round-trip for the token reader and the JWT→cookie derivation)tuist build Stuff-iOS-Tests— iOS scheme unaffected